From 924d54c46ffdb4b59b66d3972ca231927aeceb4e Mon Sep 17 00:00:00 2001 From: davidjiang8888 Date: Sat, 22 Aug 2026 21:30:55 -0400 Subject: [PATCH 01/21] Add fail-closed golden price lineage proof --- Makefile | 6 +- src/data_update.py | 16 + src/golden_price_lineage_proof.py | 562 +++++++++++++++++++++++ tests/test_data_update.py | 48 ++ tests/test_golden_price_lineage_proof.py | 253 ++++++++++ tests/test_launchers.py | 31 ++ 6 files changed, 915 insertions(+), 1 deletion(-) create mode 100644 src/golden_price_lineage_proof.py create mode 100644 tests/test_golden_price_lineage_proof.py diff --git a/Makefile b/Makefile index 9a5c32084..06a223715 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help help-full next-stage profile-context research-change-snapshot research-change-monitor research-review-queue research-event-review-record demo demo-data-build demo-data-check demo-dashboard demo-dashboard-smoke demo-dashboard-render-smoke local-profile-seed linkedin-share-check public-performance-contract public-performance-gate public-ux-review-checklist public-ux-review-checklist-json public-ux-review-notes public-ux-review-notes-check public-ux-review-note pilot-review-feedback pilot-feedback-closeout browser-qa-evidence browser-qa-capture-plan hosted-demo-readiness pilot-readiness-check pilot-readiness-packet pilot-share-brief trusted-data-pilot trusted-data-pilot-candidates trusted-data-pilot-packet trusted-data-pilot-lane trusted-data-pilot-board trusted-data-pilot-evidence reviewed-data-proof reviewed-data-proof-record reviewed-batch-proof reviewed-batch-proof-record reviewed-batch-compare reviewed-batch-preflight proof-readiness-reconciliation auto-refresh-plan auto-refresh-daily auto-refresh-weekly auto-refresh-optional auto-refresh-runbook auto-refresh-status scheduler-activation-checklist auto-apply-gate lane-outcome-history price-reviewed-run fundamentals-batch-proof peer-batch-proof peer-mapping-source-review peer-mapping-writeback-guard public-demo-readiness-pack readiness-ops-center readiness-queue data-coverage-proof-queues coverage-frontier data-coverage-planner coverage-expansion-loop readiness-ops-evidence reviewed-batch decision-proof-queue metric-readiness metric-readiness-board benchmark-risk-review diff-hygiene diff-hygiene-summary diff-hygiene-files data-release-decision public-release-package public-release-handoff license-status session-source-preflight source-activation-guide provider-setup-checklist provider-smoke fmp-smoke alpha-vantage-smoke finnhub-smoke universe-scope fundamentals-source-ladder fundamentals-source-ladder-queue optional-context-source-ladder optional-context-source-ladder-queue fmp-stage alpha-vantage-stage finnhub-stage yfinance-stage sec-filing-share-stage staged-hygiene-check public-wording-check public-check status status-check test pipeline stock-report stock-report-md local-tickers monthly track-record validate-data data-sources-check data-sources research-health research-health-check risk-context action-queue action-queue-check project-status project-status-check verify validate-all daily dashboard dashboard-smoke sec-stage sec-validate sec-preview sec-apply imports-validate imports-preview imports-apply import-staging universe-preview universe-preview-summary universe-stage universe-apply universe-refresh universe-report universe-active coverage data-wizard unlock-ladder unlock-summary command-bundles command-bundle-details command-bundle-runbook bundle-prices bundle-fundamentals bundle-peers bundle-prices-broader bundle-fundamentals-broader bundle-peers-broader detail-prices detail-fundamentals detail-peers detail-prices-broader detail-fundamentals-broader detail-peers-broader runbook-prices runbook-fundamentals runbook-peers runbook-prices-broader runbook-fundamentals-broader runbook-peers-broader focus-price focus-fundamentals focus-peers onboarding templates price-status price-worklist fundamentals-peer-worklist optional-context-worklist sec-stage-queue peer-mapping-queue dcf-input-proof-queue dcf-input-proof-handoff dcf-input-source-review dcf-input-source-command-plan dcf-input-source-guard share-count-proof-queue price-history-proof-queue price-validate price-preview price-apply price-refresh price-refresh-loop price-normalize import-prices price-coverage dcf-readiness import-fundamentals optional-context-summary optional-context-readiness import-earnings import-analyst-estimates readiness readiness-preview readiness-evidence-remediation golden-evidence-cohort readiness-release-review readiness-release-record readiness-release-guard readiness-materialize readiness-snapshot research-decisions earnings-nowcast-pilot earnings-nowcast-walkthrough earnings-nowcast-templates earnings-nowcast-validate earnings-nowcast-preview earnings-nowcast-readiness earnings-nowcast-prospective-plan +.PHONY: help help-full next-stage profile-context research-change-snapshot research-change-monitor research-review-queue research-event-review-record demo demo-data-build demo-data-check demo-dashboard demo-dashboard-smoke demo-dashboard-render-smoke local-profile-seed linkedin-share-check public-performance-contract public-performance-gate public-ux-review-checklist public-ux-review-checklist-json public-ux-review-notes public-ux-review-notes-check public-ux-review-note pilot-review-feedback pilot-feedback-closeout browser-qa-evidence browser-qa-capture-plan hosted-demo-readiness pilot-readiness-check pilot-readiness-packet pilot-share-brief trusted-data-pilot trusted-data-pilot-candidates trusted-data-pilot-packet trusted-data-pilot-lane trusted-data-pilot-board trusted-data-pilot-evidence reviewed-data-proof reviewed-data-proof-record reviewed-batch-proof reviewed-batch-proof-record reviewed-batch-compare reviewed-batch-preflight proof-readiness-reconciliation auto-refresh-plan auto-refresh-daily auto-refresh-weekly auto-refresh-optional auto-refresh-runbook auto-refresh-status scheduler-activation-checklist auto-apply-gate lane-outcome-history price-reviewed-run fundamentals-batch-proof peer-batch-proof peer-mapping-source-review peer-mapping-writeback-guard public-demo-readiness-pack readiness-ops-center readiness-queue data-coverage-proof-queues coverage-frontier data-coverage-planner coverage-expansion-loop readiness-ops-evidence reviewed-batch decision-proof-queue metric-readiness metric-readiness-board benchmark-risk-review diff-hygiene diff-hygiene-summary diff-hygiene-files data-release-decision public-release-package public-release-handoff license-status session-source-preflight source-activation-guide provider-setup-checklist provider-smoke fmp-smoke alpha-vantage-smoke finnhub-smoke universe-scope fundamentals-source-ladder fundamentals-source-ladder-queue optional-context-source-ladder optional-context-source-ladder-queue fmp-stage alpha-vantage-stage finnhub-stage yfinance-stage sec-filing-share-stage staged-hygiene-check public-wording-check public-check status status-check test pipeline stock-report stock-report-md local-tickers monthly track-record validate-data data-sources-check data-sources research-health research-health-check risk-context action-queue action-queue-check project-status project-status-check verify validate-all daily dashboard dashboard-smoke sec-stage sec-validate sec-preview sec-apply imports-validate imports-preview imports-apply import-staging universe-preview universe-preview-summary universe-stage universe-apply universe-refresh universe-report universe-active coverage data-wizard unlock-ladder unlock-summary command-bundles command-bundle-details command-bundle-runbook bundle-prices bundle-fundamentals bundle-peers bundle-prices-broader bundle-fundamentals-broader bundle-peers-broader detail-prices detail-fundamentals detail-peers detail-prices-broader detail-fundamentals-broader detail-peers-broader runbook-prices runbook-fundamentals runbook-peers runbook-prices-broader runbook-fundamentals-broader runbook-peers-broader focus-price focus-fundamentals focus-peers onboarding templates price-status price-worklist fundamentals-peer-worklist optional-context-worklist sec-stage-queue peer-mapping-queue dcf-input-proof-queue dcf-input-proof-handoff dcf-input-source-review dcf-input-source-command-plan dcf-input-source-guard share-count-proof-queue price-history-proof-queue price-validate price-preview price-apply price-refresh price-refresh-loop price-normalize import-prices price-coverage dcf-readiness import-fundamentals optional-context-summary optional-context-readiness import-earnings import-analyst-estimates readiness readiness-preview readiness-evidence-remediation golden-evidence-cohort golden-price-lineage-proof readiness-release-review readiness-release-record readiness-release-guard readiness-materialize readiness-snapshot research-decisions earnings-nowcast-pilot earnings-nowcast-walkthrough earnings-nowcast-templates earnings-nowcast-validate earnings-nowcast-preview earnings-nowcast-readiness earnings-nowcast-prospective-plan .PHONY: thesis-journal thesis-journal-preview thesis-journal-record .PHONY: prospective-field-proof-status prospective-field-proof-audit prospective-field-proof-preview prospective-field-proof-record @@ -333,6 +333,7 @@ help-full: @echo " make readiness-preview [TOP_N=20] In-memory preview: stable readiness impact, change causes, and promotion evidence without writing files" @echo " make readiness-evidence-remediation [TOP_N=20] [JSON=1] Closest-first inspection-only evidence queue; stdout only" @echo " make golden-evidence-cohort [TOP_N=5] [JSON=1] Five-role inspection-only evidence packet; stdout only" + @echo " make golden-price-lineage-proof [LIVE=1] [REVIEW_CUTOFF=] [JSON=1] Four-name public price candidate proof; stdout only and no apply path" @echo " make readiness-release-review [TOP_N=20] [JSON=1] Review the exact default readiness candidate without writing" @echo " make readiness-release-record PREVIEW_RECEIPT= REVIEWER= REVIEW_DATE= TECHNICAL_DECISION= DISTRIBUTION_DECISION= CONFIRM_REVIEWED=1 Record one exact named review" @echo " make readiness-release-guard RECORD_ID= Revalidate one record and print exact named staging paths" @@ -1490,6 +1491,9 @@ readiness-evidence-remediation: golden-evidence-cohort: @PYTHONDONTWRITEBYTECODE=1 python3 -m src.golden_evidence_cohort --top-n $(or $(TOP_N),5) $(if $(JSON),--json,) +golden-price-lineage-proof: + @PYTHONDONTWRITEBYTECODE=1 python3 -m src.golden_price_lineage_proof $(if $(filter 1 true yes,$(LIVE)),--live,) $(if $(REVIEW_CUTOFF),--review-cutoff "$(REVIEW_CUTOFF)",) $(if $(JSON),--json,) + readiness-release-review: @PYTHONDONTWRITEBYTECODE=1 python3 -m src.readiness_release_review review --project-root . --top-n $(or $(TOP_N),20) $(if $(JSON),--json,) diff --git a/src/data_update.py b/src/data_update.py index 5c848d122..506e6745a 100644 --- a/src/data_update.py +++ b/src/data_update.py @@ -1,5 +1,6 @@ from __future__ import annotations +import hashlib import json import importlib import os @@ -265,12 +266,18 @@ def __init__( base_url: str = "https://query1.finance.yahoo.com/v8/finance/chart", range_days: int = 900, opener: Callable[..., Any] = urlopen, + clock: Callable[[], datetime] | None = None, ) -> None: self.base_url = base_url.rstrip("/") self.range_days = range_days self.opener = opener + self.clock = clock or (lambda: datetime.now(timezone.utc)) + self.last_source_reference = "" + self.last_retrieved_at = "" def fetch_history(self, ticker: str) -> tuple[pd.DataFrame, list[str]]: + self.last_source_reference = "" + self.last_retrieved_at = "" ticker = ticker.upper().strip() symbol = _yahoo_chart_symbol(ticker) period2 = int(time.time()) @@ -291,6 +298,15 @@ def fetch_history(self, ticker: str) -> tuple[pd.DataFrame, list[str]]: suffix = f" for {symbol}" if symbol != ticker else "" return pd.DataFrame(columns=PRICE_COLUMNS), [f"{ticker}: update failed from Yahoo chart endpoint{suffix} ({exc})"] + retrieved_at = self.clock() + if retrieved_at.tzinfo is None or retrieved_at.utcoffset() is None: + raise ValueError("Yahoo chart retrieval clock must be timezone-aware") + self.last_source_reference = ( + f"yahoo-chart:{symbol}:sha256:" + f"{hashlib.sha256(payload.encode('utf-8')).hexdigest()}" + ) + self.last_retrieved_at = retrieved_at.astimezone(timezone.utc).isoformat() + try: parsed = json.loads(payload) except json.JSONDecodeError as exc: diff --git a/src/golden_price_lineage_proof.py b/src/golden_price_lineage_proof.py new file mode 100644 index 000000000..7cd286e3f --- /dev/null +++ b/src/golden_price_lineage_proof.py @@ -0,0 +1,562 @@ +"""Read-only public price-lineage candidate review for the Golden Evidence Cohort.""" + +from __future__ import annotations + +import argparse +import json +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Callable, Mapping, Protocol + +import pandas as pd + +from src.commercial_source_rights import ( + SourceRights, + load_source_rights_registry, + review_commercial_field_scope, +) +from src.data_update import YahooChartDailyPriceSource +from src.golden_evidence_cohort import ( + RESEARCH_ONLY_BOUNDARY, + GoldenEvidenceCohort, + GoldenEvidenceMember, + build_golden_evidence_cohort, +) +from src.paths import resolve_project_root +from src.price_lineage_temporal import review_daily_price_retrieval + + +OPERATING_COHORT_ROLES = {"saved_operating_company", "evidence_gap_control"} +METHOD_FIT_ROLE = "method_fit_exclusion" +PUBLIC_PRICE_SOURCE_ID = "yahoo" + + +class _CandidatePriceSource(Protocol): + source_id: str + last_source_reference: str + last_retrieved_at: str + + def fetch_history(self, ticker: str) -> tuple[pd.DataFrame, list[str]]: + ... + + +@dataclass(frozen=True) +class GoldenPriceLineageProofMember: + ticker: str + cohort_role: str + collection_status: str + observation_date: str + close: float | None + source_id: str + source_reference: str + retrieved_at: str + availability_at: str + review_cutoff: str + temporal_status: str + rights_status: str + price_scope_status: str + blockers: tuple[str, ...] + owner_decision_required: bool + collection_notes: tuple[str, ...] + next_evidence_review_action: str + + +@dataclass(frozen=True) +class GoldenPriceLineageProof: + status: str + provider_source_id: str + saved_snapshot_identity: str + proposed_snapshot_identity: str + review_cutoff: str + members: tuple[GoldenPriceLineageProofMember, ...] + method_fit_exclusions: tuple[str, ...] + live_collection_performed: bool + candidate_collected_count: int + collection_incomplete_count: int + rights_approved_count: int + price_scope_complete_count: int + inspection_only: bool = True + activation_authorized: bool = False + canonical_apply_authorized: bool = False + readiness_materialization_authorized: bool = False + source_rights_change_authorized: bool = False + recommendation_authorized: bool = False + repository_writes: tuple[str, ...] = () + research_only_boundary: str = RESEARCH_ONLY_BOUNDARY + + +@dataclass(frozen=True) +class _CollectedCandidate: + member: GoldenEvidenceMember + frame: pd.DataFrame + source_reference: str + retrieved_at: str + notes: tuple[str, ...] + fetch_failed: bool + + +def _utc_now() -> datetime: + return datetime.now(timezone.utc) + + +def _normalized_cutoff( + review_cutoff: str | None, + clock: Callable[[], datetime], +) -> str: + if review_cutoff: + return review_cutoff + current = clock() + if current.tzinfo is None or current.utcoffset() is None: + raise ValueError("review clock must be timezone-aware") + return current.astimezone(timezone.utc).isoformat() + + +def _operating_members(cohort: GoldenEvidenceCohort) -> tuple[GoldenEvidenceMember, ...]: + return tuple( + member for member in cohort.members if member.cohort_role in OPERATING_COHORT_ROLES + ) + + +def _method_fit_exclusions(cohort: GoldenEvidenceCohort) -> tuple[str, ...]: + return tuple( + member.ticker for member in cohort.members if member.cohort_role == METHOD_FIT_ROLE + ) + + +def _selection( + ticker: str, + frame: pd.DataFrame, +) -> tuple[str, pd.Series | None, str]: + if frame.empty or not {"ticker", "date", "close"}.issubset(frame.columns): + return "fetch_failed", None, "" + normalized = frame.copy() + normalized["ticker"] = normalized["ticker"].astype("string").str.upper().str.strip() + normalized["_proof_date"] = pd.to_datetime( + normalized["date"], errors="coerce", format="mixed" + ) + normalized["_proof_close"] = pd.to_numeric(normalized["close"], errors="coerce") + normalized = normalized.loc[ + normalized["ticker"].eq(ticker) + & normalized["_proof_date"].notna() + & normalized["_proof_close"].notna() + & normalized["_proof_close"].gt(0) + ].copy() + if normalized.empty: + return "no_usable_candidate", None, "" + latest_date = normalized["_proof_date"].max() + latest = normalized.loc[normalized["_proof_date"] == latest_date] + observation_date = pd.Timestamp(latest_date).date().isoformat() + if len(latest) != 1: + return "ambiguous_latest_candidate", None, observation_date + return "candidate_collected", latest.iloc[0], observation_date + + +def _next_action( + ticker: str, + collection_status: str, + blockers: tuple[str, ...], +) -> str: + if collection_status == "not_requested": + return ( + f"Run the explicit live candidate review for {ticker}; this cannot apply prices or rebuild readiness." + ) + if collection_status != "candidate_collected": + return f"Resolve the {collection_status} evidence gap for {ticker}; do not pad or infer a row." + if any(blocker.startswith("missing_provenance:") for blocker in blockers): + return f"Review exact row-level price provenance for {ticker}; do not infer missing evidence." + if any( + blocker + in { + "invalid_observation_date", + "missing_retrieved_at", + "invalid_retrieved_at", + "retrieved_at_timezone_required", + "review_cutoff_required", + "invalid_review_cutoff", + "review_cutoff_timezone_required", + "retrieved_before_observation_available", + "retrieved_after_review_cutoff", + } + for blocker in blockers + ): + return f"Review the exact retrieval timestamp and cutoff evidence for {ticker}." + if any(blocker.startswith("commercial_rights:") for blocker in blockers): + return ( + f"Owner decision required for exact source '{PUBLIC_PRICE_SOURCE_ID}' commercial rights; " + "do not borrow the yfinance registry entry." + ) + if "registered_price_scope_incomplete" in blockers: + return ( + f"Owner review required for exact source '{PUBLIC_PRICE_SOURCE_ID}' registered prices scope." + ) + return ( + f"Candidate evidence for {ticker} is reviewable; a separate reviewed apply and readiness decision remain required." + ) + + +def _not_requested_member( + member: GoldenEvidenceMember, + *, + rights_status: str, + price_scope_status: str, +) -> GoldenPriceLineageProofMember: + blockers = ["live_candidate_collection_required"] + if rights_status != "approved": + blockers.append(f"commercial_rights:{rights_status}") + if price_scope_status != "complete": + blockers.append("registered_price_scope_incomplete") + return GoldenPriceLineageProofMember( + ticker=member.ticker, + cohort_role=member.cohort_role, + collection_status="not_requested", + observation_date="", + close=None, + source_id=PUBLIC_PRICE_SOURCE_ID, + source_reference="", + retrieved_at="", + availability_at="", + review_cutoff="", + temporal_status="not_evaluated", + rights_status=rights_status, + price_scope_status=price_scope_status, + blockers=tuple(blockers), + owner_decision_required=( + rights_status != "approved" or price_scope_status != "complete" + ), + collection_notes=(), + next_evidence_review_action=_next_action( + member.ticker, "not_requested", tuple(blockers) + ), + ) + + +def _review_collected_member( + collected: _CollectedCandidate, + *, + review_cutoff: str, + rights_status: str, + price_scope_status: str, +) -> GoldenPriceLineageProofMember: + member = collected.member + if collected.fetch_failed: + collection_status = "fetch_failed" + blockers = ("candidate_fetch_failed",) + return GoldenPriceLineageProofMember( + ticker=member.ticker, + cohort_role=member.cohort_role, + collection_status=collection_status, + observation_date="", + close=None, + source_id=PUBLIC_PRICE_SOURCE_ID, + source_reference="", + retrieved_at="", + availability_at="", + review_cutoff=review_cutoff, + temporal_status="not_evaluated", + rights_status=rights_status, + price_scope_status=price_scope_status, + blockers=blockers, + owner_decision_required=False, + collection_notes=collected.notes, + next_evidence_review_action=_next_action( + member.ticker, collection_status, blockers + ), + ) + + collection_status, row, observation_date = _selection( + member.ticker, collected.frame + ) + if row is None: + blocker = ( + "ambiguous_latest_candidate_row" + if collection_status == "ambiguous_latest_candidate" + else "no_usable_candidate_row" + ) + blockers = (blocker,) + return GoldenPriceLineageProofMember( + ticker=member.ticker, + cohort_role=member.cohort_role, + collection_status=collection_status, + observation_date=observation_date, + close=None, + source_id=PUBLIC_PRICE_SOURCE_ID, + source_reference=collected.source_reference, + retrieved_at=collected.retrieved_at, + availability_at="", + review_cutoff=review_cutoff, + temporal_status="not_evaluated", + rights_status=rights_status, + price_scope_status=price_scope_status, + blockers=blockers, + owner_decision_required=False, + collection_notes=collected.notes, + next_evidence_review_action=_next_action( + member.ticker, collection_status, blockers + ), + ) + + temporal = review_daily_price_retrieval( + observation_date, + collected.retrieved_at, + review_cutoff=review_cutoff, + ) + blockers: list[str] = [] + if not collected.source_reference: + blockers.append("missing_provenance:source_ref") + if not collected.retrieved_at: + blockers.append("missing_provenance:retrieved_at") + blockers.extend(temporal.blockers) + if rights_status != "approved": + blockers.append(f"commercial_rights:{rights_status}") + if price_scope_status != "complete": + blockers.append("registered_price_scope_incomplete") + blocker_tuple = tuple(dict.fromkeys(blockers)) + return GoldenPriceLineageProofMember( + ticker=member.ticker, + cohort_role=member.cohort_role, + collection_status=collection_status, + observation_date=observation_date, + close=float(row["_proof_close"]), + source_id=PUBLIC_PRICE_SOURCE_ID, + source_reference=collected.source_reference, + retrieved_at=temporal.retrieved_at, + availability_at=temporal.availability_at, + review_cutoff=temporal.review_cutoff, + temporal_status=temporal.status, + rights_status=rights_status, + price_scope_status=price_scope_status, + blockers=blocker_tuple, + owner_decision_required=( + rights_status != "approved" or price_scope_status != "complete" + ), + collection_notes=collected.notes, + next_evidence_review_action=_next_action( + member.ticker, collection_status, blocker_tuple + ), + ) + + +def build_golden_price_lineage_proof( + cohort: GoldenEvidenceCohort, + *, + rights_registry: Mapping[str, SourceRights], + live: bool, + source: _CandidatePriceSource | None = None, + review_cutoff: str | None = None, + clock: Callable[[], datetime] = _utc_now, +) -> GoldenPriceLineageProof: + """Collect or preview exact public price evidence without mutating repository state.""" + + operating = _operating_members(cohort) + exclusions = _method_fit_exclusions(cohort) + scope = review_commercial_field_scope( + rights_registry, PUBLIC_PRICE_SOURCE_ID, ("prices",) + ) + rights_status = scope.rights_status + price_scope_status = "complete" if not scope.missing_supported_fields else "review_required" + + if not live: + members = tuple( + _not_requested_member( + member, + rights_status=rights_status, + price_scope_status=price_scope_status, + ) + for member in operating + ) + return GoldenPriceLineageProof( + status="collection_not_requested", + provider_source_id=PUBLIC_PRICE_SOURCE_ID, + saved_snapshot_identity=cohort.saved_snapshot_identity, + proposed_snapshot_identity=cohort.proposed_snapshot_identity, + review_cutoff="", + members=members, + method_fit_exclusions=exclusions, + live_collection_performed=False, + candidate_collected_count=0, + collection_incomplete_count=len(members), + rights_approved_count=0, + price_scope_complete_count=0, + ) + + if source is None: + raise ValueError("live collection requires an explicit candidate price source") + if str(source.source_id).strip() != PUBLIC_PRICE_SOURCE_ID: + raise ValueError( + f"exact public price source must be '{PUBLIC_PRICE_SOURCE_ID}'" + ) + + collected_rows: list[_CollectedCandidate] = [] + for member in operating: + try: + frame, notes = source.fetch_history(member.ticker) + except Exception as exc: # pragma: no cover - defensive network boundary + frame = pd.DataFrame() + notes = [f"{member.ticker}: candidate collection failed ({exc})"] + collected_rows.append( + _CollectedCandidate( + member=member, + frame=frame, + source_reference=str( + getattr(source, "last_source_reference", "") or "" + ).strip(), + retrieved_at=str( + getattr(source, "last_retrieved_at", "") or "" + ).strip(), + notes=tuple(str(note) for note in notes if str(note).strip()), + fetch_failed=frame.empty, + ) + ) + + cutoff = _normalized_cutoff(review_cutoff, clock) + members = tuple( + _review_collected_member( + collected, + review_cutoff=cutoff, + rights_status=rights_status, + price_scope_status=price_scope_status, + ) + for collected in collected_rows + ) + incomplete_count = sum( + member.collection_status != "candidate_collected" for member in members + ) + candidate_count = len(members) - incomplete_count + rights_approved_count = sum( + member.collection_status == "candidate_collected" + and member.rights_status == "approved" + for member in members + ) + scope_complete_count = sum( + member.collection_status == "candidate_collected" + and member.price_scope_status == "complete" + for member in members + ) + if incomplete_count: + status = "candidate_collection_incomplete" + elif any(member.rights_status != "approved" for member in members): + status = "candidate_evidence_collected_rights_blocked" + elif any(member.price_scope_status != "complete" for member in members): + status = "candidate_evidence_collected_scope_blocked" + elif any(member.blockers for member in members): + status = "candidate_evidence_review_required" + else: + status = "candidate_evidence_reviewable" + return GoldenPriceLineageProof( + status=status, + provider_source_id=PUBLIC_PRICE_SOURCE_ID, + saved_snapshot_identity=cohort.saved_snapshot_identity, + proposed_snapshot_identity=cohort.proposed_snapshot_identity, + review_cutoff=cutoff, + members=members, + method_fit_exclusions=exclusions, + live_collection_performed=True, + candidate_collected_count=candidate_count, + collection_incomplete_count=incomplete_count, + rights_approved_count=rights_approved_count, + price_scope_complete_count=scope_complete_count, + ) + + +def render_golden_price_lineage_proof_json( + packet: GoldenPriceLineageProof, +) -> str: + return json.dumps( + asdict(packet), ensure_ascii=False, separators=(",", ":"), sort_keys=True + ) + + +def render_golden_price_lineage_proof(packet: GoldenPriceLineageProof) -> str: + lines = [ + "Golden Cohort Price Lineage Proof", + "", + f"status={packet.status}", + f"provider_source_id={packet.provider_source_id}", + f"saved_snapshot_identity={packet.saved_snapshot_identity}", + f"proposed_snapshot_identity={packet.proposed_snapshot_identity}", + f"review_cutoff={packet.review_cutoff or ''}", + f"members={len(packet.members)}", + f"method_fit_exclusions={','.join(packet.method_fit_exclusions) or 'none'}", + ] + for member in packet.members: + lines.extend( + [ + f"- {member.ticker}: role={member.cohort_role}; collection_status={member.collection_status}", + f" observation_date={member.observation_date or ''}; close={member.close if member.close is not None else ''}", + f" source_id={member.source_id}", + f" source_reference={member.source_reference or ''}", + f" retrieved_at={member.retrieved_at or ''}", + f" availability_at={member.availability_at or ''}", + f" review_cutoff={member.review_cutoff or ''}", + f" temporal_status={member.temporal_status}", + f" rights_status={member.rights_status}", + f" price_scope_status={member.price_scope_status}", + f" blockers={','.join(member.blockers) or 'none'}", + f" owner_decision_required={str(member.owner_decision_required).lower()}", + f" collection_notes={' | '.join(member.collection_notes) or 'none'}", + f" next_evidence_review_action={member.next_evidence_review_action}", + ] + ) + lines.extend( + [ + "", + f"live_collection_performed={str(packet.live_collection_performed).lower()}", + f"candidate_collected_count={packet.candidate_collected_count}", + f"collection_incomplete_count={packet.collection_incomplete_count}", + f"rights_approved_count={packet.rights_approved_count}", + f"price_scope_complete_count={packet.price_scope_complete_count}", + "inspection_only=true", + "activation_authorized=false", + "canonical_apply_authorized=false", + "readiness_materialization_authorized=false", + "source_rights_change_authorized=false", + "recommendation_authorized=false", + "repository_writes=[]", + RESEARCH_ONLY_BOUNDARY, + ] + ) + return "\n".join(lines) + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Collect or preview public price-lineage candidates for the Golden Evidence Cohort." + ) + parser.add_argument("--project-root", default=".") + parser.add_argument("--data-dir") + parser.add_argument("--live", action="store_true") + parser.add_argument("--review-cutoff") + parser.add_argument("--json", action="store_true") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = _parser().parse_args(argv) + project_root = resolve_project_root(args.project_root) + registry = load_source_rights_registry(project_root / "config" / "source_rights.yml") + cohort = build_golden_evidence_cohort( + project_root, + data_dir=Path(args.data_dir) if args.data_dir else None, + top_n=5, + rights_registry=registry, + review_cutoff=args.review_cutoff, + ) + source = YahooChartDailyPriceSource() if args.live else None + packet = build_golden_price_lineage_proof( + cohort, + rights_registry=registry, + live=args.live, + source=source, + review_cutoff=args.review_cutoff, + ) + print( + render_golden_price_lineage_proof_json(packet) + if args.json + else render_golden_price_lineage_proof(packet) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_data_update.py b/tests/test_data_update.py index b29e562e3..0e2e90b0c 100644 --- a/tests/test_data_update.py +++ b/tests/test_data_update.py @@ -1,5 +1,7 @@ import json +import hashlib import sys +from datetime import datetime, timezone from pathlib import Path import pandas as pd @@ -200,6 +202,52 @@ def opener(request, timeout: int): assert "unofficial Yahoo chart endpoint" in warnings[0] +def test_yahoo_chart_source_captures_exact_payload_reference_and_retrieval_time(): + payload = json.dumps( + { + "chart": { + "result": [ + { + "timestamp": [1767312000], + "indicators": { + "quote": [ + { + "open": [100.0], + "high": [102.0], + "low": [99.0], + "close": [101.0], + "volume": [12345], + } + ], + "adjclose": [{"adjclose": [100.5]}], + }, + } + ], + "error": None, + } + }, + separators=(",", ":"), + ) + + def opener(_request, timeout: int): + assert timeout == 20 + return FakeHTTPResponse(payload) + + source = YahooChartDailyPriceSource( + opener=opener, + clock=lambda: datetime(2026, 8, 22, 20, 30, tzinfo=timezone.utc), + ) + + frame, _warnings = source.fetch_history("AMD") + + assert len(frame) == 1 + assert source.last_source_reference == ( + "yahoo-chart:AMD:sha256:" + + hashlib.sha256(payload.encode("utf-8")).hexdigest() + ) + assert source.last_retrieved_at == "2026-08-22T20:30:00+00:00" + + def test_yahoo_chart_source_uses_provider_symbol_alias_but_preserves_local_ticker(): seen: dict[str, str] = {} diff --git a/tests/test_golden_price_lineage_proof.py b/tests/test_golden_price_lineage_proof.py new file mode 100644 index 000000000..60990009e --- /dev/null +++ b/tests/test_golden_price_lineage_proof.py @@ -0,0 +1,253 @@ +import pandas as pd + +from src.commercial_source_rights import SourceRights +from src.golden_evidence_cohort import GoldenEvidenceCohort, GoldenEvidenceMember +from src.golden_price_lineage_proof import ( + build_golden_price_lineage_proof, + render_golden_price_lineage_proof, + render_golden_price_lineage_proof_json, +) + + +def _rights( + source_id: str, + *, + commercial_use: str = "approved", + supported_fields: tuple[str, ...] = ("prices",), +) -> SourceRights: + return SourceRights( + source_id=source_id, + display_name=source_id, + permitted_use="source_backed_research", + commercial_use=commercial_use, + redistribution="derived_data_only", + storage_limits="reviewed rows only", + attribution="required", + rate_limits="provider terms", + authentication="none", + expected_freshness="market_data_dependent", + supported_fields=supported_fields, + fallback_priority=1, + ) + + +def _member( + ticker: str, + role: str, + *, + asset_type: str = "company", +) -> GoldenEvidenceMember: + return GoldenEvidenceMember( + ticker=ticker, + asset_type=asset_type, + cohort_role=role, + selection_reason="fixture", + state="fixture", + saved_readiness_identity=f"saved:{ticker}", + proposed_readiness_identity=f"proposed:{ticker}", + usable_evidence_lanes=(), + withheld_evidence_lanes=("price_lineage",), + source_identifiers=(), + source_rights_states=(), + missing_registered_fields=("prices",), + provenance_omissions=(), + temporal_evidence_omissions=(), + price_lineage_omissions=("source", "source_ref", "retrieved_at"), + method_fit_exclusions=("non_operating_asset_type",) if role == "method_fit_exclusion" else (), + independent_blockers=("price_lineage",), + owner_decision_required=False, + next_evidence_review_action="fixture", + saved_research_loop_status="partial_saved_evidence_only", + research_only_boundary="Research workflow evidence only; no security action guidance.", + ) + + +def _cohort() -> GoldenEvidenceCohort: + return GoldenEvidenceCohort( + status="inspection_only", + saved_snapshot_identity="saved:cohort", + proposed_snapshot_identity="proposed:cohort", + members=( + _member("AMD", "saved_operating_company"), + _member("AVGO", "saved_operating_company"), + _member("COHR", "saved_operating_company"), + _member("ABAT", "evidence_gap_control"), + _member("QQQ", "method_fit_exclusion", asset_type="etf"), + ), + top_n=5, + ) + + +def _price_rows(ticker: str) -> pd.DataFrame: + return pd.DataFrame( + [ + { + "date": "2026-08-19", + "ticker": ticker, + "open": 99.0, + "high": 102.0, + "low": 98.0, + "close": 101.0, + "adj_close": 101.0, + "volume": 1000, + }, + { + "date": "2026-08-20", + "ticker": ticker, + "open": 100.0, + "high": 103.0, + "low": 99.0, + "close": 102.0, + "adj_close": 102.0, + "volume": 1100, + }, + ] + ) + + +class FakeYahooSource: + source_id = "yahoo" + + def __init__(self, payloads: dict[str, pd.DataFrame | None]) -> None: + self.payloads = payloads + self.calls: list[str] = [] + self.last_source_reference = "" + self.last_retrieved_at = "" + + def fetch_history(self, ticker: str) -> tuple[pd.DataFrame, list[str]]: + self.calls.append(ticker) + payload = self.payloads.get(ticker) + if payload is None: + self.last_source_reference = "" + self.last_retrieved_at = "" + return pd.DataFrame(), [f"{ticker}: source unavailable"] + self.last_source_reference = f"yahoo-chart:{ticker}:sha256:{ticker.lower()}-fixture" + self.last_retrieved_at = "2026-08-22T20:30:00+00:00" + return payload.copy(), [f"{ticker}: research-grade candidate"] + + +def test_preview_names_exact_operating_targets_without_fetching_or_borrowing_yfinance_rights(): + packet = build_golden_price_lineage_proof( + _cohort(), + rights_registry={"yfinance": _rights("yfinance")}, + live=False, + ) + + assert packet.status == "collection_not_requested" + assert [member.ticker for member in packet.members] == ["AMD", "AVGO", "COHR", "ABAT"] + assert packet.method_fit_exclusions == ("QQQ",) + assert all(member.collection_status == "not_requested" for member in packet.members) + assert all(member.source_id == "yahoo" for member in packet.members) + assert all(member.rights_status == "unknown_source" for member in packet.members) + assert all(member.price_scope_status == "review_required" for member in packet.members) + assert packet.live_collection_performed is False + assert packet.candidate_collected_count == 0 + assert packet.collection_incomplete_count == 4 + assert packet.activation_authorized is False + assert packet.repository_writes == () + + +def test_live_collection_preserves_exact_candidate_evidence_and_stays_rights_blocked(): + source = FakeYahooSource( + {ticker: _price_rows(ticker) for ticker in ("AMD", "AVGO", "COHR", "ABAT", "QQQ")} + ) + + packet = build_golden_price_lineage_proof( + _cohort(), + rights_registry={"yfinance": _rights("yfinance")}, + live=True, + source=source, + review_cutoff="2026-08-22T21:00:00Z", + ) + + assert source.calls == ["AMD", "AVGO", "COHR", "ABAT"] + assert packet.status == "candidate_evidence_collected_rights_blocked" + assert packet.method_fit_exclusions == ("QQQ",) + assert all(member.collection_status == "candidate_collected" for member in packet.members) + assert all(member.observation_date == "2026-08-20" for member in packet.members) + assert all(member.close == 102.0 for member in packet.members) + assert all(member.source_reference.endswith(f"{member.ticker.lower()}-fixture") for member in packet.members) + assert all(member.retrieved_at == "2026-08-22T20:30:00+00:00" for member in packet.members) + assert all(member.temporal_status == "temporal_complete" for member in packet.members) + assert all(member.rights_status == "unknown_source" for member in packet.members) + assert all(member.price_scope_status == "review_required" for member in packet.members) + assert all(member.owner_decision_required for member in packet.members) + assert all( + member.blockers == ( + "commercial_rights:unknown_source", + "registered_price_scope_incomplete", + ) + for member in packet.members + ) + assert packet.activation_authorized is False + assert packet.repository_writes == () + + +def test_approved_exact_yahoo_source_can_be_reviewable_but_never_authorizes_activation(): + source = FakeYahooSource({ticker: _price_rows(ticker) for ticker in ("AMD", "AVGO", "COHR", "ABAT")}) + + packet = build_golden_price_lineage_proof( + _cohort(), + rights_registry={"yahoo": _rights("yahoo")}, + live=True, + source=source, + review_cutoff="2026-08-22T21:00:00Z", + ) + + assert packet.status == "candidate_evidence_reviewable" + assert all(member.rights_status == "approved" for member in packet.members) + assert all(member.price_scope_status == "complete" for member in packet.members) + assert all(member.blockers == () for member in packet.members) + assert all(member.owner_decision_required is False for member in packet.members) + assert packet.activation_authorized is False + assert packet.canonical_apply_authorized is False + assert packet.readiness_materialization_authorized is False + assert packet.source_rights_change_authorized is False + + +def test_fetch_failure_and_ambiguous_latest_rows_fail_closed_without_padding(): + ambiguous = _price_rows("AVGO") + ambiguous = pd.concat([ambiguous, ambiguous.tail(1)], ignore_index=True) + source = FakeYahooSource( + { + "AMD": None, + "AVGO": ambiguous, + "COHR": _price_rows("COHR"), + "ABAT": _price_rows("ABAT"), + } + ) + + packet = build_golden_price_lineage_proof( + _cohort(), + rights_registry={"yahoo": _rights("yahoo")}, + live=True, + source=source, + review_cutoff="2026-08-22T21:00:00Z", + ) + + assert packet.status == "candidate_collection_incomplete" + assert [member.ticker for member in packet.members] == ["AMD", "AVGO", "COHR", "ABAT"] + assert packet.members[0].collection_status == "fetch_failed" + assert packet.members[0].blockers == ("candidate_fetch_failed",) + assert packet.members[1].collection_status == "ambiguous_latest_candidate" + assert packet.members[1].blockers == ("ambiguous_latest_candidate_row",) + assert len(packet.members) == 4 + + +def test_renderers_are_deterministic_and_keep_all_authorization_boundaries_visible(): + packet = build_golden_price_lineage_proof( + _cohort(), + rights_registry={"yfinance": _rights("yfinance")}, + live=False, + ) + + assert render_golden_price_lineage_proof_json(packet) == render_golden_price_lineage_proof_json(packet) + rendered = render_golden_price_lineage_proof(packet) + assert "live_collection_performed=false" in rendered + assert "method_fit_exclusions=QQQ" in rendered + assert "activation_authorized=false" in rendered + assert "canonical_apply_authorized=false" in rendered + assert "readiness_materialization_authorized=false" in rendered + assert "source_rights_change_authorized=false" in rendered + assert "repository_writes=[]" in rendered + assert "Research workflow evidence only; no security action guidance." in rendered diff --git a/tests/test_launchers.py b/tests/test_launchers.py index c2a3f1d0d..d8ec85fd8 100644 --- a/tests/test_launchers.py +++ b/tests/test_launchers.py @@ -200,6 +200,37 @@ def test_golden_evidence_cohort_make_is_deterministic_json_and_write_free(): assert _tree_manifest(root) == before +def test_golden_price_lineage_proof_make_defaults_to_no_live_collection_and_no_writes(): + root = Path.cwd() + before = _tree_manifest(root) + + result = subprocess.run( + ["make", "--no-print-directory", "golden-price-lineage-proof", "JSON=1"], + cwd=root, + capture_output=True, + text=True, + check=False, + env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"}, + ) + + assert result.returncode == 0, result.stderr + payload = json.loads(result.stdout) + assert payload["status"] == "collection_not_requested" + assert payload["live_collection_performed"] is False + assert [member["ticker"] for member in payload["members"]] == [ + "AMD", + "AVGO", + "COHR", + "ABAT", + ] + assert payload["method_fit_exclusions"] == ["QQQ"] + assert payload["canonical_apply_authorized"] is False + assert payload["readiness_materialization_authorized"] is False + assert payload["source_rights_change_authorized"] is False + assert payload["repository_writes"] == [] + assert _tree_manifest(root) == before + + def _run_isolated_golden_make(root: Path, fixture_root: Path, *, json_output: bool): command = [ "make", From 19d59bea64d2a37f69024d60ab195c0b50467d27 Mon Sep 17 00:00:00 2001 From: davidjiang8888 Date: Sat, 22 Aug 2026 22:16:35 -0400 Subject: [PATCH 02/21] Ignore Git metadata in launcher tree manifests --- tests/test_launchers.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_launchers.py b/tests/test_launchers.py index d8ec85fd8..e6429cabd 100644 --- a/tests/test_launchers.py +++ b/tests/test_launchers.py @@ -17,7 +17,10 @@ def _makefile_targets(makefile: str | None = None) -> set[str]: def _tree_manifest(root: Path) -> dict[str, tuple[str, bytes | None]]: manifest = {".": ("directory", None)} for path in sorted(root.rglob("*")): - relative = path.relative_to(root).as_posix() + relative_path = path.relative_to(root) + if relative_path.parts and relative_path.parts[0] == ".git": + continue + relative = relative_path.as_posix() if path.is_symlink(): manifest[relative] = ("symlink", os.readlink(path).encode()) elif path.is_dir(): From e3a66258c38189b2eca403c518267361154e9bda Mon Sep 17 00:00:00 2001 From: davidjiang8888 Date: Wed, 26 Aug 2026 20:58:39 -0400 Subject: [PATCH 03/21] Plan Journey Repair v1 --- .../plans/2026-08-26-journey-repair-v1.md | 602 ++++++++++++++++++ .../2026-08-26-journey-repair-v1-design.md | 162 +++++ 2 files changed, 764 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-26-journey-repair-v1.md create mode 100644 docs/superpowers/specs/2026-08-26-journey-repair-v1-design.md diff --git a/docs/superpowers/plans/2026-08-26-journey-repair-v1.md b/docs/superpowers/plans/2026-08-26-journey-repair-v1.md new file mode 100644 index 000000000..52ca0be9a --- /dev/null +++ b/docs/superpowers/plans/2026-08-26-journey-repair-v1.md @@ -0,0 +1,602 @@ +# Journey Repair v1 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make Discover evidence-access first, preserve selected-company return context through Monitor and Advanced Evidence, and show only a neutral report state until authoritative saved evidence is ready. + +**Architecture:** Keep the existing Streamlit routes and research engines. Add small pure presentation/route helpers around the current Discover readiness frame, Personal Research navigation, and shared single-stock lifecycle; reuse the current report builder and evidence routes unchanged. Route state distinguishes selected-company analysis (`ticker`) from Monitor return context (`return_ticker`). + +**Tech Stack:** Python 3.12, Streamlit, pandas, deterministic HTML/CSS helpers, pytest, repository browser gates, in-app Browser. + +**Spec:** `docs/superpowers/specs/2026-08-26-journey-repair-v1-design.md` + +## Global Constraints + +- Work only in `/Users/yjian070/Documents/New project/.worktrees/journey-repair-v1` on `codex/journey-repair-v1`. +- The implementation base is `19d59bea64d2a37f69024d60ab195c0b50467d27` unless a ledgered ruling says otherwise. +- Preserve all seven pre-existing auxiliary worktrees; do not delete, clean, reset, or modify them. +- Preserve `data/`, `outputs/`, readiness artifacts, proof ledgers, `docs/assets/`, and generated research outputs byte-for-byte. +- Never use `git add -A`; stage only named files for each commit. +- Follow TDD: every production behavior starts with a focused failing test whose failure is observed and recorded. +- Reuse the current design system, route vocabulary, and HTML helpers. Do not use Figma or create a new app/template. +- Discover availability remains alphabetical readiness-only evidence access, not strict eligibility, a ranking, expected return, or a recommendation. +- Monitor remains the existing focused-cohort/workspace follow-up surface; `return_ticker` never filters Monitor data. +- Missing, invalid, stale, candidate-only, rights-blocked, or unsupported evidence remains fail-closed. +- `build_stock_report()` remains the sole authority for a rendered report answer. No pre-payload readiness claim may be shown. +- No data/source/provider/rights/readiness calculation, canonical row, proof record, session persistence contract, or report content changes. +- No merge, push, deploy, publication, GitHub setting change, or external side effect. +- Research-only: no broker integration, auto-trading, fabricated evidence, recommendation, or direct buy/sell instruction. + +--- + +### Task 1: Make Discover evidence-access first + +**Files:** +- Modify: `src/dashboard.py:6355-6386,30190-30342,30918-31200,37395-37448` +- Modify: `tests/test_research_mode_dashboard_contract.py:412-559,1690-1729` +- Modify: `tests/test_dashboard_helpers.py:2196-2540` + +**Interfaces:** +- Consumes: existing `DailyQueueBuildStatus.result.eligible` and the readiness-only frame returned by `discover_saved_company_browse_frame(...)`. +- Produces: `discover_primary_answer_html(saved_company_count: int, strict_eligible_count: int) -> str` and `discover_quick_company_links_html(frame: pd.DataFrame, *, limit: int = 4) -> str`. +- Produces: optional `strict_eligible_count` input on `render_stock_selector(...)`; public selector behavior is unchanged when it is absent. + +- [ ] **Step 1: Write failing pure-helper tests** + +Add tests with literal expectations: + +```python +def test_discover_primary_answer_separates_saved_access_from_strict_eligibility(): + rendered = dashboard.discover_primary_answer_html(8, 0) + + assert "8 saved companies are available for evidence review" in rendered + assert "0 currently pass the strict screen" in rendered + assert "alphabetical" in rendered.lower() + assert "not a ranking" in rendered.lower() + + +def test_discover_quick_links_are_alphabetical_non_ranked_company_briefs(): + frame = dashboard.pd.DataFrame({"Ticker": ["NVDA", "AMD", "AVGO", "COHR", "TSLA"]}) + + rendered = dashboard.discover_quick_company_links_html(frame, limit=4) + + assert [rendered.index(ticker) for ticker in ("AMD", "AVGO", "COHR", "NVDA")] == sorted( + rendered.index(ticker) for ticker in ("AMD", "AVGO", "COHR", "NVDA") + ) + assert "TSLA" not in rendered + assert rendered.count("page=company-workbench") == 4 + assert "ranking" in rendered.lower() +``` + +- [ ] **Step 2: Run the helper tests and verify RED** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest -q -p no:cacheprovider \ + tests/test_dashboard_helpers.py \ + -k 'discover_primary_answer or discover_quick_links' +``` + +Expected: collection failures because the two helpers do not exist. + +- [ ] **Step 3: Implement the minimal pure helpers** + +Add helpers beside the existing Discover browser helpers. Use +`answer_panel_html(...)`, `SafeRouteAction`, `html.escape`, `_quoted_ticker`, +and the existing Company Workbench route. Singular/plural copy must remain +grammatical for 0, 1, and many saved companies. Sort a copied ticker series +case-insensitively, remove blanks and duplicates, and cap at `max(limit, 0)`. + +The primary answer must use this exact semantic copy: + +```python +saved_count = max(int(saved_company_count), 0) +eligible_count = max(int(strict_eligible_count), 0) +saved_noun = "saved company" if saved_count == 1 else "saved companies" +answer = ( + f"{saved_count:,} {saved_noun} {'is' if saved_count == 1 else 'are'} " + f"available for evidence review; {eligible_count:,} currently pass the strict screen." +) +reason = ( + "Saved-company availability and strict-screen eligibility are separate. " + "The links below are alphabetical evidence paths, not a ranking; strict thresholds remain unchanged." +) +``` + +Every quick link label is `Open {TICKER} Company Brief` and targets +`?mode=research&page=company-workbench&ticker={encoded}&open=1`. + +- [ ] **Step 4: Run helper tests and verify GREEN** + +Run the Step 2 command. Expected: the new tests pass with pristine output. + +- [ ] **Step 5: Write a failing Discover integration contract** + +Add a test that drives `render_personal_research_route(selected_page="Discover", ...)` +with eight saved cohort members and an empty strict queue. Capture Markdown +and widget calls in one ordered `events` list and assert: + +```python +primary_index = next( + index for index, event in enumerate(events) + if event[0] == "markdown" and "8 saved companies are available" in event[1] +) +search_index = events.index(("text_input", "Search saved companies")) +assert "0 currently pass the strict screen" in events[primary_index][1] +assert primary_index < search_index +assert all(ticker in " ".join(event[1] for event in events if event[0] == "markdown") + for ticker in ("AMD", "AVGO", "COHR", "NVDA")) +``` + +Also assert the strict queue detail remains available inside +`Advanced: cohort readiness context` and the selector receives the live +strict count rather than a literal. + +- [ ] **Step 6: Run the integration test and verify RED** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest -q -p no:cacheprovider \ + tests/test_research_mode_dashboard_contract.py \ + -k 'discover and evidence_access_first' +``` + +Expected: failure because strict-screen messaging still owns the first answer +and no compact quick-link row exists. + +- [ ] **Step 7: Integrate the live counts and quick links** + +Pass `len(daily_queue.result.eligible)` into `render_stock_selector(...)` as +`strict_eligible_count`. In the exact Research Discover branch, render the +new primary answer and compact quick links immediately after the selector +frame is calculated, before search, filters, and the full result cards. + +Do not call `render_daily_research_queue(... include_details=False)` as a +second first-screen answer. Keep `render_daily_research_queue_details(...)` +inside the existing advanced cohort expander so strict evidence is preserved. +Public Stock Selector and Operator paths must not render the new Discover +answer. + +- [ ] **Step 8: Run focused and adjacent tests** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest -q -p no:cacheprovider \ + tests/test_dashboard_helpers.py \ + tests/test_research_mode_dashboard_contract.py \ + -k 'discover or saved_company or stock_selector' +``` + +Expected: all selected tests pass with no warnings. + +- [ ] **Step 9: Commit Task 1** + +```bash +git add src/dashboard.py tests/test_dashboard_helpers.py tests/test_research_mode_dashboard_contract.py +git commit -m "Improve Discover evidence access" +``` + +--- + +### Task 2: Preserve Monitor return context and mark Advanced Evidence location + +**Files:** +- Modify: `src/dashboard_navigation.py:77-89,201-279` +- Modify: `src/research_workspace.py:1388-1470,1570-1585` +- Modify: `src/dashboard.py:36700-36820,37093-37239,37457-37758` +- Modify: `tests/test_dashboard_navigation.py:171-248` +- Modify: `tests/test_research_workspace.py:1606-1685` +- Modify: `tests/test_research_mode_dashboard_contract.py:70-115,1155-1252,2089-2181` +- Modify if behavior text changes: `docs/PERSONAL_RESEARCH_MODE.md` + +**Interfaces:** +- Consumes: registered local tickers from the existing provider. +- Produces: Monitor route key `return_ticker`; `validated_research_return_ticker(value: object, registered_tickers: Iterable[object]) -> str`; `research_monitor_return_link(ticker: str) -> dict[str, str]`. +- Extends: `research_workflow_navigation_html(active_page: str, ticker: str = "")` with a secondary current-location marker for evidence routes. + +- [ ] **Step 1: Write failing route and helper tests** + +Add literal behavior tests: + +```python +def test_research_monitor_canonical_query_preserves_return_ticker_only(): + query = { + "mode": "research", + "page": "monitor", + "return_ticker": "BRK/B", + "ticker": "NVDA", + "open": "1", + } + assert nav.canonical_workspace_query("research", "Monitor", query) == { + "mode": "research", + "page": "monitor", + "return_ticker": "BRK/B", + } + + +def test_validated_monitor_return_ticker_fails_closed(): + registered = ("NVDA", "BRK/B") + assert research_workspace.validated_research_return_ticker("brk/b", registered) == "BRK/B" + assert research_workspace.validated_research_return_ticker("UNKNOWN", registered) == "" + assert research_workspace.validated_research_return_ticker("", registered) == "" + + +def test_monitor_return_link_is_explicitly_context_only(): + link = research_workspace.research_monitor_return_link("NVDA") + assert link["label"] == "Return to NVDA Company Workbench" + assert link["href"] == "?mode=research&page=company-workbench&ticker=NVDA&open=1" + assert "does not filter Monitor" in link["purpose"] +``` + +- [ ] **Step 2: Run route/helper tests and verify RED** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest -q -p no:cacheprovider \ + tests/test_dashboard_navigation.py tests/test_research_workspace.py \ + -k 'monitor and (return or canonical)' +``` + +Expected: Monitor strips `return_ticker` and the new helpers do not exist. + +- [ ] **Step 3: Implement canonical return context** + +Change only the Research Monitor allowlist: + +```python +(RESEARCH_MODE, "Monitor"): ("return_ticker",), +``` + +Implement validation by normalizing to uppercase and returning the symbol only +when it exactly matches the normalized registered set. The helper performs no +provider call. Implement the return link with URL quoting through `_quoted_ticker`. + +In `main()`, after the local provider is available, validate Monitor's +`return_ticker`. If invalid, remove only that query key from `st.query_params`. +Use the validated value for Personal Research navigation and the Monitor return +action. Do not pass it into any Monitor data loader, queue builder, frame, or +count function. + +- [ ] **Step 4: Run route/helper tests and verify GREEN** + +Run the Step 2 command. Expected: all selected tests pass. + +- [ ] **Step 5: Write failing navigation and Monitor rendering tests** + +Add assertions that: + +```python +active = research_workspace.research_workflow_navigation_html( + active_page="Monitor", ticker="BRK/B" +) +assert "page=monitor&return_ticker=BRK%2FB" in active +assert "page=company-workbench&ticker=BRK%2FB&open=1" in active +assert active.count("aria-current='page'") == 1 + +for page in ("Data Health", "Proof History"): + evidence = research_workspace.research_workflow_navigation_html( + active_page=page, ticker="AVGO" + ) + assert f"Advanced Evidence · {page}" in evidence + assert evidence.count("aria-current='page'") == 1 +``` + +Drive `render_research_monitor(...)` with a return ticker and assert the return +action is rendered once while the exact existing monitor cards/rows are +unchanged versus the no-context call. + +- [ ] **Step 6: Run navigation/render tests and verify RED** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest -q -p no:cacheprovider \ + tests/test_research_workspace.py tests/test_research_mode_dashboard_contract.py \ + -k 'workflow_navigation or monitor_return or active_evidence' +``` + +Expected: the Monitor URL drops context and evidence pages have zero current +markers. + +- [ ] **Step 7: Implement navigation, marker, and return action** + +Append `return_ticker` only to the Monitor link. Add a secondary evidence +context element after the four primary route links when the active page is +Data Health or Proof History: + +```html +
+ Advanced Evidence + Advanced Evidence · Data Health +
+``` + +Use escaped labels and exactly one `aria-current`. Extend the existing Research +navigation CSS with the current typography, border, focus, and mobile-wrap +tokens; do not invent a new color system. + +Render the Monitor return action below its primary answer when valid, followed +by this exact clarification: + +> `Monitor remains focused-cohort-wide; {TICKER} is only the return destination and does not filter these follow-up items.` + +- [ ] **Step 8: Run focused and adjacent tests** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest -q -p no:cacheprovider \ + tests/test_dashboard_navigation.py \ + tests/test_research_workspace.py \ + tests/test_research_mode_dashboard_contract.py +``` + +Expected: all tests pass with no warnings and the previous route/mode contracts +remain intact. + +- [ ] **Step 9: Update behavior documentation if needed** + +If `docs/PERSONAL_RESEARCH_MODE.md` describes Monitor as losing company +context or evidence pages as having no location cue, update only those +sentences. Do not add deployment, data freshness, or market-completeness claims. + +- [ ] **Step 10: Commit Task 2** + +```bash +git add src/dashboard_navigation.py src/research_workspace.py src/dashboard.py \ + tests/test_dashboard_navigation.py tests/test_research_workspace.py \ + tests/test_research_mode_dashboard_contract.py docs/PERSONAL_RESEARCH_MODE.md +git commit -m "Preserve research journey context" +``` + +If the documentation file has no relevant diff, omit it from `git add`. + +--- + +### Task 3: Stabilize the cold report loading state + +**Files:** +- Modify: `src/dashboard.py:7500-7585,8530-8610,32111-32520,37480-37770` +- Modify: `tests/test_dashboard_helpers.py:31947-32043,32267-32460` +- Modify: `tests/test_dashboard_render_smoke.py:880-990` +- Modify if the pure loading contract changes: `tests/test_single_stock_workflow.py:1-35` + +**Interfaces:** +- Consumes: existing `single_stock_loading_contract_cards(ticker)` and completed `report_payload`. +- Produces: `single_stock_loading_state_html(ticker: object) -> str`, built from existing `signal_card_html(...)`, with no readiness inference. +- Preserves: `build_stock_report()`, provider calls, session payload keys, report export, and every completed-report renderer. + +- [ ] **Step 1: Write failing pure and lifecycle tests** + +Add a pure loading-state assertion: + +```python +def test_single_stock_loading_state_is_neutral_and_accessible(): + rendered = dashboard.single_stock_loading_state_html("NVDA") + + assert "NVDA: preparing saved review" in rendered + assert "does not state that any analysis section is ready or blocked" in rendered + assert "No data is being refreshed or changed" in rendered + assert "role='status'" in rendered + assert "aria-live='polite'" in rendered + assert "aria-busy='true'" in rendered +``` + +Replace the source-order test that currently requires the unsafe fast summary. +The new test must assert that the cold compact-open branch does not call +`single_stock_fast_readiness_snapshot`, `single_stock_one_answer_frame`, or +`render_single_stock_public_summary` before `open_selected_report()`. + +Add controlled failure and success renderer tests: + +- failure: report construction raises; neutral loading is rendered first, no + Company Brief readiness answer is rendered, and the evidence rail is + unavailable; +- success: neutral loading precedes report construction, completed payload is + the first rendered readiness answer, and the neutral placeholder is cleared + once. + +- [ ] **Step 2: Run lifecycle tests and verify RED** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest -q -p no:cacheprovider \ + tests/test_dashboard_helpers.py tests/test_dashboard_render_smoke.py \ + tests/test_single_stock_workflow.py \ + -k 'loading or cold or preparing_saved_review or fast_readiness' +``` + +Expected: the pure helper is missing and existing cold code renders the fast +summary before the authoritative payload. + +- [ ] **Step 3: Implement one neutral loading renderer** + +Build `single_stock_loading_state_html(...)` from the existing loading card, +`signal_card_html(...)`, and a context note. It must contain no ready, blocked, +supported, valuation, peer, price, or DCF conclusion beyond the explicit phrase +that it states neither ready nor blocked. + +In the cold compact-open branch: + +- remove the fast snapshot and fast public summary calls; +- render only the neutral HTML; +- when `selected_answer_target` exists, write the HTML directly to that empty + slot; otherwise use a dedicated `st.empty()` placeholder; +- keep the existing synchronous report build and spinner; +- clear/replace the neutral state only after success or a fail-closed error; +- never alter report payload/session keys or report calculations. + +- [ ] **Step 4: Keep the public route bootstrap visible through report work** + +In `main()`, do not clear the public `Single-Stock Report` bootstrap in the +early public-shell branch. Let the existing final cleanup clear it after route +rendering. Other public routes keep their current early-clear behavior. + +- [ ] **Step 5: Run lifecycle tests and verify GREEN** + +Run the Step 2 command. Expected: all selected tests pass with pristine output. + +- [ ] **Step 6: Run adjacent report and Workbench tests** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest -q -p no:cacheprovider \ + tests/test_dashboard_helpers.py \ + tests/test_dashboard_render_smoke.py \ + tests/test_single_stock_workflow.py \ + tests/test_research_mode_dashboard_contract.py \ + -k 'single_stock or company_workbench or loading or bootstrap' +``` + +Expected: all selected tests pass, including tickerless and non-open public +routes. + +- [ ] **Step 7: Commit Task 3** + +```bash +git add src/dashboard.py tests/test_dashboard_helpers.py \ + tests/test_dashboard_render_smoke.py tests/test_single_stock_workflow.py +git commit -m "Stabilize saved report loading" +``` + +Omit unchanged test files from `git add`. + +--- + +### Task 4: Verify the complete journey and preservation contract + +**Files:** +- Modify only if a behavior assertion is missing: `tests/test_research_accessibility_browser_gate.py` +- Modify only if a behavior assertion is missing: `tests/test_workspace_visual_browser_gate.py` +- Create screenshots only under a fresh `/tmp/stock-command-center-journey-repair-*` directory. +- Modify only if current behavior documentation is inaccurate: `README.md`, `docs/PERSONAL_RESEARCH_MODE.md` + +**Interfaces:** +- Consumes: Tasks 1-3 behavior. +- Produces: fresh browser evidence, preservation matrix, final verification record, and documentation aligned to implemented behavior. + +- [ ] **Step 1: Add any missing browser behavior assertions first** + +The browser gates must verify observable behavior, not grep source text: + +- Discover first answer contains both live counts and at least four unique + Company Brief links before advanced filters; +- Monitor with `return_ticker=NVDA` shows one return action while its card/row + counts equal the no-context Monitor state; +- Data Health and Proof History each expose exactly one `aria-current` cue; +- a cold report immediately exposes the neutral live status and no readiness + answer, then exposes exactly one completed answer after stabilization; +- no `[aria-busy='true']` remains after the stable result. + +- [ ] **Step 2: Run new browser assertions and verify RED if tests changed** + +Run the exact changed test node(s). Expected before the corresponding browser +implementation adjustment: a behavior assertion fails for the missing cue, +ordering, or stable-state transition. + +- [ ] **Step 3: Make only the minimal browser-facing adjustment and verify GREEN** + +If the implementation already satisfies the behavior, do not add production +code. If a real gap is observed, change the smallest existing HTML/CSS helper +and rerun the exact failing browser node. + +- [ ] **Step 4: Run the preservation suite** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest -q -p no:cacheprovider \ + tests/test_dashboard_navigation.py \ + tests/test_research_workspace.py \ + tests/test_research_mode_dashboard_contract.py \ + tests/test_data_health_console.py \ + tests/test_single_stock_workflow.py \ + tests/test_company_workbench_html.py \ + tests/test_stock_report.py \ + tests/test_scenario_lab.py \ + tests/test_research_thesis_journal.py +``` + +Expected: all tests pass. Record counts and warnings exactly. + +- [ ] **Step 5: Run render, accessibility, and visual gates** + +Run the repository-native targets for: + +```bash +make research-dashboard-render-smoke +make research-accessibility-browser-check +make workspace-visual-browser-check +``` + +If the exact target spelling differs, use `make help`/Makefile truth and record +the actual target. Do not silently substitute a weaker check. + +- [ ] **Step 6: Capture and inspect the eight required states** + +Use the in-app Browser only. At 1280 x 720 and 390 x 844, save and inspect: + +1. Research Desk; +2. Discover with saved companies and zero strict eligibility; +3. Company Workbench with a selected ticker; +4. Monitor with return context; +5. Data Health current-location state; +6. Proof History current-location state; +7. immediate report loading; and +8. resolved report. + +Reject blank, loading-only (except state 7), cropped, wrong-route, or unstable +captures. Verify focus visibility, focus order, accessible names, reflow, and +overflow separately; screenshots alone do not prove accessibility. + +- [ ] **Step 7: Verify protected paths and functional preservation** + +Run: + +```bash +git diff --name-only 19d59bea64d2a37f69024d60ab195c0b50467d27...HEAD +git diff --check +make diff-hygiene-summary +git status --short --branch +``` + +The diff must contain no `data/`, `outputs/`, readiness, proof-ledger, +generated research output, or `docs/assets/` path. Produce a preservation +matrix covering every function named in the approved goal. + +- [ ] **Step 8: Run the full final gate** + +Run: + +```bash +make public-check +``` + +Wait for completion. Do not claim success from a partial, timed-out, or older +run. + +- [ ] **Step 9: Commit only real Task 4 changes** + +If tests or docs changed: + +```bash +git add tests/test_research_accessibility_browser_gate.py \ + tests/test_workspace_visual_browser_gate.py README.md docs/PERSONAL_RESEARCH_MODE.md +git commit -m "Verify Journey Repair behavior" +``` + +Omit every unchanged path. Screenshots and temporary evidence remain outside +the repository. + +- [ ] **Step 10: Complete review and handoff** + +Run a task review after every task and one whole-branch review over +`19d59bea64d2a37f69024d60ab195c0b50467d27..HEAD`. Resolve Critical and +Important findings through the bounded review loop. Finish with a clean +worktree and provide integration choices without merging or pushing. diff --git a/docs/superpowers/specs/2026-08-26-journey-repair-v1-design.md b/docs/superpowers/specs/2026-08-26-journey-repair-v1-design.md new file mode 100644 index 000000000..c60b45659 --- /dev/null +++ b/docs/superpowers/specs/2026-08-26-journey-repair-v1-design.md @@ -0,0 +1,162 @@ +# Journey Repair v1 Design + +**Status:** Approved by the owner through the Journey Repair v1 goal prompt. + +**Implementation base:** local `main` at +`19d59bea64d2a37f69024d60ab195c0b50467d27`. + +## Objective + +Repair the Personal Research journey so a researcher can discover an +inspectable saved company, open its Company Workbench, detour to Monitor or +Advanced Evidence, and return without losing company context. A cold report +must show a neutral saved-evidence loading state until the authoritative report +payload exists. + +This is a presentation and route-state change. It does not change research +calculations, data readiness, source rights, providers, canonical data, +generated artifacts, proof ledgers, or report contents. + +## Approved scope + +### Discover: evidence access before screen failure + +The first Discover answer combines two independently calculated counts: + +- saved companies available for evidence review; and +- companies that currently pass the strict screen. + +The answer format is: + +> `{saved_count} saved companies are available for evidence review; {eligible_count} currently pass the strict screen.` + +The saved count comes from the existing readiness-only, +company-only, focused-cohort browser. The eligible count comes from the +existing strict daily research queue. Neither count is hard-coded. + +Several alphabetical Company Brief links appear immediately after the answer. +They are evidence-access actions, not a ranking. Search, filters, detailed +readiness rows, strict eligibility detail, and all current blockers remain +available below. + +### Monitor: preserve return context without filtering + +The selected company is carried to Monitor as `return_ticker`, not `ticker`. +This distinction is visible and semantic: + +- Monitor remains the same focused-cohort/workspace follow-up surface. +- `return_ticker` only enables a return action to the selected Company + Workbench. +- It must not filter Monitor rows, alter counts, or imply a ticker-specific + monitor. +- A missing, malformed, or unregistered value is removed and ignored. +- A direct Monitor visit without return context behaves exactly as before. + +Data Health and Proof History continue using their existing validated `ticker` +context, which already returns to the matching Company Workbench. + +### Advanced Evidence: one truthful current-location cue + +The four primary Personal Research steps remain Research Desk, Discover, +Company Workbench, and Monitor. Data Health and Proof History do not become +primary workflow steps. + +When either evidence page is active, the navigation adds one secondary marker: + +- `Advanced Evidence · Data Health`; or +- `Advanced Evidence · Proof History`. + +That marker owns the page's sole `aria-current="page"`. No primary step is +marked current at the same time. + +### Loading: one neutral state until the final packet + +`build_stock_report()` remains the sole authority for the rendered readiness +answer. A cold Single-Stock Report or Company Workbench must not render the +fast saved-readiness summary before the report payload exists. + +Before the payload exists, show only the existing neutral loading contract: + +- selected ticker; +- saved local review is being prepared; +- no data is refreshed, imported, or fetched from external accounts; and +- the temporary state does not claim that any analysis section is ready or + blocked. + +For a public Single-Stock deep link, keep the route bootstrap visible until +the page has rendered its stable result. For Company Workbench, render the +neutral loading state inside the Company Brief answer slot so the strongest +surface never begins as an empty column. + +If report construction fails, show the existing fail-closed error/unavailable +state. Never leave stale optimistic content in the answer slot or evidence +rail. + +## State and route contract + +| Route | Company state accepted | Meaning | +| --- | --- | --- | +| Discover | none | Search/browse state stays session-local as today. | +| Company Workbench | validated `ticker` | Selected company being reviewed. | +| Monitor | validated `return_ticker` | Return destination only; not a filter. | +| Data Health | validated `ticker` | Existing evidence-lane focus and return context. | +| Proof History | validated `ticker` | Existing proof focus and return context. | + +Route canonicalization keeps only the exact allowed keys. Validation uses the +registered local ticker set; invalid context is removed rather than inferred. +Ticker values use the existing URL quoting rules, including symbols such as +`BRK/B`. + +## Accessibility and responsive behavior + +- Exactly one current-location cue is exposed on every Personal Research or + Advanced Evidence route. +- The loading panel uses `role="status"`, `aria-live="polite"`, and + `aria-busy="true"` only while the temporary state is present. +- Return and quick-company actions have ticker-specific accessible names. +- Keyboard focus, focus visibility, reading order, and browser back/forward + behavior remain testable. +- The current desktop rail and mobile wrapped-grid design are preserved. +- New text must reflow at 390 x 844 without clipping or horizontal overflow. + +## Preservation contract + +The following remain unchanged and available: + +- Discover search, saved presets, filters, strict eligibility, and detailed + queue evidence; +- Company Workbench's four answer lanes and progressive module gate; +- Monitor's focused-cohort follow-up logic and non-ranking boundary; +- Data Health lane inspection and Proof History raw detail; +- Public, Personal Research, and Operator workspace separation; +- valuation, scenario, thesis, evidence, quarterly trend, report, and offline + HTML modules; +- all fail-closed readiness, source, rights, freshness, and proof behavior. + +## Non-goals + +- No new analytics module, route, provider, dataset, cache, or background job. +- No data refresh, import, apply, readiness rebuild, or proof-ledger write. +- No source-rights or GitHub policy change. +- No broad dashboard refactor. +- No ranking, recommendation, expected-return, target-price, transaction, or + buy/sell language. +- No merge, push, publication, deployment, or hosted-environment change. + +## Verification and acceptance + +Acceptance requires: + +1. Unit/contract tests fail first and then pass for every changed behavior. +2. Discover shows live saved and strict counts with alphabetical quick links. +3. Monitor retains a validated return action while its data remains unchanged. +4. Data Health and Proof History expose one secondary current-location cue. +5. Cold report states show no blank answer area or pre-payload readiness claim. +6. Desktop 1280 x 720 and mobile 390 x 844 browser evidence covers the eight + states named in the goal prompt. +7. Preservation tests and the complete `make public-check` pass. +8. Protected data, output, proof, readiness, and generated-artifact paths have + no diff. + +> Data readiness first, analysis second, research decision last. + From b285080d73c13c2f5fc7f7d59d8c611e21b617a1 Mon Sep 17 00:00:00 2001 From: davidjiang8888 Date: Wed, 26 Aug 2026 21:08:30 -0400 Subject: [PATCH 04/21] Improve Discover evidence access --- src/dashboard.py | 75 +++++++++- tests/test_dashboard_helpers.py | 22 +++ .../test_research_mode_dashboard_contract.py | 138 ++++++++++++++++-- 3 files changed, 220 insertions(+), 15 deletions(-) diff --git a/src/dashboard.py b/src/dashboard.py index fc80eed3a..6dc031bd4 100644 --- a/src/dashboard.py +++ b/src/dashboard.py @@ -428,6 +428,7 @@ saved_readiness_display_label, research_workflow_navigation_html, research_workspace_header_html, + _quoted_ticker, weekly_summary_cards, ) from src.purpose_evaluation import PURPOSE_EVALUATION_SUMMARY_CSV, build_purpose_evaluation_drilldown @@ -30341,6 +30342,60 @@ def discover_saved_company_browse_frame( return result.head(max(limit, 1)).reset_index(drop=True) +def discover_primary_answer_html(saved_company_count: int, strict_eligible_count: int) -> str: + """Render the Discover answer with saved evidence access before strict screening.""" + + saved_count = max(int(saved_company_count), 0) + eligible_count = max(int(strict_eligible_count), 0) + saved_noun = "saved company" if saved_count == 1 else "saved companies" + answer = ( + f"{saved_count:,} {saved_noun} {'is' if saved_count == 1 else 'are'} " + f"available for evidence review; {eligible_count:,} currently pass the strict screen." + ) + reason = ( + "Saved-company availability and strict-screen eligibility are separate. " + "The links below are alphabetical evidence paths, not a ranking; strict thresholds remain unchanged." + ) + return answer_panel_html( + question="Find a Company · Evidence access first", + answer=answer, + reason=reason, + action=None, + stop_rule=None, + ).value + + +def discover_quick_company_links_html(frame: pd.DataFrame, *, limit: int = 4) -> str: + """Render a compact alphabetical set of Company Workbench evidence paths.""" + + if frame is None or frame.empty or "Ticker" not in frame.columns: + tickers: list[str] = [] + else: + ticker_series = frame["Ticker"].copy().fillna("").astype(str).str.strip().str.upper() + tickers = sorted( + ticker_series.loc[ticker_series.ne("")].drop_duplicates().tolist(), + key=str.casefold, + )[: max(limit, 0)] + links = "".join( + next_action_html( + SafeRouteAction( + label=f"Open {ticker} Company Brief", + href=( + "?mode=research&page=company-workbench" + f"&ticker={_quoted_ticker(ticker)}&open=1" + ), + ) + ).value + for ticker in tickers + ) + return ( + "" + ) + + def stock_selector_queue_frame( decisions_frame: pd.DataFrame | None, final_frame: pd.DataFrame | None, @@ -30923,6 +30978,7 @@ def render_stock_selector( target_page: str = "single-stock-report", allowed_tickers: tuple[str, ...] | None = None, research_boundary: str | None = None, + strict_eligible_count: int | None = None, ) -> None: research_discover = ( str(target_mode).strip().lower() == RESEARCH_MODE @@ -30965,6 +31021,19 @@ def render_stock_selector( allowed_tickers, ) + if research_discover: + st.markdown( + discover_primary_answer_html( + len(selector_frame), + 0 if strict_eligible_count is None else strict_eligible_count, + ), + unsafe_allow_html=True, + ) + st.markdown( + discover_quick_company_links_html(selector_frame), + unsafe_allow_html=True, + ) + if public_mode and not research_discover and selector_frame.empty: st.markdown( public_stock_selector_empty_hierarchy_html(), @@ -37404,11 +37473,6 @@ def render_personal_research_route( include_boundary=False, ) daily_queue = load_dashboard_daily_research_queue(context, as_of=review_date) - render_daily_research_queue( - daily_queue, - include_details=False, - include_boundary=False, - ) render_stock_selector( {}, public_mode=True, @@ -37416,6 +37480,7 @@ def render_personal_research_route( target_page="company-workbench", allowed_tickers=tuple(member.ticker for member in cohort.members), research_boundary=QUEUE_BOUNDARY, + strict_eligible_count=len(daily_queue.result.eligible), ) st.markdown(research_advanced_detail_marker_html(), unsafe_allow_html=True) with st.expander("Advanced: cohort readiness context", expanded=False): diff --git a/tests/test_dashboard_helpers.py b/tests/test_dashboard_helpers.py index 81b296fa9..4217888ac 100644 --- a/tests/test_dashboard_helpers.py +++ b/tests/test_dashboard_helpers.py @@ -2262,6 +2262,28 @@ def test_discover_saved_company_browse_frame_fails_closed_without_saved_readines ).empty +def test_discover_primary_answer_separates_saved_access_from_strict_eligibility(): + rendered = dashboard.discover_primary_answer_html(8, 0) + + assert "8 saved companies are available for evidence review" in rendered + assert "0 currently pass the strict screen" in rendered + assert "alphabetical" in rendered.lower() + assert "not a ranking" in rendered.lower() + + +def test_discover_quick_links_are_alphabetical_non_ranked_company_briefs(): + frame = dashboard.pd.DataFrame({"Ticker": ["NVDA", "AMD", "AVGO", "COHR", "TSLA"]}) + + rendered = dashboard.discover_quick_company_links_html(frame, limit=4) + + assert [rendered.index(ticker) for ticker in ("AMD", "AVGO", "COHR", "NVDA")] == sorted( + rendered.index(ticker) for ticker in ("AMD", "AVGO", "COHR", "NVDA") + ) + assert "TSLA" not in rendered + assert rendered.count("page=company-workbench") == 4 + assert "ranking" in rendered.lower() + + def test_stock_selector_source_frames_skip_legacy_outputs_for_research_discover( monkeypatch, ): diff --git a/tests/test_research_mode_dashboard_contract.py b/tests/test_research_mode_dashboard_contract.py index 51fa7b77f..4a356327b 100644 --- a/tests/test_research_mode_dashboard_contract.py +++ b/tests/test_research_mode_dashboard_contract.py @@ -500,7 +500,7 @@ def __exit__(self, *args): monkeypatch.setattr( dashboard, "load_dashboard_daily_research_queue", - lambda *args, **kwargs: object(), + lambda *args, **kwargs: SimpleNamespace(result=SimpleNamespace(eligible=())), raising=False, ) monkeypatch.setattr( @@ -552,8 +552,124 @@ def __exit__(self, *args): assert all(value != "## Find a Company" for value in headings) assert all("Browse saved companies" not in value for value in headings) - assert calls[:2] == ["strict eligibility", "saved browsing"] - assert calls[2:] == ["advanced"] + assert calls == ["saved browsing", "advanced"] + + +def test_discover_evidence_access_first_keeps_strict_queue_in_advanced_context(monkeypatch): + events: list[tuple[str, object]] = [] + context = SimpleNamespace(data_dir=Path("/selected-profile/data")) + tickers = ("NVDA", "AMD", "AVGO", "COHR", "TSLA", "XOM", "PLTR", "ZZZ") + readiness = dashboard.pd.DataFrame( + {"ticker": tickers, "asset_type": ["company"] * len(tickers)} + ) + strict_status = DailyQueueBuildStatus( + result=evaluate_daily_queue(()), + considered_count=0, + readiness_row_count=len(tickers), + price_row_count=0, + valuation_observation_count=0, + blocker_counts=(), + message="No strict-screen matches.", + ) + + class ContextManager: + def __init__(self, label: str): + self.label = label + + def __enter__(self): + events.append(("expander", self.label)) + return self + + def __exit__(self, *args): + return False + + selector_counts: list[int] = [] + original_selector = dashboard.render_stock_selector + + def render_selector(*args, **kwargs): + selector_counts.append(kwargs["strict_eligible_count"]) + return original_selector(*args, **kwargs) + + monkeypatch.setattr(dashboard, "load_observation_recency", lambda *args, **kwargs: None) + monkeypatch.setattr(dashboard, "render_research_workspace_header", lambda *args, **kwargs: None) + monkeypatch.setattr(dashboard, "load_dashboard_daily_research_queue", lambda *args, **kwargs: strict_status) + monkeypatch.setattr(dashboard, "load_ticker_readiness_report", lambda: (readiness, None)) + monkeypatch.setattr(dashboard, "load_dcf_readiness", lambda: (dashboard.pd.DataFrame(), None)) + monkeypatch.setattr( + dashboard, + "load_optional_context_readiness", + lambda: { + "earnings_readiness": (dashboard.pd.DataFrame(), None), + "analyst_estimates_readiness": (dashboard.pd.DataFrame(), None), + }, + ) + monkeypatch.setattr(dashboard, "load_output", lambda *args, **kwargs: (dashboard.pd.DataFrame(), None)) + monkeypatch.setattr(dashboard, "dashboard_readiness_summary", lambda *args, **kwargs: {}) + monkeypatch.setattr( + dashboard, + "stock_selector_saved_filter_presets", + lambda: ({"label": "All", "state": "All", "readiness": "All", "detail": "All", "theme": "All", "search": ""},), + ) + monkeypatch.setattr(dashboard, "render_stock_selector", render_selector) + monkeypatch.setattr(dashboard, "focused_cohort_cards", lambda *args, **kwargs: []) + monkeypatch.setattr(dashboard, "focused_cohort_coverage_cards", lambda *args, **kwargs: []) + monkeypatch.setattr(dashboard, "render_signal_cards", lambda *args, **kwargs: None) + monkeypatch.setattr(dashboard.st, "session_state", {}) + monkeypatch.setattr( + dashboard.st, + "markdown", + lambda value, **kwargs: events.append(("markdown", value)), + ) + monkeypatch.setattr( + dashboard.st, + "text_input", + lambda label, **kwargs: events.append(("text_input", label)) or "", + ) + monkeypatch.setattr(dashboard.st, "expander", lambda label, **kwargs: ContextManager(label)) + monkeypatch.setattr(dashboard.st, "form", lambda label, **kwargs: ContextManager(label)) + monkeypatch.setattr(dashboard.st, "columns", lambda widths: [dashboard.st] * len(widths)) + monkeypatch.setattr(dashboard.st, "selectbox", lambda label, options, **kwargs: options[0]) + monkeypatch.setattr(dashboard.st, "form_submit_button", lambda *args, **kwargs: False) + monkeypatch.setattr(dashboard.st, "dataframe", lambda *args, **kwargs: None) + monkeypatch.setattr(dashboard.st, "caption", lambda *args, **kwargs: None) + monkeypatch.setattr( + dashboard, + "render_notice_card", + lambda title, body, *args, **kwargs: events.append(("notice", f"{title} {body}")), + ) + + dashboard.render_personal_research_route( + selected_page="Discover", + provider=object(), + context=context, + state={}, + cohort=SimpleNamespace(members=tuple(SimpleNamespace(ticker=ticker) for ticker in tickers)), + coverage=object(), + weekly_summary=object(), + ticker="AMD", + review_date=date(2026, 8, 26), + ) + + primary_index = next( + index + for index, event in enumerate(events) + if event[0] == "markdown" and "8 saved companies are available" in event[1] + ) + search_index = events.index(("text_input", "Search saved companies")) + assert "0 currently pass the strict screen" in events[primary_index][1] + assert primary_index < search_index + assert all( + ticker in " ".join(event[1] for event in events if event[0] == "markdown") + for ticker in ("AMD", "AVGO", "COHR", "NVDA") + ) + advanced_index = events.index(("expander", "Advanced: cohort readiness context")) + strict_detail_index = next( + index + for index, event in enumerate(events) + if event[0] == "notice" and "No company currently has complete evidence" in event[1] + ) + assert advanced_index < strict_detail_index + assert selector_counts == [0] def test_discover_route_owns_one_boundary_and_places_recency_after_saved_browser( @@ -577,7 +693,11 @@ def __exit__(self, *args): "render_research_workspace_header", lambda *args, **kwargs: calls.append(("header", kwargs)), ) - monkeypatch.setattr(dashboard, "load_dashboard_daily_research_queue", lambda *args, **kwargs: object()) + monkeypatch.setattr( + dashboard, + "load_dashboard_daily_research_queue", + lambda *args, **kwargs: SimpleNamespace(result=SimpleNamespace(eligible=())), + ) monkeypatch.setattr(dashboard, "render_daily_research_queue", lambda *args, **kwargs: calls.append(("strict", kwargs))) monkeypatch.setattr( dashboard, @@ -614,12 +734,10 @@ def __exit__(self, *args): assert header_kwargs["include_boundary"] is False assert header_kwargs["compact"] is True assert header_kwargs.get("observation_recency") is None - assert [name for name, _ in calls].index("strict") < [name for name, _ in calls].index( - "saved" - ) < [name for name, _ in calls].index("recency") - strict_kwargs = next(value for name, value in calls if name == "strict") - assert strict_kwargs["include_details"] is False - assert strict_kwargs["include_boundary"] is False + assert "strict" not in [name for name, _ in calls] + assert [name for name, _ in calls].index("saved") < [name for name, _ in calls].index( + "recency" + ) saved_kwargs = next(value for name, value in calls if name == "saved") assert saved_kwargs["research_boundary"] == dashboard.QUEUE_BOUNDARY From fa61c53066ff3599309d22f77e9f0462da1eaff7 Mon Sep 17 00:00:00 2001 From: davidjiang8888 Date: Wed, 26 Aug 2026 21:15:55 -0400 Subject: [PATCH 05/21] Preserve research journey context --- docs/PERSONAL_RESEARCH_MODE.md | 4 +- src/dashboard.py | 78 +++++++++++++++- src/dashboard_navigation.py | 2 +- src/research_workspace.py | 52 ++++++++++- tests/test_dashboard_navigation.py | 18 ++++ .../test_research_mode_dashboard_contract.py | 88 +++++++++++++++++-- tests/test_research_workspace.py | 25 +++++- 7 files changed, 252 insertions(+), 15 deletions(-) diff --git a/docs/PERSONAL_RESEARCH_MODE.md b/docs/PERSONAL_RESEARCH_MODE.md index b0c8848ec..1ae774c4e 100644 --- a/docs/PERSONAL_RESEARCH_MODE.md +++ b/docs/PERSONAL_RESEARCH_MODE.md @@ -4,14 +4,14 @@ Personal Research is the default local workspace for repeated company review. It ## Workflow -Personal Research uses one in-content Personal research workflow navigation for its four primary pages. The same DOM node becomes a compact desktop rail and a wrapped phone grid whose four primary destinations remain discoverable without horizontal scrolling; its labelled Workspace mode disclosure replaces the retired Personal/Public/Operator sidebar selector. The main column omits the Operator command/readiness header and broad profile strip, so the route-specific question and answer appear first. Public and Operator remain explicit URL modes. Data Health and Proof History remain secondary direct-link evidence routes, reuse the same workflow nav with no false current core item, and intentionally do not join the primary navigation. +Personal Research uses one in-content Personal research workflow navigation for its four primary pages. The same DOM node becomes a compact desktop rail and a wrapped phone grid whose four primary destinations remain discoverable without horizontal scrolling; its labelled Workspace mode disclosure replaces the retired Personal/Public/Operator sidebar selector. The main column omits the Operator command/readiness header and broad profile strip, so the route-specific question and answer appear first. Public and Operator remain explicit URL modes. Data Health and Proof History remain secondary direct-link evidence routes, reuse the same workflow nav with a truthful Advanced Evidence current-location cue instead of a false current core item, and intentionally do not join the primary navigation. 1. **Research Desk** starts with one **Today's Research Brief**: what saved work needs attention, one traceable reason, the saved-readiness warning, and one action to Monitor for saved follow-up items, Data Health for a separate saved-source freshness condition, or Discover when neither is due. It derives the saved-item count from the same summary items Monitor uses rather than treating one event and its category rows as interchangeable counts. A zero-item answer applies only to evidence loaded in the workspace and never proves that no external event or research need exists. Weekly, cohort, observation, coverage, and source-change detail remains under collapsed Advanced Evidence. 2. **Discover** answers two different questions separately. **Screen eligibility — when supported** applies the unchanged strict Daily Momentum & Valuation evidence gates. **Browse saved companies** is an alphabetical, readiness-only evidence-access list that explains why each company is inspectable, what evidence is usable, and the main evidence gap before opening its Company Brief. Browsing is not screen eligibility, ranking, opportunity scoring, or a recommendation, and it does not read legacy ranking outputs. 3. **Company Workbench** starts with one Company Brief answering `Use now -> Still withheld -> What changed -> Next research task`, followed by the visible research-only stop rule and ticker-preserving Data Health action. `Open evidence and analysis modules` restores the existing Research Decision Lab, business trend, valuation, forward context, authoring, conclusion detail, methodology, and offline HTML brief for the current session. The primary task is not repeated in the detailed layer; the same task object still feeds the HTML snapshot. Technical lane coverage and Decision Lab evidence remain under collapsed Advanced sections. After that explicit action, the detailed sequence remains `What Changed -> Research Decision Lab -> Business Trend` before valuation, forward context, withheld evidence, and conclusion detail. -4. **Monitor** now answers one question through one **Follow-up Queue**: **Since the last review**, **Needs verification**, **Waiting on evidence**, **Scheduled context**, and **Evidence freshness**. When saved actionable evidence exists, five compact panels render once and any due process rows stay in focused-cohort order. When no saved research item is due but a saved-source freshness condition needs attention, the same five categories remain visible, the answer explicitly separates that condition from a saved item or live alert, and one **Open Data Health** action leads to evidence review. Only when neither a saved item nor a saved-source freshness condition needs attention does Monitor render the concise empty state, explicitly say this does not prove that no external event, risk, or research need exists, and provide one **Open Discover** action. +4. **Monitor** now answers one question through one **Follow-up Queue**: **Since the last review**, **Needs verification**, **Waiting on evidence**, **Scheduled context**, and **Evidence freshness**. When opened from a selected Company Workbench, it provides an explicit return action but keeps the same focused-cohort-wide follow-up scope; the return company does not filter cards, rows, counts, freshness, or evidence. When saved actionable evidence exists, five compact panels render once and any due process rows stay in focused-cohort order. When no saved research item is due but a saved-source freshness condition needs attention, the same five categories remain visible, the answer explicitly separates that condition from a saved item or live alert, and one **Open Data Health** action leads to evidence review. Only when neither a saved item nor a saved-source freshness condition needs attention does Monitor render the concise empty state, explicitly say this does not prove that no external event, risk, or research need exists, and provide one **Open Discover** action. The Follow-up Queue reuses the fixed seven-day summary, unresolved saved source-change rows, existing discipline precedence, and independent readiness/observation freshness. Full process rows, stable identities, and source-change evidence remain under `Advanced: Monitor evidence`. Monitor-only rows are retained there; they are not deleted, promoted, rescored, or reinterpreted. Five-company Earnings Nowcast readiness remains a separate collapsed Advanced section. Ordinary route use writes nothing. diff --git a/src/dashboard.py b/src/dashboard.py index 6dc031bd4..2f723260e 100644 --- a/src/dashboard.py +++ b/src/dashboard.py @@ -422,10 +422,12 @@ research_desk_brief_html, research_accessibility_media_preferences_css, research_evidence_return_link, + research_monitor_return_link, research_monitor_frame, monitor_freshness_condition_label, monitor_primary_answer, saved_readiness_display_label, + validated_research_return_ticker, research_workflow_navigation_html, research_workspace_header_html, _quoted_ticker, @@ -36795,6 +36797,14 @@ def render_research_workspace_styles() -> None: margin: .45rem 0 .8rem; max-width: 100%; } + .research-workflow-routes { + align-items: center; + display: flex; + flex: 1 1 34rem; + flex-wrap: wrap; + gap: .45rem; + min-width: 0; + } .research-workflow-link { align-items: center; border: 1px solid #c8d5cf; @@ -36805,12 +36815,38 @@ def render_research_workspace_styles() -> None: padding: .35rem .7rem; text-decoration: none !important; } + .research-workflow-link:focus-visible, + .research-workflow-evidence-current:focus-within { + outline: 3px solid #0f766e; + outline-offset: 2px; + } .research-workflow-link[aria-current='page'] { background: #0f766e; border-color: #0f766e; color: #fff !important; font-weight: 760; } + .research-workflow-evidence-current { + align-items: center; + border: 1px solid #c8d5cf; + border-left: 4px solid #0f766e; + border-radius: 6px; + display: flex; + gap: .35rem; + min-height: 2.75rem; + padding: .35rem .7rem; + } + .research-workflow-evidence-current > span { + color: #52615c; + font-size: .72rem; + font-weight: 760; + text-transform: uppercase; + } + .research-workflow-evidence-current > strong { + color: #0f4c3a; + font-size: .86rem; + line-height: 1.25; + } .public-ticker-summary.research { display: grid; grid-template-columns: 8rem minmax(0, 1fr) minmax(0, 1fr) minmax(12rem, 0.8fr); @@ -36966,7 +37002,9 @@ def render_research_workspace_styles() -> None: grid-template-columns: 1fr; } .research-workflow-navigation { gap: .35rem; margin: .35rem 0 .65rem; } + .research-workflow-routes { flex-basis: 100%; gap: .35rem; } .research-workflow-link { flex: 1 1 10rem; justify-content: center; min-width: 0; } + .research-workflow-evidence-current { flex: 1 1 100%; justify-content: center; text-align: center; } .research-desk-brief { padding: .85rem; } .research-desk-brief-answer { font-size: 1.15rem; } .research-desk-brief-context { grid-template-columns: 1fr; } @@ -37165,6 +37203,8 @@ def render_research_monitor( weekly_summary: WeeklyResearchSummary, cohort: FocusedCohort, observation_recency: ObservationRecencySet | None = None, + *, + return_ticker: str = "", ) -> None: render_research_workspace_header( "Monitor", @@ -37215,6 +37255,14 @@ def render_research_monitor( ).value, unsafe_allow_html=True, ) + if return_ticker: + return_link = research_monitor_return_link(return_ticker) + st.link_button(return_link["label"], return_link["href"]) + st.caption( + "Monitor remains focused-cohort-wide; " + f"{str(return_ticker).strip().upper()} is only the return destination and " + "does not filter these follow-up items." + ) st.markdown("## Follow-up Queue") discipline_frame = pd.DataFrame(research_discipline_rows(discipline)) if queue.has_saved_follow_up: @@ -37452,6 +37500,7 @@ def render_personal_research_route( weekly_summary: WeeklyResearchSummary, ticker: str, review_date, + return_ticker: str = "", ) -> bool: """Load one local observation result and render one primary research route.""" if selected_page not in RESEARCH_PATH_PAGE_TITLES: @@ -37510,7 +37559,17 @@ def render_personal_research_route( elif selected_page == "Company Workbench": render_company_workbench(provider, context, state, coverage, observation_recency) else: - render_research_monitor(state, context, weekly_summary, cohort, observation_recency) + if return_ticker: + render_research_monitor( + state, + context, + weekly_summary, + cohort, + observation_recency, + return_ticker=return_ticker, + ) + else: + render_research_monitor(state, context, weekly_summary, cohort, observation_recency) return True @@ -37559,6 +37618,19 @@ def main() -> None: ) catalog = LocalDataCatalog(BASE_DIR, data_dir=DATA_DIR, outputs_dir=OUTPUTS_DIR) provider = get_local_provider() + monitor_return_ticker = "" + if research_mode and selected_page == "Monitor": + registered_tickers = ( + provider.list_local_tickers() + if hasattr(provider, "list_local_tickers") + else () + ) + monitor_return_ticker = validated_research_return_ticker( + st.query_params.get("return_ticker"), + registered_tickers, + ) + if "return_ticker" in st.query_params and not monitor_return_ticker: + del st.query_params["return_ticker"] bootstrap_placeholder = ( render_public_route_bootstrap(initial_page, initial_mode) if initial_mode == PUBLIC_DEMO_MODE @@ -37705,7 +37777,8 @@ def main() -> None: render_public_workflow_skip_target() else: if research_mode: - render_research_workflow_navigation(selected_page, ticker=ticker) + navigation_ticker = monitor_return_ticker if selected_page == "Monitor" else ticker + render_research_workflow_navigation(selected_page, ticker=navigation_ticker) render_public_workflow_skip_target() render_research_workspace_styles() else: @@ -37747,6 +37820,7 @@ def main() -> None: weekly_summary=weekly_research_summary, ticker=ticker, review_date=pd.Timestamp.now(tz="UTC").date(), + return_ticker=monitor_return_ticker, ): return if content_page == "Home": diff --git a/src/dashboard_navigation.py b/src/dashboard_navigation.py index 36401af5f..80bf2abaa 100644 --- a/src/dashboard_navigation.py +++ b/src/dashboard_navigation.py @@ -83,7 +83,7 @@ (RESEARCH_MODE, "Research Desk"): (), (RESEARCH_MODE, "Discover"): (), (RESEARCH_MODE, "Company Workbench"): ("ticker", "open", "cash_preview"), - (RESEARCH_MODE, "Monitor"): (), + (RESEARCH_MODE, "Monitor"): ("return_ticker",), (RESEARCH_MODE, "Data Health"): DATA_HEALTH_QUERY_KEYS, (RESEARCH_MODE, PROOF_HISTORY_PATH_TITLE): ("ticker",), } diff --git a/src/research_workspace.py b/src/research_workspace.py index 591c92e3f..0a0618237 100644 --- a/src/research_workspace.py +++ b/src/research_workspace.py @@ -1385,6 +1385,33 @@ def _quoted_ticker(ticker: str) -> str: return quote(str(ticker or "").strip().upper(), safe="") +def validated_research_return_ticker( + value: object, + registered_tickers: Iterable[object], +) -> str: + """Return a registered Monitor destination, or no context when validation fails.""" + + requested = str(value or "").strip().upper() + registered = { + str(ticker or "").strip().upper() + for ticker in registered_tickers + if str(ticker or "").strip() + } + return requested if requested and requested in registered else "" + + +def research_monitor_return_link(ticker: str) -> dict[str, str]: + """Build the selected-company return action without changing Monitor scope.""" + + symbol = _quoted_ticker(ticker) + label_ticker = str(ticker or "").strip().upper() + return { + "label": f"Return to {label_ticker} Company Workbench", + "href": f"?mode=research&page=company-workbench&ticker={symbol}&open=1", + "purpose": "Return to the selected company; this context does not filter Monitor.", + } + + def research_workflow_navigation_html(*, active_page: str, ticker: str = "") -> str: """Render the deterministic Personal Research route sequence.""" @@ -1403,7 +1430,10 @@ def research_workflow_navigation_html(*, active_page: str, ticker: str = "") -> else "", ) ) - routes.append(("Monitor", "monitor", "?mode=research&page=monitor")) + monitor_href = "?mode=research&page=monitor" + if symbol: + monitor_href = f"{monitor_href}&return_ticker={symbol}" + routes.append(("Monitor", "monitor", monitor_href)) links: list[str] = [] for label, slug, href in routes: if not href: @@ -1413,17 +1443,33 @@ def research_workflow_navigation_html(*, active_page: str, ticker: str = "") -> " — Choose a company in Discover first" ) continue - current = " aria-current='page'" if active_slug == slug else "" + current = ( + " aria-current='page'" + if active_slug == slug and active_slug not in {"data-health", "proof-history"} + else "" + ) links.append( "" f"{html.escape(label)}" ) + evidence_label = { + "data-health": "Data Health", + "proof-history": "Proof History", + }.get(active_slug, "") + evidence_current = ( + "
" + "Advanced Evidence" + f"Advanced Evidence · {html.escape(evidence_label)}" + "
" + if evidence_label + else "" + ) return ( "