diff --git a/.github/workflows/commercial-research-beta.yml b/.github/workflows/commercial-research-beta.yml new file mode 100644 index 00000000..2b7dfb86 --- /dev/null +++ b/.github/workflows/commercial-research-beta.yml @@ -0,0 +1,49 @@ +name: Commercial Research Beta + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + local-engineering-gate: + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + PYTHONDONTWRITEBYTECODE: "1" + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + steps: + - name: Check out pull request revision + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha }} + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Install dependencies + run: python3 -m pip install -e . pytest + + - name: Run full test suite + run: python3 -m pytest tests -q + + - name: Smoke-test dashboard startup + run: make dashboard-smoke + + - name: Render Personal Research routes + run: make research-dashboard-render-smoke + + - name: Check public wording + run: make public-wording-check + + - name: Check generated-artifact hygiene + run: make pr-range-hygiene-check BASE_SHA="$PR_BASE_SHA" HEAD_SHA="$PR_HEAD_SHA" + + - name: Check whitespace + run: git diff --check "$PR_BASE_SHA...$PR_HEAD_SHA" diff --git a/Makefile b/Makefile index bd74fc9e..011cdd4b 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,14 @@ -.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 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-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-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 +.PHONY: refresh-operations-status refresh-operations-runbook +.PHONY: earnings-nowcast-sec-actuals-stage +.PHONY: earnings-nowcast-cohort-readiness earnings-consensus-source-status earnings-consensus-source-review earnings-consensus-collection-plan earnings-consensus-collection-status earnings-consensus-collection-preview earnings-consensus-collection-record research-outcome-review research-outcome-review-preview research-outcome-review-record catalyst-evidence-timeline catalyst-evidence-preview catalyst-evidence-record +EARNINGS_NOWCAST_COHORT ?= NVDA,AMD,AVGO,MU,QCOM +.PHONY: commercial-source-rights commercial-beta-check commercial-beta-release-check +.PHONY: private-beta-readiness +.PHONY: point-in-time-universe-status point-in-time-universe-preview DEFAULT_TRUSTED_PILOT_TICKERS := MU,CRDO,HOOD,TSLA,META,A,APLD DEFAULT_TRUSTED_PILOT_EVIDENCE_TICKERS := MU,CRDO @@ -16,7 +24,10 @@ help: @echo " make project-status-check Read current coverage, blockers, and executable next steps" @echo " make provider-setup-checklist Review optional key setup without exposing secrets" @echo " make hosted-demo-readiness Check hosted-app readiness without deploying" - @echo " make pilot-review-feedback Print the controlled 5-10 reviewer feedback capture guide" + @echo " make commercial-beta-check Run the read-only commercial-beta contract checks" + @echo " make point-in-time-universe-status MANIFEST= [REGISTRY=] Validate one immutable universe package" + @echo " make point-in-time-universe-preview MANIFEST= [REGISTRY=] [TOP_N=20] Preview one immutable universe package" + @echo " make pilot-review-feedback Print the controlled 10-20 reviewer feedback capture guide" @echo " make pilot-feedback-closeout Print the reviewer feedback closeout and fix/defer checklist" @echo " make scheduler-activation-checklist Print the safe scheduler activation checklist" @echo " make public-check / public-release-handoff Verify sharing and terminal steps" @@ -29,7 +40,7 @@ help: @echo "" @echo "For the full local command catalog, run: make help-full" -.PHONY: dashboard-render-smoke +.PHONY: dashboard-render-smoke research-dashboard-render-smoke commercial-beta-performance-contract commercial-beta-performance-gate .PHONY: price-history-batch-closeout next-stage: @@ -88,6 +99,8 @@ help-full: @echo " Print one lane group's ordered proof steps and evidence summary" @echo " make reviewed-data-proof" @echo " make reviewed-batch-proof" + @echo " make proof-readiness-reconciliation TOP_N=20" + @echo " Compare historical proof outcomes with current saved readiness without writing files" @echo " make reviewed-batch-compare" @echo " make reviewed-batch-preflight" @echo " Print the durable reviewed data proof ledger" @@ -121,6 +134,10 @@ help-full: @echo " Print the copy-ready terminal handoff for verify, stage, commit, and push" @echo " make license-status" @echo " Print the read-only license/reuse gate before public sharing" + @echo " make point-in-time-universe-status MANIFEST= [REGISTRY=]" + @echo " Print deterministic validation truth for one immutable universe package" + @echo " make point-in-time-universe-preview MANIFEST= [REGISTRY=] [TOP_N=20]" + @echo " Preview reproduction digests and capped canonical exclusions without writing" @echo " make session-source-preflight [SEC_USER_AGENT='Name email@example.com']" @echo " Check one session's SEC/yfinance/local-fundamentals path before retrying source-backed coverage work" @echo " make source-activation-guide" @@ -152,7 +169,7 @@ help-full: @echo " make public-ux-review-note" @echo " Record one local UX review note row without staging or refreshing data" @echo " make pilot-review-feedback" - @echo " Print the controlled 5-10 reviewer feedback capture guide" + @echo " Print the controlled 10-20 reviewer feedback capture guide" @echo " make pilot-feedback-closeout" @echo " Print the reviewer feedback closeout and fix/defer checklist" @echo " make readiness-ops-center" @@ -197,6 +214,7 @@ help-full: @echo " make lane-outcome-history [LEDGER=data/reviewed_data_proofs.csv] Print lane outcome history from reviewed proof rows" @echo " make reviewed-data-proof-record LANE= PROOF_ID= PROOF_DATE= FINAL_OUTCOME= Record an intentional reviewed proof row" @echo " make reviewed-batch-proof [LEDGER=data/reviewed_batch_proofs.csv] Print durable reviewed batch proof rows" + @echo " make proof-readiness-reconciliation [TOP_N=20] [TICKERS=ARCT] [JSON=1] Compare historical proof with current saved readiness" @echo " make reviewed-batch-proof-record BATCH_ID= LANE= REVIEW_DATE= FINAL_OUTCOME= Record a reviewed or auto-gated batch outcome" @echo " make reviewed-batch-compare [BATCH_ID=] [LANE=prices] [REVIEW_DATE=] Compare prior/current readiness snapshots for proof-ledger fields" @echo " make reviewed-batch-preflight [LANE=prices] [TOP_N=100] [MAX_CANDIDATES=3500] Check snapshot, dry-run, compare, proof, and artifact gates" @@ -204,6 +222,9 @@ help-full: @echo " make auto-refresh-daily Print daily price, SEC filing/share-count, and fundamentals refresh commands" @echo " make auto-refresh-runbook Print compact scheduler runbook for SCHEDULE=daily|weekly|optional|all" @echo " make auto-refresh-status Print source activation, setup gaps, next command, and runbook pointer" + @echo " make refresh-operations-status Print read-only provider/retry planning status" + @echo " make refresh-operations-runbook Print the read-only refresh operations runbook" + @echo " make private-beta-readiness Print the read-only nine-area private-beta readiness contract" @echo " make scheduler-activation-checklist Print the safe scheduler activation checklist" @echo " make auto-apply-gate Evaluate deterministic auto-apply gate fields for one staged slice" @echo " make fundamentals-batch-proof [DRY_RUN=1] [TOP_N=10] [TICKERS=NVDA,MSFT] Preview or write the SEC/manual fundamentals proof packet with validate, preview, rejected-row, compare, and proof-record gates" @@ -212,7 +233,7 @@ help-full: @echo " make price-history-batch-closeout [TOP_N=10] [TICKERS=AIAI,AMAN] Print a read-only grouped still-blocked proof-record scaffold" @echo " make peer-batch-proof [DRY_RUN=1] [TOP_N=10] [TICKERS=NVDA,MSFT] Preview or write the peer mapping and mapped-peer valuation-input proof packet without inferring peers" @echo " make peer-mapping-source-review [DRY_RUN=1] [TOP_N=10] [TICKERS=NVDA,MSFT] Preview or write a fillable source-review packet before editing data/imports/peers.csv" - @echo " make peer-mapping-writeback-guard TICKER= PEER_TICKER= PEER_GROUP= SOURCE= AS_OF_DATE= REVIEWER= REVIEW_DATE= Preview one reviewed peer import row; blocks duplicates, self-peers, placeholders, and stale readiness" + @echo " make peer-mapping-writeback-guard TICKER= PEER_TICKER= PEER_GROUP= PEER_ROLE= COMPARABILITY_BASIS= VALUATION_ANCHOR_ELIGIBLE= SOURCE= AS_OF_DATE= REVIEWER= REVIEW_DATE= Preview one reviewed peer import row; blocks incomplete evidence, duplicates, self-peers, placeholders, and stale readiness" @echo " make price-reviewed-run [MAX_CANDIDATES=3500] [TOP_N=100] [PROVIDER=auto] Print reviewed capped price-run execution, diff, and rollback plan" @echo " make public-demo-readiness-pack Print the small shareable public demo proof set" @echo " make readiness-ops-center Print lane-level ready/partial/blocked/excluded operations without refreshing data" @@ -229,6 +250,7 @@ help-full: @echo " make metric-readiness-board [TICKERS=NVDA,META] [TOP_N=10] [BENCHMARKS=SPY,QQQ] [OUTPUT=outputs/metric_readiness_board.csv] Print or optionally export the combined benchmark metric-readiness board" @echo " make diff-hygiene Print a read-only staging guide that separates product files from local data changes" @echo " make diff-hygiene-summary Print a short read-only staging summary for public checks" + @echo " make pr-range-hygiene-check BASE_SHA= HEAD_SHA= Inspect committed PR paths for generated churn" @echo " make diff-hygiene-files Write local pathspec files under outputs/staging for safer reviewed staging" @echo " make data-release-decision Print read-only post-batch keep-local, reviewed-data-release, and cleanup guidance" @echo " make public-release-package Print read-only product staging, generated exclusion, final checks, commit, and push guidance" @@ -303,6 +325,7 @@ help-full: @echo " make import-staging Write header-only staging CSV files under data/imports" @echo " make validate-data Validate local CSV datasets" @echo " make readiness-snapshot Save current ticker readiness as data/reports/ticker_readiness_report.previous.csv before a refresh" + @echo " make readiness-preview [TOP_N=20] Preview stable readiness impact, change causes, and promotion evidence in memory without writing files" @echo " make readiness Write central data/reports/ticker_readiness_report.csv" @echo "" @echo "Price fallback:" @@ -316,7 +339,8 @@ help-full: @echo " make price-coverage Write data/price_coverage_report.csv with rows per universe ticker" @echo " Start with make status, then the printed price check or guided batch" @echo " make price-normalize INPUT=data/raw/prices/NVDA.csv TICKER=NVDA SOURCE=yahoo_manual" - @echo " make price-validate && make price-preview && make price-apply" + @echo " make price-normalize INPUT=data/raw/prices/NVDA.csv TICKER=NVDA SOURCE= SOURCE_REF= RETRIEVED_AT= AS_OF=" + @echo " make price-validate AS_OF= && make price-preview AS_OF= && make price-apply AS_OF=" @echo "" @echo "Preview-first fundamentals and universe imports:" @echo " export SEC_USER_AGENT='Name email@example.com'" @@ -425,6 +449,12 @@ public-performance-contract: public-performance-gate: @python3 -m src.public_performance_gate --browser --warm-runs $(or $(WARM_RUNS),5) --cold-runs $(or $(COLD_RUNS),1) --timeout-seconds $(or $(TIMEOUT_SECONDS),30) $(if $(BASE_URL),--base-url "$(BASE_URL)",) $(if $(CHROME),--chrome "$(CHROME)",) +commercial-beta-performance-contract: + @python3 -m src.public_performance_gate --workflow research --contract --root . + +commercial-beta-performance-gate: + @python3 -m src.public_performance_gate --workflow research --browser --root . --warm-runs $(or $(WARM_RUNS),5) --cold-runs $(or $(COLD_RUNS),1) --timeout-seconds $(or $(TIMEOUT_SECONDS),30) --output "$(or $(OUTPUT),/tmp/stock-command-center-commercial-beta-performance.json)" $(if $(BASE_URL),--base-url "$(BASE_URL)",) $(if $(CHROME),--chrome "$(CHROME)",) + public-ux-review-checklist: @python3 -m src.public_ux_review_checklist @@ -442,7 +472,7 @@ public-ux-review-note: pilot-review-feedback: @echo "Controlled Pilot Review Feedback" - @echo "Read-only: this target prints the 5-10 external reviewers feedback capture guide." + @echo "Read-only: this target prints the 10-20 external reviewers feedback capture guide; all counted sessions must be independent target-persona reviews." @echo "It does not refresh data, import rows, stage files, commit, push, or deploy." @echo "Research-only: pilot feedback is product clarity evidence, not data proof, source proof, data freshness proof, investment advice, broker integration, or a trade instruction." @echo "" @@ -451,15 +481,20 @@ pilot-review-feedback: @echo "Local-only working copy: cp docs/PILOT_REVIEW_FEEDBACK_LOG_TEMPLATE.csv /tmp/stock-command-center-pilot-feedback.csv" @echo "" @echo "Reviewer path:" - @echo " Home -> Stock Selector -> Single-Stock Report -> Data Health -> Proof History" + @echo " Research Desk -> Discover -> Company Workbench -> Monitor" @echo "" - @echo "Ask four questions:" + @echo "Ask the complete task questions:" @echo " 1. Where did you start?" @echo " 2. What could you use now?" @echo " 3. What was blocked or excluded?" - @echo " 4. What would you do next?" + @echo " 4. What evidence supports one conclusion?" + @echo " 5. What was difficult to author or update?" + @echo " 6. What would you do next?" + @echo " 7. Would you use this workflow again?" + @echo " 8. What is the most important missing workflow?" @echo "" - @echo "Capture only anonymous route, clarity, time-to-first-answer, and reproducible issue notes." + @echo "Scorecard: Task success; Time to first useful answer; Readiness comprehension; Evidence trace; Authoring friction; Misuse risk; Trust; Perceived performance; Repeat-use case; Repeat-use intent; Most important missing workflow." + @echo "Before starting, confirm voluntary participation, withdrawal rights, minimal anonymous capture, and the working-note deletion date." @echo "Closeout outcomes: clear, reproducible_ui_issue, documentation_gap, environment_limited, intentionally_deferred." @echo "Do not capture names, account details, investment opinions, price targets, trade decisions, or portfolio information." @echo "Commit a feedback log only after removing personal information and only when it is intentionally reviewed pilot evidence." @@ -502,13 +537,13 @@ linkedin-share-check: @echo "Research-only: this is portfolio/demo packaging, not investment advice, data freshness proof, broker integration, or trade instruction." @echo "" @echo "1. Open your LinkedIn profile and confirm the Featured card title matches:" - @echo " Stock Research Command Center | Readiness-First Stock Research Workflow" - @echo "2. Confirm the Featured description says the project is research-only and mentions no broker integration, auto-trading, or investment advice." - @echo "3. Use docs/assets/linkedin-public-dashboard.png when you want the curated product screenshot." + @echo " Stock Research Command Center | Evidence-First Company Research" + @echo "2. Confirm the Featured description names Research Desk -> Discover -> Company Workbench -> Monitor and keeps the project research-only with no broker integration, auto-trading, or investment advice." + @echo "3. Use docs/assets/linkedin-public-dashboard.png only as the reviewed count-safe Company Workbench answer visual." @echo "4. If LinkedIn shows GitHub's generated OpenGraph card, keep it only if that card is acceptable; otherwise remove and re-add the Featured item with the curated screenshot." - @echo "5. Use the GitHub link unless you have separately deployed and verified a hosted Streamlit app." + @echo "5. Use the stable GitHub repository link only after this reviewed feature reaches the default branch. Until then, keep the existing stable item or label any non-default review link Draft engineering preview." @echo "6. Open the GitHub link from LinkedIn and confirm the README starts with External Reviewer Start Here and names the GitHub/local-app boundary before deeper operator detail." - @echo "7. Stop before claiming screenshots prove current data freshness, coverage completion, provider-key activation, or investment advice." + @echo "7. Stop before claiming screenshots prove current data freshness, readiness counts, coverage completion, provider-key activation, hosting, market validation, or investment advice." @echo "" @echo "Next safe repo checks:" @echo " make public-check" @@ -667,11 +702,14 @@ peer-mapping-source-review: @python3 -m src.peer_mapping_source_review --root . --top-n $(or $(TOP_N),10) $(if $(TICKERS),--tickers "$(TICKERS)",) --md-output $(or $(MD_OUTPUT),outputs/peer_mapping_source_review.md) --csv-output $(or $(CSV_OUTPUT),outputs/peer_mapping_source_review.csv) $(if $(DRY_RUN),--dry-run,) peer-mapping-writeback-guard: - @python3 -m src.peer_mapping_source_review --root . --guard-writeback --ticker "$(TICKER)" --peer-ticker "$(PEER_TICKER)" --peer-group "$(PEER_GROUP)" --sector "$(SECTOR)" --industry "$(INDUSTRY)" --source "$(SOURCE)" --as-of-date "$(AS_OF_DATE)" --relationship-rationale "$(RELATIONSHIP_RATIONALE)" --reviewer "$(REVIEWER)" --review-date "$(REVIEW_DATE)" --source-proof-status "$(SOURCE_PROOF_STATUS)" --import-row-ready "$(IMPORT_ROW_READY)" + @python3 -m src.peer_mapping_source_review --root . --guard-writeback --ticker "$(TICKER)" --peer-ticker "$(PEER_TICKER)" --peer-group "$(PEER_GROUP)" --sector "$(SECTOR)" --industry "$(INDUSTRY)" --peer-role "$(PEER_ROLE)" --source "$(SOURCE)" --as-of-date "$(AS_OF_DATE)" --relationship-rationale "$(RELATIONSHIP_RATIONALE)" --comparability-basis "$(COMPARABILITY_BASIS)" --valuation-anchor-eligible "$(VALUATION_ANCHOR_ELIGIBLE)" --reviewer "$(REVIEWER)" --review-date "$(REVIEW_DATE)" --source-proof-status "$(SOURCE_PROOF_STATUS)" --import-row-ready "$(IMPORT_ROW_READY)" reviewed-batch-proof: @python3 -m src.reviewed_batch_proof --ledger $(or $(LEDGER),data/reviewed_batch_proofs.csv) +proof-readiness-reconciliation: + @python3 -m src.proof_readiness_reconciliation --root . --top-n $(or $(TOP_N),20) $(if $(TICKERS),--tickers "$(TICKERS)",) $(if $(filter 1,$(JSON)),--json,) + reviewed-batch-compare: @python3 -m src.readiness_comparison --root . --top-n $(or $(TOP_N),25) --batch-id "$(or $(BATCH_ID),)" --lane "$(or $(LANE),prices)" --review-date "$(or $(REVIEW_DATE),)" @@ -696,6 +734,12 @@ auto-refresh-runbook: auto-refresh-status: @python3 -m src.auto_refresh_orchestrator --root . --schedule $(or $(SCHEDULE),daily) --status +refresh-operations-status: + @python3 -m src.auto_refresh_orchestrator --root . --schedule $(or $(SCHEDULE),daily) --status + +refresh-operations-runbook: + @python3 -m src.auto_refresh_orchestrator --root . --schedule $(or $(SCHEDULE),daily) --runbook + scheduler-activation-checklist: @echo "Scheduler Activation Checklist" @echo "Read-only: this target prints scheduling gates only; it does not refresh, import, apply, stage, commit, push, deploy, or expose secrets." @@ -749,6 +793,10 @@ diff-hygiene: diff-hygiene-summary: @python3 scripts/diff_hygiene.py --summary +.PHONY: pr-range-hygiene-check +pr-range-hygiene-check: + @python3 scripts/diff_hygiene.py --range-base "$(BASE_SHA)" --range-head "$(HEAD_SHA)" + diff-hygiene-files: @python3 scripts/diff_hygiene.py --write-files @@ -767,6 +815,24 @@ universe-scope: staged-hygiene-check: @python3 scripts/diff_hygiene.py --staged-check +point-in-time-universe-status point-in-time-universe-preview: export POINT_IN_TIME_UNIVERSE_MANIFEST := $(value MANIFEST) +point-in-time-universe-status point-in-time-universe-preview: export POINT_IN_TIME_UNIVERSE_REGISTRY := $(if $(strip $(value REGISTRY)),$(value REGISTRY),config/source_rights.yml) +point-in-time-universe-status point-in-time-universe-preview: export POINT_IN_TIME_UNIVERSE_TOP_N := $(if $(strip $(value TOP_N)),$(value TOP_N),20) + +point-in-time-universe-status: + @case "$${POINT_IN_TIME_UNIVERSE_MANIFEST}" in *[![:space:]]*) ;; *) echo "MANIFEST is required" >&2; exit 2;; esac + @PYTHONDONTWRITEBYTECODE=1 python3 -m src.point_in_time_universe status \ + --manifest "$${POINT_IN_TIME_UNIVERSE_MANIFEST}" \ + --registry "$${POINT_IN_TIME_UNIVERSE_REGISTRY}" \ + --top-n "$${POINT_IN_TIME_UNIVERSE_TOP_N}" + +point-in-time-universe-preview: + @case "$${POINT_IN_TIME_UNIVERSE_MANIFEST}" in *[![:space:]]*) ;; *) echo "MANIFEST is required" >&2; exit 2;; esac + @PYTHONDONTWRITEBYTECODE=1 python3 -m src.point_in_time_universe preview \ + --manifest "$${POINT_IN_TIME_UNIVERSE_MANIFEST}" \ + --registry "$${POINT_IN_TIME_UNIVERSE_REGISTRY}" \ + --top-n "$${POINT_IN_TIME_UNIVERSE_TOP_N}" + public-wording-check: @python3 scripts/public_wording_check.py @@ -799,6 +865,23 @@ public-check: test: python3 -m pytest tests -q +COMMERCIAL_SOURCE_RIGHTS_REPO_ROOT := $(strip $(shell dirname "$(MAKEFILE_LIST)")) + +commercial-source-rights: export COMMERCIAL_SOURCE_RIGHTS_CONFIG := $(value CONFIG) +commercial-source-rights: export COMMERCIAL_SOURCE_RIGHTS_SOURCE := $(value SOURCE) +commercial-source-rights: export COMMERCIAL_SOURCE_RIGHTS_REPO_ROOT := $(COMMERCIAL_SOURCE_RIGHTS_REPO_ROOT) +commercial-source-rights: + @set --; \ + case "$${COMMERCIAL_SOURCE_RIGHTS_CONFIG}" in \ + *[![:space:]]*) set -- "$$@" "--config=$${COMMERCIAL_SOURCE_RIGHTS_CONFIG}" ;; \ + esac; \ + case "$${COMMERCIAL_SOURCE_RIGHTS_SOURCE}" in \ + *[![:space:]]*) set -- "$$@" "--source=$${COMMERCIAL_SOURCE_RIGHTS_SOURCE}" ;; \ + esac; \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONPATH="$${COMMERCIAL_SOURCE_RIGHTS_REPO_ROOT}$${PYTHONPATH:+:$${PYTHONPATH}}" \ + python3 -m src.commercial_source_rights "$$@" + status: python3 -m src.project_status --refresh-artifacts --top-n $(or $(TOP_N),5) @@ -1043,13 +1126,13 @@ peer-mapping-queue: python3 -m src.data_onboarding --peer-mapping-queue $(if $(TOP_N),--top-n $(TOP_N),) $(if $(TICKERS),--tickers $(TICKERS),) price-validate: - STOCK_RESEARCH_DATA_PROFILE=local python3 -m src.data_update --validate-price-imports + STOCK_RESEARCH_DATA_PROFILE=local python3 -m src.data_update --validate-price-imports $(if $(AS_OF),--review-cutoff "$(AS_OF)",) price-preview: - STOCK_RESEARCH_DATA_PROFILE=local python3 -m src.data_update --preview-price-import-merge + STOCK_RESEARCH_DATA_PROFILE=local python3 -m src.data_update --preview-price-import-merge $(if $(AS_OF),--review-cutoff "$(AS_OF)",) price-apply: - STOCK_RESEARCH_DATA_PROFILE=local python3 -m src.data_update --apply-price-import-merge + STOCK_RESEARCH_DATA_PROFILE=local python3 -m src.data_update --apply-price-import-merge $(if $(AS_OF),--review-cutoff "$(AS_OF)",) price-refresh: ifdef TICKERS @@ -1065,10 +1148,15 @@ price-normalize: ifndef INPUT $(error INPUT is required, for example: make price-normalize INPUT=data/raw/prices/NVDA.csv TICKER=NVDA SOURCE=yahoo_manual) endif +ifdef RETRIEVED_AT +ifndef AS_OF + $(error AS_OF is required when RETRIEVED_AT is supplied) +endif +endif ifdef TICKER - python3 -m src.price_import_normalizer --input $(INPUT) --ticker $(TICKER) --source $(or $(SOURCE),generic_manual) + python3 -m src.price_import_normalizer --input $(INPUT) --ticker $(TICKER) --source $(or $(SOURCE),generic_manual) $(if $(SOURCE_REF),--source-ref "$(SOURCE_REF)",) $(if $(RETRIEVED_AT),--retrieved-at "$(RETRIEVED_AT)",) $(if $(AS_OF),--review-cutoff "$(AS_OF)",) else - python3 -m src.price_import_normalizer --input $(INPUT) --source $(or $(SOURCE),generic_manual) + python3 -m src.price_import_normalizer --input $(INPUT) --source $(or $(SOURCE),generic_manual) $(if $(SOURCE_REF),--source-ref "$(SOURCE_REF)",) $(if $(RETRIEVED_AT),--retrieved-at "$(RETRIEVED_AT)",) $(if $(AS_OF),--review-cutoff "$(AS_OF)",) endif daily: @@ -1082,10 +1170,10 @@ daily: python3 -m src.project_status --write-output dashboard: - streamlit run src/dashboard.py --client.toolbarMode viewer --server.headless true + PYTHONPATH="$(CURDIR):$${PYTHONPATH:-}" streamlit run src/dashboard.py --client.toolbarMode viewer --server.headless true demo-dashboard: - STOCK_RESEARCH_DATA_PROFILE=demo streamlit run src/dashboard.py --client.toolbarMode viewer --server.headless true + STOCK_RESEARCH_DATA_PROFILE=demo PYTHONPATH="$(CURDIR):$${PYTHONPATH:-}" streamlit run src/dashboard.py --client.toolbarMode viewer --server.headless true dashboard-smoke: scripts/smoke_dashboard.sh @@ -1096,6 +1184,24 @@ demo-dashboard-smoke: dashboard-render-smoke: @python3 -m src.dashboard_render_smoke +research-dashboard-render-smoke: + @python3 -m src.dashboard_render_smoke --routes research + +.PHONY: sec-quarterly-cash-preview +sec-quarterly-cash-preview: +ifndef AS_OF + $(error AS_OF is required, for example: make sec-quarterly-cash-preview AS_OF=2026-07-20T23:59:59-04:00) +endif + @python3 -m src.sec_quarterly_cash_generation_preview \ + --ticker "$(or $(TICKER),NVDA)" \ + --cik "$(or $(CIK),0001045810)" \ + --fiscal-period "$(or $(FISCAL_PERIOD),2027-Q1)" \ + --period-start "$(or $(PERIOD_START),2026-01-26)" \ + --period-end "$(or $(PERIOD_END),2026-04-26)" \ + --accession "$(or $(ACCESSION),0001045810-26-000052)" \ + --primary-document "$(or $(PRIMARY_DOCUMENT),nvda-20260426.htm)" \ + --as-of "$(AS_OF)" + demo-dashboard-render-smoke: @STOCK_RESEARCH_DATA_PROFILE=demo python3 -m src.dashboard_render_smoke @@ -1111,6 +1217,31 @@ provider-setup-checklist: hosted-demo-readiness: @python3 -m src.hosted_demo_readiness --root . +private-beta-readiness: + @python3 -m src.private_beta_readiness + +commercial-beta-check: + @echo "Commercial Research Beta Check" + @echo "Read-only: checks source rights, refresh-operation safety, and private-beta readiness without fetching or applying data." + @$(MAKE) --silent commercial-source-rights + @$(MAKE) --silent refresh-operations-status + @$(MAKE) --silent private-beta-readiness + +commercial-beta-release-check: + @echo "Commercial Research Beta Release Check" + @echo "Read-only: verifies the local release candidate without fetching, importing, applying, staging, committing, pushing, or changing external services." + @$(MAKE) --silent commercial-beta-check + @python3 -m pytest tests/test_dashboard_render_smoke.py tests/test_public_performance_gate.py tests/test_launchers.py -q + @$(MAKE) --silent research-dashboard-render-smoke + @$(MAKE) --silent commercial-beta-performance-contract + @$(MAKE) --silent browser-qa-evidence + @$(MAKE) --silent public-check + @$(MAKE) --silent pilot-readiness-check TOP_N=10 + @$(MAKE) --silent diff-hygiene-summary + @git diff --check + @echo "Safe claims: reproducible local research workflow; readiness-first analysis; source-rights gates; research-only controlled beta candidate." + @echo "Unsafe claims: hosted or authenticated availability; complete coverage; calibrated predictive accuracy; investment recommendations; licensed operation without verified rights." + provider-smoke: ifndef TICKER $(error TICKER is required, for example: make provider-smoke PROVIDER=fmp TICKER=NVDA) @@ -1232,6 +1363,9 @@ import-analyst-estimates: readiness: python3 -m src.readiness_engine +readiness-preview: + @PYTHONDONTWRITEBYTECODE=1 python3 -m src.readiness_preview --top-n $(or $(TOP_N),20) + readiness-snapshot: python3 -m src.readiness_engine --snapshot-only @@ -1278,7 +1412,107 @@ earnings-nowcast-preview: @python3 -m src.earnings_nowcast_onboarding preview --input-dir $(or $(INPUT_DIR),data/imports/earnings_nowcast) $(if $(EXISTING_DIR),--existing-dir $(EXISTING_DIR),) $(if $(AS_OF),--cutoff $(AS_OF),) earnings-nowcast-readiness: - @python3 -m src.earnings_nowcast_onboarding readiness --input-dir $(or $(INPUT_DIR),data/imports/earnings_nowcast) --ticker $(or $(TICKER),SYN1) $(if $(AS_OF),--cutoff $(AS_OF),) + @python3 -m src.earnings_nowcast_onboarding readiness --input-dir $(if $(FIXTURE),tests/fixtures/earnings_nowcast_onboarding,$(or $(INPUT_DIR),data/imports/earnings_nowcast)) --ticker $(or $(TICKER),SYN1) $(if $(AS_OF),--cutoff $(AS_OF),) + +earnings-nowcast-cohort-readiness: + @python3 -m src.earnings_nowcast_cohort --input-dir $(if $(FIXTURE),tests/fixtures/earnings_nowcast_onboarding,$(or $(INPUT_DIR),data/imports/earnings_nowcast)) --tickers "$(or $(TICKERS),$(EARNINGS_NOWCAST_COHORT))" --as-of "$(or $(AS_OF),2026-01-31T23:59:59Z)" $(if $(JSON),--json,) + +earnings-consensus-source-status: + @python3 -m src.earnings_consensus_sources $(if $(REVIEWED_CSV),--reviewed-csv "$(REVIEWED_CSV)",) $(if $(JSON),--json,) + +earnings-consensus-source-review: + @PYTHONDONTWRITEBYTECODE=1 python3 -m src.earnings_consensus_sources --review-csv "$(INPUT)" --provider "$(PROVIDER)" --as-of "$(AS_OF)" $(if $(JSON),--json,) + +earnings-consensus-collection-plan: + @python3 -m src.earnings_consensus_collector plan --tickers "$(or $(TICKERS),$(EARNINGS_NOWCAST_COHORT))" --as-of "$(or $(AS_OF),2026-01-31T23:59:59Z)" --cadence "$(or $(CADENCE),weekly)" + +earnings-consensus-collection-status: + @python3 -m src.earnings_consensus_collector status --ledger "$(or $(LEDGER),data/imports/earnings_nowcast/prospective_consensus.csv)" + +earnings-consensus-collection-preview: + @python3 -m src.earnings_consensus_collector preview --input "$(INPUT)" --ledger "$(or $(LEDGER),data/imports/earnings_nowcast/prospective_consensus.csv)" --as-of "$(AS_OF)" + +earnings-consensus-collection-record: + @test -n "$(AS_OF)" || (echo "AS_OF is required and must match the reviewed preview cutoff" >&2; exit 2) + @test -n "$(PREVIEW_RECEIPT)" || (echo "PREVIEW_RECEIPT is required from the exact reviewed preview" >&2; exit 2) + @test "$(CONFIRM_REVIEWED)" = "1" || (echo "CONFIRM_REVIEWED=1 is required after reviewing the exact preview" >&2; exit 2) + @python3 -m src.earnings_consensus_collector record --input "$(INPUT)" --ledger "$(or $(LEDGER),data/imports/earnings_nowcast/prospective_consensus.csv)" --as-of "$(AS_OF)" --preview-receipt "$(PREVIEW_RECEIPT)" --confirm-reviewed + +prospective-field-proof-status: export FIELD_PROOF_LEDGER := $(if $(strip $(value LEDGER)),$(value LEDGER),data/prospective_field_proofs.csv) +prospective-field-proof-status: export FIELD_PROOF_JSON := $(value JSON) +prospective-field-proof-status: + @if [ "$${FIELD_PROOF_JSON}" = "1" ]; then \ + PYTHONDONTWRITEBYTECODE=1 python3 -m src.prospective_field_proof status --ledger "$${FIELD_PROOF_LEDGER}" --json; \ + else \ + PYTHONDONTWRITEBYTECODE=1 python3 -m src.prospective_field_proof status --ledger "$${FIELD_PROOF_LEDGER}"; \ + fi + +prospective-field-proof-audit: export FIELD_PROOF_LEDGER := $(if $(strip $(value LEDGER)),$(value LEDGER),data/prospective_field_proofs.csv) +prospective-field-proof-audit: export FIELD_PROOF_JSON := $(value JSON) +prospective-field-proof-audit: + @if [ "$${FIELD_PROOF_JSON}" = "1" ]; then \ + PYTHONDONTWRITEBYTECODE=1 python3 -m src.prospective_field_proof audit --ledger "$${FIELD_PROOF_LEDGER}" --json; \ + else \ + PYTHONDONTWRITEBYTECODE=1 python3 -m src.prospective_field_proof audit --ledger "$${FIELD_PROOF_LEDGER}"; \ + fi + +prospective-field-proof-preview: export FIELD_PROOF_INPUT := $(value INPUT) +prospective-field-proof-preview: export FIELD_PROOF_LEDGER := $(if $(strip $(value LEDGER)),$(value LEDGER),data/prospective_field_proofs.csv) +prospective-field-proof-preview: export FIELD_PROOF_AS_OF := $(value AS_OF) +prospective-field-proof-preview: export FIELD_PROOF_JSON := $(value JSON) +prospective-field-proof-preview: + @if [ "$${FIELD_PROOF_JSON}" = "1" ]; then \ + PYTHONDONTWRITEBYTECODE=1 python3 -m src.prospective_field_proof preview --input "$${FIELD_PROOF_INPUT}" --ledger "$${FIELD_PROOF_LEDGER}" --as-of "$${FIELD_PROOF_AS_OF}" --json; \ + else \ + PYTHONDONTWRITEBYTECODE=1 python3 -m src.prospective_field_proof preview --input "$${FIELD_PROOF_INPUT}" --ledger "$${FIELD_PROOF_LEDGER}" --as-of "$${FIELD_PROOF_AS_OF}"; \ + fi + +prospective-field-proof-record: export FIELD_PROOF_INPUT := $(value INPUT) +prospective-field-proof-record: export FIELD_PROOF_LEDGER := $(if $(strip $(value LEDGER)),$(value LEDGER),data/prospective_field_proofs.csv) +prospective-field-proof-record: export FIELD_PROOF_AS_OF := $(value AS_OF) +prospective-field-proof-record: export FIELD_PROOF_PREVIEW_RECEIPT := $(value PREVIEW_RECEIPT) +prospective-field-proof-record: export FIELD_PROOF_CONFIRM_REVIEWED := $(value CONFIRM_REVIEWED) +prospective-field-proof-record: export FIELD_PROOF_JSON := $(value JSON) +prospective-field-proof-record: + @case "$${FIELD_PROOF_INPUT}" in *[![:space:]]*) ;; *) echo "INPUT is required for the exact reviewed field proof batch" >&2; exit 2;; esac + @case "$${FIELD_PROOF_AS_OF}" in *[![:space:]]*) ;; *) echo "AS_OF is required and must match the reviewed preview cutoff" >&2; exit 2;; esac + @case "$${FIELD_PROOF_PREVIEW_RECEIPT}" in *[![:space:]]*) ;; *) echo "PREVIEW_RECEIPT is required from the exact reviewed preview" >&2; exit 2;; esac + @test "$${FIELD_PROOF_CONFIRM_REVIEWED}" = "1" || (echo "CONFIRM_REVIEWED=1 is required after reviewing the exact preview" >&2; exit 2) + @if [ "$${FIELD_PROOF_JSON}" = "1" ]; then \ + PYTHONDONTWRITEBYTECODE=1 python3 -m src.prospective_field_proof record --input "$${FIELD_PROOF_INPUT}" --ledger "$${FIELD_PROOF_LEDGER}" --as-of "$${FIELD_PROOF_AS_OF}" --preview-receipt "$${FIELD_PROOF_PREVIEW_RECEIPT}" --confirm-reviewed --json; \ + else \ + PYTHONDONTWRITEBYTECODE=1 python3 -m src.prospective_field_proof record --input "$${FIELD_PROOF_INPUT}" --ledger "$${FIELD_PROOF_LEDGER}" --as-of "$${FIELD_PROOF_AS_OF}" --preview-receipt "$${FIELD_PROOF_PREVIEW_RECEIPT}" --confirm-reviewed; \ + fi + +research-outcome-review: + @python3 -m src.research_outcome_review --ledger "$(or $(LEDGER),data/research_outcome_reviews.csv)" --profile-key "$(or $(PROFILE_KEY),default)" --ticker "$(or $(TICKER),NVDA)" + +research-outcome-review-preview: + @python3 -m src.research_outcome_review --ledger "$(or $(LEDGER),data/research_outcome_reviews.csv)" --preview-input "$(INPUT)" + +research-outcome-review-record: + @python3 -m src.research_outcome_review --ledger "$(or $(LEDGER),data/research_outcome_reviews.csv)" --record-input "$(INPUT)" $(if $(CONFIRM_REVIEWED),--confirm-reviewed,) + +catalyst-evidence-timeline: + @python3 -m src.catalyst_evidence_timeline --ledger "$(or $(LEDGER),data/catalyst_evidence.csv)" --profile-key "$(or $(PROFILE_KEY),default)" --ticker "$(or $(TICKER),NVDA)" --as-of "$(or $(AS_OF),2026-01-31T23:59:59Z)" + +catalyst-evidence-preview: + @python3 -m src.catalyst_evidence_timeline --ledger "$(or $(LEDGER),data/catalyst_evidence.csv)" --preview-input "$(INPUT)" + +catalyst-evidence-record: + @python3 -m src.catalyst_evidence_timeline --ledger "$(or $(LEDGER),data/catalyst_evidence.csv)" --record-input "$(INPUT)" $(if $(CONFIRM_REVIEWED),--confirm-reviewed,) earnings-nowcast-prospective-plan: @python3 -m src.earnings_nowcast_onboarding prospective-plan --output-dir $(or $(OUTPUT_DIR),data/imports/earnings_nowcast) + +earnings-nowcast-sec-actuals-stage: +ifndef TICKERS + $(error TICKERS is required, for example: make earnings-nowcast-sec-actuals-stage TICKERS=NVDA OUTPUT_DIR=/tmp/sec-actuals AS_OF=2026-07-16T03:59:59Z) +endif +ifndef OUTPUT_DIR + $(error OUTPUT_DIR is required; use a generated temporary/review directory) +endif +ifndef AS_OF + $(error AS_OF is required for cutoff-safe evidence staging) +endif + @python3 -m src.earnings_nowcast_sec_actuals --tickers "$(TICKERS)" --output-dir "$(OUTPUT_DIR)" --cutoff "$(AS_OF)" --max-runtime-seconds "$(or $(SEC_STAGE_MAX_RUNTIME_SECONDS),300)" diff --git a/PRODUCT_SPEC.md b/PRODUCT_SPEC.md index 51b11966..23f686ca 100644 --- a/PRODUCT_SPEC.md +++ b/PRODUCT_SPEC.md @@ -6,15 +6,31 @@ This project is a local, CSV-first stock research command center. It helps a use The product is not a trading bot. It does not place orders, connect to brokers, recommend options trades, or fabricate missing market, fundamentals, earnings, or analyst-estimate data. +The supported workflow is `Research Desk -> Discover -> Company Workbench -> Monitor`. Operator-only legacy compatibility utilities are retained for deterministic regression and historical file contracts, not as current investing capability. + +## In-App Reviewed Research Records + +Thesis, evidence, catalyst, and outcome records are all available in the collapsed Company Workbench composer. +A valid record requires an exact preview and explicit confirmation before save. +Final integrity commit `e3a090dba` ensures confirmation appends only the receipt-matched recomputed record and enforces one readable active thesis lineage: revisions must supersede the exact active entry and preserve its thesis ID. The Company Workbench locks and explains that relationship, with temporary-ledger create -> revise -> reload coverage. +Confirmation-integrity commit `5a6c55921` binds every displayed preview field, preview time, and destination label to the exact receipt. If an append raises after it may have written, confirmation returns one-shot `save_pending_reload` with the exact record ID unless the locked ledger is provably unchanged; it never invites a blind duplicate retry. +Drafts are untrusted and preview receipts are session-only. +Production tests never append repository ledgers; persistence tests use temporary ledgers. +A saved record cannot change readiness, forecasts, probabilities, recommendations, or any other ledger. + +Priority 4's local validator is frozen; its permitted real-data exit gate remains externally incomplete. +Priority 6's provider-neutral authorization contract is complete locally; hosted implementation remains environment-dependent. +Priority 7 accessibility remediation is the next safe executable local lane. + ## Target User The target user is an individual investor or research reviewer who wants a deterministic local workflow for: - maintaining a broad market universe; - narrowing that universe into an active research list; -- reviewing portfolio holdings; +- reviewing one company and its source-backed research record; - tracking readiness by analysis feature; -- generating watchlists only when the needed data exists; +- monitoring a focused cohort without ranking companies for action; - seeing exact data blockers and next import actions. ## Non-Goals @@ -99,6 +115,12 @@ The product is now organized around a few research-facing surfaces: The primary public path is: Home workflow start -> Stock Selector -> Single-Stock Report -> Data Health lane answer -> Proof History evidence. +## Legacy Compatibility Boundary + +`Monthly Picks`, `Momentum Leaders`, `Portfolio Review`, `Value / Re-rating`, and `Final Watchlist` are Operator-only compatibility utilities. Every retained page is labeled **Legacy research utility — not part of Personal Research Mode**, and its detailed output is hidden behind an explicit collapsed compatibility control. + +These utilities preserve historical algorithms, regression checks, and output filenames only. They cannot feed Research Decision Lab, cannot change readiness, and cannot produce recommendations, sizing, or transaction behavior. Public and Personal Research routes cannot open them. Retained position, cost-basis, disposition, ranked-candidate, or action-like fields are not supported product claims. + These surfaces must show supported, blocked, partial, and excluded states before showing detailed tables. Broad-universe tables, command blocks, route maps, and proof ledgers should stay filtered, row-limited, collapsed, or operator-scoped by default. ## MVP Definition diff --git a/READINESS_MODEL.md b/READINESS_MODEL.md index c9ee9a7c..8cfb242b 100644 --- a/READINESS_MODEL.md +++ b/READINESS_MODEL.md @@ -37,6 +37,8 @@ The feature does not apply to this ticker or asset type. | `portfolio_ready` | holdings, prices, purpose metadata | holding row plus enough relevant data for review component | holding ticker parseable, position fields numeric when present | `portfolio_review.csv` | show review state | show missing components | mark holding blocked | not applicable | | `final_decision_ready` | central readiness report plus analysis outputs | enough data for the chosen decision bucket | blocked features reduce confidence | `research_decisions.csv`, `final_watchlist.csv` | show decision bucket and confidence | show reduced confidence | classify as Blocked by Data | classify as Excluded | +`momentum_ready`, `portfolio_ready`, and `final_decision_ready` remain compatibility-only readiness rows for historical pipelines. Their retained pages are labeled **Legacy research utility — not part of Personal Research Mode** and are available only in Operator mode behind a collapsed compatibility control. They cannot feed Research Decision Lab, cannot change readiness outside their own historical calculation contracts, and cannot produce recommendations, sizing, or transaction behavior. Personal Research and Public routes do not expose these rows as current product capability. + ## General Dashboard Rules - Show readiness before conclusions. diff --git a/README.md b/README.md index ce479a01..cd3c68c2 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,19 @@ # Stock Research Command Center -A local, CSV-first research dashboard for screening stocks, reviewing portfolio names, and seeing exactly which data is ready to support analysis. +An **Evidence-First Research Workbench** for serious individual equity researchers and small research teams performing repeated company research, valuation review, forward-scenario analysis, and truthful data-readiness decisions. > Data readiness first, analysis second, research decision last. -![Dashboard preview](docs/assets/public-demo-home-real.jpg) + ## External Reviewer Start Here + +![Company Workbench answer preview](docs/assets/linkedin-public-dashboard.png) + This repository is ready to review as a controlled GitHub/LinkedIn portfolio demo. It is not currently published as a hosted Streamlit app. +**Primary product workflow:** Research Desk -> Discover -> Company Workbench -> Monitor. Run `make dashboard`, then open `http://localhost:8501/`. The Workbench answers what can be used now and keeps a six-lane Research Decision Lab below `What Changed`; Monitor adds a no-ranking Research Discipline Review while source-change state remains independent. Data Health and Proof History stay under **Advanced Evidence**. See [Personal Research Mode](docs/PERSONAL_RESEARCH_MODE.md) for workflow states and truth boundaries. + +**Secondary controlled demo:** Home -> Stock Selector -> Single-Stock Report -> Data Health -> Proof History. This is the shorter public-review path for visitors who do not need the complete company-research workspace. + +This is a **local Commercial Research Beta foundation**, not a hosted or commercially launched product. Operator context remains the source/proof workspace. Authentication, private workspaces, operated data rights, real beta users, and repeatable provider operations remain separate gates. A local contract pass does not prove hosting, licensed operation, or external validation. + | Question | Short answer | | --- | --- | | What should I open first? | Start with this README preview, then use `docs/PUBLIC_DEMO_WALKTHROUGH.md` for the five-page workflow. | @@ -19,20 +28,23 @@ This repository is ready to review as a controlled GitHub/LinkedIn portfolio dem | What proves current local readiness? | `make status-check TOP_N=5` remains the source for current local counts; screenshots are product evidence only. | | What should I not claim? | No hosted app yet, no open-source reuse, no investment advice, no broker integration, no auto-trading, and no screenshot-based data freshness proof. | -First review move: open Stock Selector, choose a ticker such as `NVDA`, read the Single-Stock Report answer, then open Data Health only when an input is blocked. The selected-profile trust strip follows [Data Profiles](docs/DATA_PROFILES.md) and shows source/readiness dates, identity, freshness, and matching counts. The local Change Monitor queues same-profile evidence changes as research tasks, never recommendations. The append-only Research Thesis Journal preserves reviewer-authored hypotheses, evidence, invalidation conditions, confidence history, and review dates without changing readiness. The session-local Scenario Lab varies bounded DCF assumptions without writing canonical data. The Source Freshness Timeline separates effective, retrieval, market, and report times. The operator-only Research Comparison View compares two or three selected tickers across evidence availability and reviewed journal context without a score or winner. The Peer Read-Through Map keeps candidate and trusted relationships separate and withholds contextual read-through until relationship, result, and fiscal-timing evidence are explicit. The Decision-Process Scorecard reviews documentation discipline without grading the company or measuring returns; see [Methodology](docs/METHODOLOGY.md). +First personal-research move: open Research Desk for the deterministic focused cohort and weekly summary, use Discover to choose a ticker such as `NVDA`, and open Company Workbench. The Workbench separates changed evidence, source-backed quarterly Revenue/EPS trend, valuation, forward context, withheld inputs, conclusion, and one next research task. Missing canonical quarterly rows fail closed, Q4 is never derived, and the cohort order is reviewability rather than expected return. Use Monitor for source-backed changes; open Data Health only when a missing input is the question. Supporting research tools remain secondary to that answer flow. The selected-profile trust strip follows [Data Profiles](docs/DATA_PROFILES.md). The append-only Research Thesis Journal, session-local Scenario Lab, Source Freshness Timeline, operator-only Research Comparison View, Peer Read-Through Map, and Decision-Process Scorecard preserve their existing evidence and no-ranking boundaries; see [Methodology](docs/METHODOLOGY.md). Thesis, evidence, catalyst, and outcome records are all available in the collapsed Company Workbench composer. A valid record requires an exact preview and explicit confirmation before save. Drafts are untrusted and preview receipts are session-only. Production tests never append repository ledgers; persistence tests use temporary ledgers. A saved record cannot change readiness, forecasts, probabilities, recommendations, or any other ledger. Priority 4's local validator is frozen; its permitted real-data exit gate remains externally incomplete. Priority 6's provider-neutral authorization contract is complete locally; hosted implementation remains environment-dependent. Priority 7 accessibility remediation is the next safe executable local lane. Final integrity commit `e3a090dba` ensures confirmation appends only the receipt-matched recomputed record and enforces one readable active thesis lineage: revisions must supersede the exact active entry and preserve its thesis ID. The Company Workbench locks and explains that relationship, with temporary-ledger create -> revise -> reload coverage. Confirmation-integrity commit `5a6c55921` binds every displayed preview field, preview time, and destination label to the exact receipt. If an append raises after it may have written, confirmation returns one-shot `save_pending_reload` with the exact record ID unless the locked ledger is provably unchanged; it never invites a blind duplicate retry. + +Older Monthly Picks, Momentum Leaders, Portfolio Review, Value / Re-rating, and Final Watchlist views are retained only behind the Operator boundary **Legacy research utility — not part of Personal Research Mode**. They are compatibility and regression aids, not supported investing features. They cannot feed Research Decision Lab, cannot change readiness, and cannot produce recommendations, sizing, or transaction behavior. Public and Personal Research deep links fail closed to their safe start pages; legacy details require an explicit collapsed Operator control. +First review move: open Stock Selector, choose a ticker such as `NVDA`, read the Single-Stock Report answer, then open Data Health only when an input is blocked. ## What It Does This project turns a broad stock universe into a readiness-first research dashboard. It checks market data before analysis, separates `Research Now`, `Monitor`, and `Blocked by Data` review states, explains missing prices, fundamentals, DCF inputs, peers, earnings, and analyst estimates, and produces Streamlit pages plus single-stock reports with At A Glance status, a plain-English Reader Guide, an Evaluation Snapshot, a Proof Checklist, Best Review Path, data-confidence cues, source readiness notes, and read-only proof steps. ### Earnings Nowcast Pilot The repository includes a readiness-gated, deterministic synthetic-fixture workflow for quarterly Revenue/EPS ranges and consensus-relative classification. It records fiscal period, forecast cutoff, expected report date, forecast horizon, provenance, model version, input hash, freshness, metric definitions, and withheld states; peer/news signals are directional evidence only and cannot change forecast numbers. Duplicate fiscal periods cannot inflate history, unresolved revisions fail closed per metric, and Revenue/EPS definitions must match on currency, unit scale, accounting basis, share basis, operations basis, and split treatment. Run `make earnings-nowcast-walkthrough` for six clearly synthetic reviewer scenarios, or `FIXTURE=1 make earnings-nowcast-pilot TICKER=SYN1 AS_OF=2026-01-31T23:59:59Z` to inspect one offline packet. Read-only templates, validation, preview, readiness, and prospective collection planning are documented in [Earnings Nowcast Pilot](docs/EARNINGS_NOWCAST_PILOT.md); there is no automatic apply path. -This infrastructure does not establish real-company coverage or predictive accuracy. Real semiconductor output remains blocked until append-only point-in-time consensus and quarterly actual histories are source-backed. Numerical Beat/Miss probability is withheld until at least 100 leakage-safe out-of-sample events pass calibration and benchmark gates. The pilot does not predict post-earnings price movement and remains research-only, not investment advice. +This infrastructure does not establish real-company coverage or predictive accuracy. Real semiconductor output remains blocked until append-only point-in-time consensus and quarterly actual histories are source-backed. Numerical Beat/Miss probability is withheld until at least 100 leakage-safe out-of-sample events pass calibration and benchmark gates. The pilot does not predict post-earnings price movement and remains research-only, not investment advice. The activation layer adds a five-company readiness board, prospective consensus collection, point-in-time valuation context, outcome learning, and catalyst evidence inside the existing research workflow; see the pilot and methodology docs for their fail-closed contracts. Stage A is prospective-only: `make prospective-field-proof-status`, read-only `make prospective-field-proof-audit`, `make prospective-field-proof-preview INPUT= AS_OF=`, and explicit `make prospective-field-proof-record INPUT= AS_OF= PREVIEW_RECEIPT= CONFIRM_REVIEWED=1` preserve `technical_write_eligible` and `commercial_evidence_eligible` independently. The preview receipt binds ledger, input, cutoff, commercial mode, and source-rights registry. Audit exposes append history and active-head blockers with `preview_receipt_persisted=false` and `receipt_revalidation_required=true`; it does not activate readiness, update canonical data, or activate Company Workbench. An absent ledger is a valid empty state, and legacy narrative proof is not upgraded. No sample field-proof rows are checked in. Detailed operation and locking limits are in [Local Workflow Guide](docs/OPERATOR_GUIDE.md). ```mermaid flowchart LR - Home["Home: workflow start"] --> Selector["Stock Selector: readiness-backed queue"] - Selector --> Report["Single-Stock Report: one ticker"] - Report --> Health["Data Health: missing input"] - Health --> Proof["Proof History: source-proof trail"] + Desk["Research Desk: changed evidence"] --> Discover["Discover: readiness-backed company"] + Discover --> Workbench["Company Workbench: trend, valuation, forward view"] + Workbench --> Monitor["Monitor: unresolved research changes"] + Workbench -. advanced evidence .-> Health["Data Health and Proof History"] ``` ## Now / Next / Not Yet This is the fastest reviewer answer: the product is shareable as a controlled demo now, deeper coverage is source-gated, and hosting/provider automation stays optional until verified. @@ -41,22 +53,8 @@ This is the fastest reviewer answer: the product is shareable as a controlled de | Now | GitHub/LinkedIn portfolio demo with public workflow, screenshots, methodology, local run commands, manual gates, and a locally passed performance gate. | Use `make public-check` before sharing; keep generated churn excluded and do not treat local timing as hosted proof. | | Next | Optional controlled hosted preview and task-based external pilot review. | Hosting remains external until a URL is verified; reviewer feedback must remain anonymous workflow evidence, not investment opinion. | | Not yet | Full hosted data product, complete fundamentals/peer/optional coverage, or provider-backed automation across the universe. | Do not claim this until external hosting, provider keys, source proof, validation, preview, apply, rebuilt readiness, and proof history support it. | -## Current Next Stages -Use this table to decide what to do next without reopening exhausted proof loops or overstating the public demo. -| Stage | Current state | Next safe move | -| --- | --- | --- | -| Performance release candidate | Passed locally on the fixed demo profile | Re-run `make public-performance-gate` for current route evidence; do not mix measurements with broad data refreshes or treat local timing as hosted proof. | -| LinkedIn publish | Ready after GitHub sync | If the branch is ahead, push reviewed commits after `make public-check`; if GitHub is synced, use the GitHub link and `docs/LINKEDIN_PROJECT_BRIEF.md`; do not claim hosted app availability. | -| Hosted Streamlit demo | External account required | Run `make hosted-demo-readiness`, then follow `docs/HOSTED_DEMO_DEPLOYMENT.md`; keep GitHub as the public link until the hosted route is verified. | -| FMP provider activation | External key required | Configure `FMP_API_KEY` outside the repo, then run one reviewed ticker smoke before any broader batch. | -| Peer readiness upgrade | Source-gated | Keep candidate peers as context only until source-backed peer rows pass review. | -| Optional earnings / estimates | Locked | Use trusted provider or reviewed manual rows only; do not infer optional context. | -| Earnings Nowcast evidence | Real-data safety gates ready; real evidence blocked | Use the versioned templates and prospective plan; add no real output until comparable quarterly actuals and point-in-time consensus pass conflict, cutoff, validation, and readiness gates. | -| Broad proof queues | Do not retry now | Current queues are exhausted; reopen only after keyed provider rows, reviewed manual rows, or changed blockers exist. | -| Public UX polish | Review limited | Public checks and repeated local cold/warm route timings pass; external reviewer evidence is still required before upgrading the claim. | -| Generated artifacts | Excluded by default | Keep local CSV/report/sample-report churn unstaged unless one exact artifact is reviewed as public evidence. | ## What You Can Analyze -When trusted local data is available, the product can produce price, momentum, benchmark-relative return, drawdown, volatility, beta, Sharpe/Sortino review metrics, liquidity, market-direction context, portfolio purpose checks, thesis-review flags, DCF readiness, conservative scenario valuation, source-backed peer context, ETF/index monitor reports, and single-stock reports with reader guidance, proof checklists, blockers, read-only proof steps, and source readiness notes. Most blocked rows are not errors. They are data gaps the command center exposes instead of hiding. +When trusted local data is available, the supported workflow can produce price and benchmark context, drawdown, volatility, beta, Sharpe/Sortino review metrics, liquidity, market-direction context, thesis-review flags, DCF readiness, conservative scenario valuation, source-backed peer context, ETF/index monitor reports, and single-stock reports with reader guidance, proof checklists, blockers, read-only proof steps, and source readiness notes. Historical ranked, picks, portfolio, and final-watchlist calculations remain operator-only compatibility utilities. Most blocked rows are not errors. They are data gaps the command center exposes instead of hiding. ## How Analysis Works The report is not a black box: local data rows provide inputs, and project rules decide what can be analyzed. Price-ready rows can support setup/risk context and benchmark/risk review metrics, DCF-ready rows can support assumptions and sensitivity, and peer-ready rows can support source-backed relative context. Missing fundamentals, peer inputs, earnings, or estimates stay locked; company valuation is excluded for ETF/index/fund monitor rows, not failed. ## Current Snapshot @@ -81,7 +79,7 @@ Use this as the short GitHub/LinkedIn review path before reading operator detail | Shares outstanding proof | Use `make share-count-proof-queue TOP_N=10` when DCF is blocked specifically by `shares_outstanding`. | Share count must come from SEC/manual source proof or trusted local rows; the product does not infer it from price, market cap, or peers. | | Peers | Use `DRY_RUN=1 make peer-batch-proof TOP_N=10` and `docs/TRUSTED_PEER_PILOT_SOURCE_TEMPLATE.csv` to collect reviewed 25-50 company source rows outside the import file; use `make peer-mapping-writeback-guard ...` before copy/paste, and use the ranked pilot packet first when a peer-input lane leads. | Peer trend and peer valuation stay separate; guessed peers or file row counts do not become valuation, and candidate context stays out of trusted proof as `candidate_context_only` until source-backed proof passes. | | Earnings / estimates | Keep locked until trusted local rows exist. | Empty optional context is intentional, not a broken chart. | -Pilot packaging is read-only first: `make pilot-readiness-check TOP_N=10` checks sync, hygiene, freshness, source-proof queues, proof ledger, screenshot evidence, public-check, and guardrails; `make pilot-share-brief` writes the concise public/demo share brief at `outputs/pilot_share_brief.md`, which does not refresh data or unlock blocked inputs. Use `make pilot-readiness-packet` only when a reviewer needs the fuller packet. +Pilot packaging is read-only first, but not entirely read-only: start with `make pilot-readiness-check TOP_N=10`, which checks sync, hygiene, freshness, source-proof queues, proof ledger, screenshot evidence, public-check, and guardrails. `make pilot-share-brief` writes the concise public/demo share brief at `outputs/pilot_share_brief.md`; `make pilot-readiness-packet` writes `outputs/pilot_readiness_packet.md` for a fuller reviewer packet. `make pilot-readiness-packet` is not read-only. The generated packet does not refresh data or unlock blocked inputs. When proof queues are exhausted, use `make project-status-check` and then `make provider-setup-checklist`. Provider setup is only an activation boundary: it can activate a source, but readiness changes still require validate, preview, rejected-row review, source provenance, apply/skip decision, rebuilt readiness, and proof ledger evidence. No broad coverage batch should run from setup alone. Do not retry exhausted proof queues until new source-backed rows, keyed provider data, reviewed manual rows, or changed blockers exist. Operator runbooks live outside the first-review README: use `docs/OPERATOR_GUIDE.md` for reviewed batch execution, `docs/DATA_STRATEGY.md` for lane mechanics, and `docs/SOURCE_ACTIVATION_GUIDE.md` for provider/source setup. Before turning any refresh path into a recurring job, run `make scheduler-activation-checklist`. Scheduler maturity starts as status-only monitoring; mutating refresh or apply paths stay off until provider smoke or source proof, validation, preview, zero rejected rows, provenance, no-fabrication checks, rebuilt readiness, proof history, and proof recording pass. @@ -90,7 +88,15 @@ This is a working local research prototype with deterministic outputs, dashboard Useful with limits: price/momentum, fundamentals/DCF, peer review, and final decision buckets when trusted local data exists. Intentionally locked: broad-universe fundamentals, peer comparison, earnings, and analyst estimates until trusted rows are imported. Not built to be: a full-market data vendor, real-time recommendation service, broker/execution system, or auto-refreshing trading system. ## Product Tour -Start with the five public paths the dashboard is built around: +For repeated local use, start with the four Personal Research destinations: +| Path | Use it when | First answer | +| --- | --- | --- | +| Research Desk | You want changed evidence, attention items, blocked/stale work, and the next research action. | What changed since the last comparable review? | +| Discover | You want to filter readiness-backed companies without a buy ranking. | Which company can support review now? | +| Company Workbench | You want one company answer spanning data usability, business trend, valuation, forward context, uncertainty, and next review work. | What can I use now, and what remains withheld? | +| Monitor | You want unresolved source-backed changes and wait conditions. | Which evidence change needs review? | + +The controlled Public workspace keeps its existing five-page path: | Path | Use it when | First place to open | | --- | --- | --- | | Home | You want the workflow question, next safe action, stop rule, and then readiness context before choosing a route. | `Home` | @@ -98,14 +104,14 @@ Start with the five public paths the dashboard is built around: | Single-Stock Report | You want a ticker-level research note with ready, blocked, excluded, and data-confidence states. | `Single-Stock Report` | | Data Health | You want to understand what trusted input is missing and which proof path should be reviewed next. | `Data Health` | | Proof History | You want one evidence answer before opening raw proof ledger details. | `Proof History` | -The dashboard starts in public visitor mode at `http://localhost:8501/?mode=public`. +The dashboard starts in Personal Research mode at `http://localhost:8501/`. Public review remains available at `http://localhost:8501/?mode=public`. - Home answers what the product is, where to start, and when to stop. - Stock Selector filters readiness-backed candidates without framing the queue as advice. - Single-Stock Report shows selected-ticker readiness, usable sections, blocked inputs, and one next step before detailed report sections. - Data Health starts with Coverage Summary / What Can I Use, one answer per lane, and advanced proof drawers collapsed. - Proof History is evidence-only before trusting a changed readiness state. -Switch off Public visitor mode only for internal Operator context, detailed boards, local proof commands, and validate / preview / apply guidance. Advanced pages remain secondary, and watchlist-style outputs stay readiness-state output, not an action list. +Choose Operator only for detailed boards, local proof commands, and validate / preview / apply guidance. Advanced pages remain secondary, and watchlist-style outputs stay readiness-state output, not an action list. ## Quick Start Run these from the repository root so `make` can find the project targets. Open the product before proof packets or report commands so reviewers see the guided workflow before operator detail. @@ -116,13 +122,13 @@ make demo # print the safe visitor path without changing local data make demo-dashboard # open the compact tracked profile at http://localhost:8501/?mode=public ``` -Optional read-only proof after the app flow is clear: `make status-check TOP_N=5`, `make pilot-readiness-check TOP_N=10 && make pilot-readiness-packet`, and `make stock-report-md TICKER=NVDA`. +Optional read-only proof after the app flow is clear starts with `make status-check TOP_N=5` and `make pilot-readiness-check TOP_N=10`; `make pilot-readiness-packet` and `make stock-report-md TICKER=NVDA` intentionally write their documented Markdown outputs. For the mutable default operator workspace, use `make dashboard`; it is intentionally separate from the public demo profile and can reflect local refresh/import work. When you want to run a controlled pilot, use the [Pilot Runbook](docs/PILOT_RUNBOOK.md). When you want to rebuild local outputs after changing data, use the deeper [Local Workflow Guide](docs/OPERATOR_GUIDE.md) for rebuild, import, refresh, and proof steps. -For 5-10 external reviewer sessions, run `make pilot-review-feedback` and use [Controlled Pilot Review Feedback](docs/PILOT_REVIEW_FEEDBACK_TEMPLATE.md) plus the structured [feedback log template](docs/PILOT_REVIEW_FEEDBACK_LOG_TEMPLATE.csv). Then run `make pilot-feedback-closeout` and follow the [Pilot Feedback Closeout Checklist](docs/PILOT_FEEDBACK_CLOSEOUT_CHECKLIST.md) to classify each row as `clear`, `reproducible_ui_issue`, `documentation_gap`, `environment_limited`, or `intentionally_deferred`. Capture route clarity and reproducible UX issues only; keep the working log outside Git until it is anonymized and intentionally reviewed; feedback does not prove data freshness, source readiness, investment conclusions, or coverage completion. +For 10-20 external reviewer sessions, run `make pilot-review-feedback` and use [Controlled Pilot Review Feedback](docs/PILOT_REVIEW_FEEDBACK_TEMPLATE.md) plus the structured [feedback log template](docs/PILOT_REVIEW_FEEDBACK_LOG_TEMPLATE.csv). Then run `make pilot-feedback-closeout` and follow the [Pilot Feedback Closeout Checklist](docs/PILOT_FEEDBACK_CLOSEOUT_CHECKLIST.md) to classify each row as `clear`, `reproducible_ui_issue`, `documentation_gap`, `environment_limited`, or `intentionally_deferred`. Capture route clarity and reproducible UX issues only; keep the working log outside Git until it is anonymized and intentionally reviewed; feedback does not prove data freshness, source readiness, investment conclusions, or coverage completion. ## Try This Visitor Workflow Open the product first and follow the five-page path. Use terminal commands only when you want to inspect the same proof artifacts locally. @@ -166,14 +172,8 @@ This repository is shared under a controlled portfolio-demo license. Visitors ma The stock-analysis method is implemented in this repository: readiness gates, momentum rules, DCF assumptions, relative-valuation checks, peer readiness, and report wording live under `src/`. Standard Python packages support data handling and UI; optional `yfinance` is an unofficial research-grade adapter, and configured FMP/Alpha Vantage/Finnhub keys can serve as research-grade fallback sources for price and fundamentals staging. The analysis rules, valuation gates, decision buckets, and research-only guardrails come from project code plus local CSV inputs. Fundamentals-ready means trusted company fields can be reviewed, DCF-ready means scenario math can be reviewed, and peer-ready means source-backed relative context can be reviewed. See [Research Methodology](docs/METHODOLOGY.md) for the calculation flow and [Analysis Capability Audit](docs/analysis_capability_audit.md) for what is strong today, what remains limited, and where the method lives. ## Core Outputs -The main build creates deterministic research files under `outputs/`, including purpose classification, market direction, momentum leaders, portfolio review, valuation-readiness context, final watchlist, and research decisions. `undervalued_candidates.csv` is a legacy filename for valuation-readiness and re-rating context, not automatic undervalued calls. Readiness and source-health reports live under `data/reports/`. +The main build retains deterministic historical files under `outputs/`, including purpose classification, market direction, momentum leaders, portfolio review, valuation-readiness context, final watchlist, and research decisions. Momentum, portfolio, value/re-rating, and final-watchlist files are compatibility outputs rather than current Personal Research capabilities. `undervalued_candidates.csv` is a legacy filename for valuation-readiness and re-rating context, not automatic undervalued calls. Readiness and source-health reports live under `data/reports/`. ## Research-Only Guardrails This is investment research software, not investment advice and not a trading system. It does not place orders, connect to brokers, route trades, auto-trade, recommend option trades, provide direct buy/sell instructions, or fabricate prices, fundamentals, peers, earnings, analyst estimates, valuation inputs, or recommendations. That constraint is intentional. The product is useful because it says when data is missing instead of pretending every ticker is ready. - -## Architecture -The app is organized around dashboard, readiness, decision, report, provider, local-data, and test modules. It is CSV-first and deterministic by default. Optional network-backed data stays behind provider interfaces and is labeled as research-grade when used. - -## Roadmap Snapshot -The current public workflow is intentionally guided: Home -> Stock Selector -> Single-Stock Report -> Data Health -> Proof History. The local fixed-demo performance gate has passed and remains a regression check. The active evidence stage is a narrow, append-only Earnings Nowcast pilot using comparable quarterly actuals and historical point-in-time consensus snapshots; hosting and external review remain separate external stages. Fundamentals/DCF, source-backed peers, optional earnings/estimate context, and real nowcast output stay locked until trusted rows prove readiness. diff --git a/ROADMAP.md b/ROADMAP.md index 7eeae4f2..c3ba89dd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,259 +1,279 @@ # Roadmap -Stock Research Command Center follows one principle: **data readiness first, analysis second, research decision last**. It is research-only software: no investment advice, broker trading, order routing, auto-trading, direct buy/sell instructions, or fabricated data. +Stock Research Command Center is an **Evidence-First Research Workbench** for serious individual equity researchers and small research teams. Its operating rule is **data readiness first, analysis second, research decision last**. -This is the active plan only. Completed delivery history lives in [Completed Milestones](docs/COMPLETED_MILESTONES.md). +It is research-only: no investment advice, recommendation, company ranking, broker integration, order routing, auto-trading, direct buy/sell instruction, allocation, position sizing, stop-loss, take-profit, or fabricated evidence. The active roadmap is this file; detailed delivery history lives in [Completed Milestones](docs/COMPLETED_MILESTONES.md), accessibility findings in [Accessibility Evidence](docs/ACCESSIBILITY_EVIDENCE.md), point-in-time-universe remediation in [Point-in-Time Universe Review History](docs/internal/POINT_IN_TIME_UNIVERSE_REVIEW_HISTORY.md), and persistent execution boundaries in the [Commercial Research Beta Continuation Contract](docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md). ## Current Truth -Use live, read-only commands instead of static counts: - - Master universe rows: use `make project-status` or `make status-check TOP_N=5`. - Active research rows: use `make project-status` or the dashboard Home page. - Lane readiness: use `make readiness-ops-center`. +- Historical proof versus current readiness: use `make proof-readiness-reconciliation TOP_N=20`. It is a current-snapshot audit; historical proof cannot promote current readiness. +- Stale-readiness impact: use `make readiness-preview TOP_N=20`. It computes future proposed states in memory, writes no files, and does not authorize staging or a readiness rebuild. - Source/provider state: use `make session-source-preflight` and `make provider-setup-checklist`. -- Package/share state: use `make pilot-readiness-check TOP_N=10` and `make public-check`. +- Package/share state: use `make pilot-readiness-check TOP_N=10`, `make public-check`, and `make browser-qa-evidence`. +- Commercial-beta state: use `make commercial-beta-check`, `make commercial-beta-performance-gate`, and `make commercial-beta-release-check`. These are local evidence only; they do not refresh data and do not prove market validation. +- Point-in-time universe software: only `make point-in-time-universe-status MANIFEST=` and `make point-in-time-universe-preview MANIFEST= TOP_N=20` are supported. Both are read-only. The product deliberately separates the tracked master universe, active universe, and analysis-ready subset. It must never imply that the whole tracked universe is analysis-ready. +The tracked June 7 readiness snapshot remains stale under this roadmap's declared-date policy. An excluded July 21 local generated working-data snapshot is not committed PR evidence. A read-only preview reported zero stable readiness changes, but that observation does not authorize staging or a readiness rebuild. Rerun the current commands instead of copying counts into documentation. + +`historical_supported_currently_blocked`, `explicit_ticker_change`, and `current_canonical_row_missing` are distinct reconciliation outcomes. Current saved readiness remains authoritative, and reconciliation does not establish the historical cause of a transition, restore canonical data, or report current readiness totals. + +The stale readiness continuation gate follows declared source dates, never file mtimes. `make readiness-preview TOP_N=20` does not make saved readiness current, does not refresh data, and is not current readiness counts or rebuild approval; provider ordering and ranking output remain planning context only until a separate intentional reviewed write runs `make readiness`. + +Primary research flow: **Research Desk -> Discover -> Company Workbench -> Monitor**. + Public visitor flow: **Home -> Stock Selector -> Single-Stock Report -> Data Health -> Proof History**. -## Completed Regression Gate +Operator source/proof work remains separate. Data Health and Proof History are Advanced Evidence in Personal Research mode. Empty ledgers stay empty; candidate context stays untrusted. -### P0: Profile Truth And Local Research Change Workflow +Data Health and Proof History stay inside Personal Research mode and retain `Return to Company Workbench`; the detour does not change readiness. The direct-open loading state preserves Selected ticker -> `Use now` -> `Still withheld` -> `Open Data Health`, a 44px primary action, at least 50px result rows, no horizontal overflow, no traceback, and the desktop four-column layout. Phone first-action density, Advanced data health cards, Auto-refresh status, and Session Source Preflight are workflow evidence only. This does not prove market validation, does not change readiness, source, research, or generated-artifact state, and does not prove hosted behavior, accessibility conformance, reviewer validation, demand, or product-market fit. -**Status:** implemented and locally verified on 2026-07-15. +Stage A-G labels are continuation maturity lanes only; they do not replace the numbered Stage 0-6 exit gates. Stage B — local field-proof audit and operator hardening is complete locally. A blocked priority does not become complete; classify it once and move to the next safe executable priority. -Every dashboard and status surface uses one selected-profile context for source date, readiness build time, snapshot identity, freshness, and matching coverage counts. Generated comparable snapshots support deterministic filing, readiness, price-history, fundamentals/share-count, and Nowcast-consensus change events. The derived review queue prioritizes unresolved research work, while append-only review outcomes remain separate from readiness mutation. +## Now: Commercial Research Beta Foundation -Use `make profile-context`, `make research-change-snapshot`, `make research-change-monitor`, and `make research-review-queue`. Generated snapshots and event previews stay unstaged. A missing baseline means no comparison is available; it never means no changes occurred. +**Current product stage:** local Commercial Research Beta release candidate and controlled portfolio/demo package, not a hosted or commercially launched product. -**Boundary:** local monitoring is read-only except for the explicit reviewed-resolution append. Hosted alerts, scheduled snapshot rotation, and notification delivery remain Later and require operating evidence. +**Positioning:** a maintained evidence-first research tool candidate, not a smaller Bloomberg, Koyfin, TIKR, Fiscal.ai, AlphaSense, Quartr, or QuantConnect. -### P0: Research Thesis And Evidence Journal +**What works locally** -**Status:** implemented and locally verified on 2026-07-15; retain as a research-process regression gate. +- Research Desk frames the saved cohort and change state; Discover selects a readiness-backed company; Company Workbench composes trends, valuation, forward scenarios, evidence, and authoring; Monitor reviews verified change and research discipline. +- Research Decision Lab supports read-only composition plus append-only thesis, counter-thesis, evidence, catalyst, invalidation, scenario-assumption, and outcome records through validate -> preview -> explicit confirm. +- SEC quarterly actuals preserve source lineage. EPS split basis remains unverified without explicit proof. Q4 actuals require an explicit SEC-filed Q4 table; Q4 is never derived. +- Historical Valuation Regime, Source Freshness Timeline - Implemented, Research Comparison View - Implemented, Peer Read-Through Map, Scenario Lab, Research Outcome Review, and Catalyst Evidence Timeline stay fail-closed when their ledgers or source inputs are empty. +- Calculation software supports price setup, drawdown, volatility, beta, Sharpe/Sortino, DCF/scenarios, valuation context, deterministic nowcast contracts, walk-forward review, and point-in-time-universe validation. +- Commercial source-rights, refresh-operation, provider-neutral authorization, workspace-isolation, audit-obligation, retention, monitoring, incident, and rollback contracts are locally testable. Local contracts do not prove hosted operation. +- Public/package/release gates and current-head automation verify code, wording, route rendering, hygiene, and research boundaries. Automated evidence is not independent human review. -The selected-profile Single-Stock Report now shows one compact, reviewer-authored thesis answer with supporting and conflicting evidence, catalysts, risks, invalidation conditions, confidence history, and review dates. `data/research_thesis_journal.csv` is append-only. Thesis revisions preserve prior entries through `supersedes_entry_id`; generated thesis text and Change Monitor tasks never write journal rows automatically. +**Current truthful limitations** -Use `make thesis-journal TICKER=` to read, `make thesis-journal-preview ...` to validate without writing, and `CONFIRM_REVIEWED=1 make thesis-journal-record ...` only after source review. Journal entries never mutate source rows, readiness, valuation, or Review Queue outcomes. +- Real semiconductor nowcast coverage remains `awaiting_point_in_time_consensus`. +- Numerical Beat/Miss probability remains `awaiting_calibration_evidence` and withheld until at least 100 valid leakage-safe out-of-sample events pass every predeclared gate. +- A saved artifact being current relative to local files does not render its latest market observation current. Current-market interpretation requires an independent observation-recency state. +- One permitted independently reviewed real point-in-time universe package, one permitted point-in-time consensus source, and one genuinely reviewed peer relationship are not on record. +- Independent beta sessions completed: zero. The local protocol is ready, but it is not user-validation or demand evidence. -**Boundary:** the journal documents a research process. Confidence is not investment conviction, expected return, position size, or a transaction instruction. +## Next: Ordered Maturity Work -### P0: Performance Release Candidate +Select the first priority with a safe executable task. If its next gate needs an unavailable source, account, environment, reviewer, or elapsed event history, classify it once under **Externally blocked** and continue to the next executable priority. Passing local tests never completes an external gate. -**Status:** passed locally on the fixed demo profile on 2026-07-14; retain as a release regression gate. +### Priority 1 — Legacy portfolio, ranking, and action-language quarantine -**Goal:** keep the guided public workflow fast enough that an external reviewer does not mistake loading for a broken page. +Priority 1 — completed locally. Legacy portfolio, ranking, position, cost-basis, picks, entry-zone, disposition, and transaction-like surfaces are Operator-only compatibility utilities labelled `Legacy research utility — not part of Personal Research Mode`. Public and Personal Research routes fail closed. Retained compatibility code cannot feed Company Workbench, Research Decision Lab, readiness, recommendations, sizing, or transaction behavior. -Use the tracked `data/demo/manifest.json` snapshot as the fixed performance dataset. Do not mix route measurements with broad data refreshes or generated local-profile churn. +### Priority 2 — Stage B field-proof audit and operator hardening: completed locally -1. Run `make public-performance-contract` to inspect the read-only route, viewport, snapshot, and threshold contract. -2. Run `make public-performance-gate` for real-browser cold and warm evidence at desktop and phone widths. -3. Measure the visible shell, first useful answer, and full settle separately; report repeated warm results as p90 rather than selecting the fastest run. -4. Treat Stock Selector, Single-Stock Report, and Data Health as critical routes. Keep Home and Proof History regression-protected. -5. Optimize saved summaries, deferred detail, pagination, and deterministic caching in small tested slices without weakening readiness or hiding blocked states. +Stage B — completed locally. This is prospective-only; legacy narrative proof is not upgraded, and an absent ledger is a valid empty state. `technical_write_eligible` and `commercial_evidence_eligible` remain independent. `make prospective-field-proof-audit` and preview expose append history, current/superseded state, blockers, per-row technical/commercial answers, and receipt revalidation without writes. Stage B is the second approved local priority after legacy surface quarantine. There is no readiness mapping; any activation requires a separate design. Activation remains non-active and separately designed. No sample field-proof rows are checked in. -**Exit gate:** loading feedback within 1 second, first useful answer within 3 seconds, warm full-settle p90 within 5 seconds, and cold full settle within 10 seconds on the defined local reference environment. +`make prospective-field-proof-status`, `make prospective-field-proof-preview INPUT= AS_OF=`, and `make prospective-field-proof-record INPUT= AS_OF= PREVIEW_RECEIPT= CONFIRM_REVIEWED=1` preserve `preview_receipt_persisted=false` and `receipt_revalidation_required=true`. The preview receipt binds ledger, input, cutoff, commercial mode, and source-rights registry. Audit and preview do not activate readiness; the audit does not update canonical data, does not update proof-readiness reconciliation, and does not activate Company Workbench. -**Stop rule:** a missing browser dependency is `environment_limited`, not a pass. Keep timing JSON and screenshots generated and unstaged unless one concise artifact is intentionally reviewed. +### Priority 3 — In-app research-record authoring: completed locally -## External Stages +Priority 3 — completed locally after direct desktop/phone runtime review and the required automated acceptance matrix. Thesis, evidence, catalyst, and outcome records are all available in the collapsed Company Workbench composer. A valid record requires an exact preview and explicit confirmation before save. Drafts are untrusted and preview receipts are session-only. Production tests never append repository ledgers; persistence tests use temporary ledgers. automated generation cannot become reviewer-authored evidence. A saved record cannot change readiness, forecasts, probabilities, recommendations, or any other ledger. -### P1: Controlled Hosted Preview Verification +Hardening commit `07758114c` closes the confirmation race: all three append engines share one resolved-ledger cooperative lock, receipts bind resolved ledger identity, every new preview resets confirmation, and uncertain post-append teardown requires one-shot read-side reload before success. -**Goal:** turn the deterministic `demo` profile into a verified, controlled hosted demo without exposing local refresh data or credentials. +Final integrity commit `e3a090dba` ensures confirmation appends only the receipt-matched recomputed record and enforces one readable active thesis lineage: revisions must supersede the exact active entry and preserve its thesis ID. The Company Workbench locks and explains that relationship, with temporary-ledger create -> revise -> reload coverage. -Repository-side preparation is complete. The remaining deployment work requires an external host/account and a verified public URL. +Confirmation-integrity commit `5a6c55921` binds every displayed preview field, preview time, and destination label to the exact receipt. If an append raises after it may have written, confirmation returns one-shot `save_pending_reload` with the exact record ID unless the locked ledger is provably unchanged; it never invites a blind duplicate retry. -1. Choose a Streamlit-compatible host and deploy `main` with `dashboard.py` as the entrypoint. -2. Set `STOCK_RESEARCH_DATA_PROFILE=demo` in the host environment. -3. Keep provider keys, account IDs, tokens, and broker/session files out of the repo and public app. -4. Verify the five-page workflow on the hosted URL at desktop and mobile widths. -5. Set `HOSTED_DEMO_URL` locally only after the URL opens successfully, then rerun the public gates before changing GitHub or LinkedIn copy. +Priority 4's local validator is frozen; its permitted real-data exit gate remains externally incomplete. Priority 6's provider-neutral authorization contract is complete locally; hosted implementation remains environment-dependent. Priority 7 accessibility remediation is the next safe executable local lane. -**Dependencies:** the local performance release gate, an external hosting account, a public or access-controlled preview URL, and a human browser review of the deployed route. +### Priority 4 — Point-in-time benchmark and universe foundation -**Stop rule:** keep GitHub as the public link until the hosted route is verified. Call the route private only when access control is actually enforced. Screenshots remain product evidence only, never data-freshness proof. +The approved design is `docs/superpowers/specs/2026-07-23-point-in-time-universe-foundation-design.md`. It remains isolated from the current ticker-centric universe. -### P1: Controlled Pilot Review +Implemented locally: read-only immutable-package status/preview with ten independent states: manifest, technical, temporal, identity, membership, corporate action, delisting, source rights, reproduction, and leakage. Synthetic fixtures remain test-only and local software evidence cannot complete Priority 4. -**Goal:** validate whether an external reviewer can understand the product in under three minutes. +Priority 4 remains open until one bounded permitted real dataset is independently reviewed, reproduces the expected membership count and digest, and passes rights, identity, corporate-action, delisting, survivorship, cutoff, partition, reproduction, and leakage gates. The controls for corporate actions, delistings, survivorship, and leakage remain explicit and independent. -1. Share the GitHub demo package with 5-10 reviewers. -2. Ask reviewers to follow the public visitor flow without operator instructions. -3. Record only concrete issues: where they started, what they thought was usable, what looked blocked, and what they expected to do next. -4. Prioritize reproducible first-viewport, wording, routing, or accessibility defects. Do not use pilot feedback to weaken readiness gates. +This local evidence does not change independent readiness for actuals, consensus, Revenue, EPS, valuation, catalysts, outcomes, backtesting, or calibration. -Use [Controlled Pilot Review Feedback](docs/PILOT_REVIEW_FEEDBACK_TEMPLATE.md) to capture anonymous, reproducible workflow observations without collecting personal, portfolio, or investment-opinion data. +It does not provide investment advice; numerical probability remains unavailable without calibration; Q4 evidence and EPS split-basis compatibility remain explicit; synthetic evidence stays test-only; candidate peer evidence remains candidate-context-only. -**Dependencies:** a locally passing performance release gate, a verified delivery path, external reviewers, and controlled feedback collection. +Digest method: `membership_count_and_sha256_at_cutoff_v1`. Supported commands are exactly `make point-in-time-universe-status MANIFEST=` and `make point-in-time-universe-preview MANIFEST= TOP_N=20`. -**Stop rule:** do not call pilot feedback data proof; it only validates product clarity and workflow reliability. +Resource limits and detailed remediation evidence live in `docs/internal/POINT_IN_TIME_UNIVERSE_REVIEW_HISTORY.md`. Priority 4 remains externally incomplete. -## Now +Local resource budgets for one supplied package: preview sample 100 rows; manifest 1 MiB; each contract CSV 32 MiB; four contract snapshots combined 64 MiB; source-rights registry 4 MiB; declared rows 250,000 per contract; package traversal 32 entries. These local bounds do not prove scale, hosted reliability, or market readiness. No permitted independently reviewed real dataset, accepted expected count/digest, or source-rights proof is on record. -### P2: Scenario Lab - Implemented +### Priority 5 — One permitted consensus source and one reviewed peer relationship -**Goal:** let a reviewer vary source-backed DCF assumptions and understand valuation sensitivity without changing canonical data or producing a recommendation. +Review one exact-source, permitted, point-in-time consensus snapshot for one ticker/fiscal period and one independently sourced peer relationship. Technical validity, rights, Revenue, EPS, comparability, reviewer decision, append history, and valuation-anchor eligibility remain independent. -1. Start only from a company whose selected profile is DCF-ready. -2. Load the saved source-backed revenue, FCF or margin, shares, cash, debt, and price context as immutable baseline evidence. -3. Allow bounded changes to revenue growth, operating or FCF margin, discount rate, terminal growth, and forecast horizon. -4. Show baseline and scenario ranges, directional sensitivity, terminal-value contribution, and every changed assumption. -5. Keep scenarios session-local or explicitly exported as generated research artifacts; never apply them to canonical fundamentals or readiness. +Neither item unlocks probability, broad coverage, readiness in another lane, or a company conclusion. -**Stop rule:** blocked or excluded DCF inputs produce no valuation output. Scenario results are assumption tests, never fair-value claims, rankings, or direct actions. +Run `make earnings-consensus-source-review SOURCE_INPUT= AS_OF=` before collection preview. Only after exact review may `CONFIRM_REVIEWED=1 make earnings-consensus-collection-record COLLECTION_INPUT= AS_OF= PREVIEW_RECEIPT=` create an append-only evidence record. It does not activate readiness or numerical probability. Never infer an explicit provider. -**Implemented proof:** the detailed Valuation tab now loads source-backed defaults, enforces bounded controls, reports changed assumptions and sensitivity, and keeps provenance and scenario identity under Advanced. It is session-local and does not change canonical inputs or readiness. +### Priority 6 — Controlled hosted operating boundary -### P2: Source Freshness Timeline - Implemented +Provider-neutral, deny-by-default workspace authorization is complete locally. `src.hosted_access_control.evaluate_workspace_access` creates an append-only, privacy-safe audit obligation. The module has no dashboard, ledger, readiness, provider, persistence, environment, network, or generated-artifact integration. It does not prove hosted authentication, deployed isolation, audit storage, retention, monitoring, rollback, incident response, operated capacity, or market validation. -**Goal:** show a selected ticker's source chronology without confusing report time, retrieval time, market time, or financial effective date. +The actual environment must prove authentication, cross-user/cross-workspace denial, least privilege, durable persistence, append-only audit, retention/deletion, health monitoring, recovery, rollback, and named operating ownership. A local contract or URL alone is insufficient. -The Sources & Gaps tab now derives a deterministic same-profile timeline from the report payload, shows unknown timestamps explicitly, deduplicates exact source records, and keeps raw provenance under Advanced. It never refreshes data, changes readiness, or infers publication, cutoff, or revision dates that are not present. +**Exit gate:** the actual hosted environment directly proves every claimed control, including an observed rollback rehearsal and named owner. -**Stop rule:** a recent retrieval or report timestamp does not render an older source period current or unlock a blocked lane. +### Priority 7 — Accessibility evidence beyond screenshots -### P2: Research Comparison View - Implemented +Implement and retest the approved narrow remediation for first-focus skip placement, visible labelled mobile navigation, ticker-specific action names, disclosure-summary focus visibility, and field-level error association. A stable semantic `main` needs a separately reviewed framework-safe design. -**Goal:** compare selected companies across usable evidence, blockers, proof freshness, and reviewed catalysts or risks without ranking them. +Complete keyboard order, 200%/400% zoom and reflow, forced colors, reduced motion, screen-reader tasks, loading/empty/withheld/stale/failure states, and material-defect retests. Screenshots and automated scans are supporting evidence only. -The existing operator-only selected review tray now accepts two or three tickers, preserves user order, and displays an evidence matrix for price, fundamentals, DCF, trusted peers, supported analysis, missing inputs, next proof, freshness, and profile-scoped journal context. It adds no route and writes no data. +The old Streamlit normalization to `target="_blank"` was corrected with a fragment destination and `target="_self"`, but K01/K03/K04/K05/K06/K09 and mobile-navigation findings remain bounded in `docs/ACCESSIBILITY_EVIDENCE.md`. Priority 7 remains incomplete. -**Stop rule:** the comparison produces no score, winner, expected return, recommendation, or action; candidate peer context remains separate from trusted-peer readiness. +Use `docs/ACCESSIBILITY_TASK_PROTOCOL.md`; the protocol is not completion evidence. The corrected same-page target is `#public-page-answer`; incomplete direct tasks remain `blocked_environment`. No WCAG conformance claim is made. -### P2: Peer Read-Through Map - Implemented +### Priority 8 — Independent workflow validation -**Goal:** show which peer results can be reviewed as directional business context without treating sector similarity or candidate peers as trusted evidence. +Run 10-20 independent workflow sessions with the target researcher persona through Research Desk -> Discover -> Company Workbench -> Monitor. Owner-led, automated, fixture, and screenshot sessions do not count. -The existing detailed Valuation tab now separates trusted relationships, candidate-only relationships, explicit business-overlap evidence, target/peer fiscal periods, source-backed Revenue/EPS actuals, and the remaining proof needed for contextual read-through. A result becomes `reviewable_context` only when relationship provenance, business overlap, actual result evidence, and both fiscal periods are explicit. +Measure task completion, time to first useful answer, readiness comprehension, evidence tracing, authoring friction, trust, misuse risk, perceived performance, repeat-use intent, and the most important missing workflow. Use voluntary minimal-data capture, withdrawal handling, a deletion date, anonymized evidence, reproducible findings, severity decisions, and material-defect retests. -**Stop rule:** candidate peers never become trusted automatically. Missing relationship source, actual result, or fiscal timing withholds read-through; even reviewable context cannot alter Earnings Nowcast numbers, DCF, readiness, rankings, or actions. +The local invitation, scorecard, log schema, runbook, and closeout checklist are ready. This does not prove demand, retention, product-market fit, or financial validity. -### P2: Decision-Process Scorecard - Implemented +### Priority 9 — Out-of-sample calibration cohort -**Goal:** render research discipline reviewable without grading a company or measuring investment performance. +Accumulate at least 100 valid leakage-safe out-of-sample events from permitted point-in-time inputs with immutable cutoffs, comparable actuals, revision lineage, exclusions, missingness, and reproducible cohort membership. -The Single-Stock Report now derives profile-scoped checks for readiness review, thesis documentation, recorded evidence, later review of conflicting evidence, invalidation conditions, confidence history, review-date currency, unresolved Change Monitor tasks, and visible DCF assumptions. Details stay collapsed below the Thesis Journal. +Evaluate predeclared Brier score, calibration bins, constant-benchmark improvement, sample sufficiency, missingness, stability, and temporal/cohort leakage. Actuals, consensus, Revenue, EPS, valuation, catalysts, outcomes, backtesting, and calibration remain independent. Probability stays withheld unless every applicable gate passes. -**Stop rule:** the scorecard reports process states and next review steps only. It produces no numeric company score, expected return, performance claim, ranking, recommendation, or action; blocked and excluded analysis remain distinct from incomplete documentation. +### Priority 10 — Separately approved hypothetical paper-position laboratory -### P2: Earnings Nowcast Pilot Evidence +Implementation is not authorized. A separate approved design must cover research-only intent, private-data policy, language, misuse analysis, and acceptance. It must prohibit recommendations, model-generated sizing, allocation, live holdings, account imports, brokers, order routing, auto-trading, stop/profit instructions, and performance claims. Live brokerage remains out of scope permanently. -**Goal:** move the implemented Earnings Nowcast pilot from synthetic infrastructure proof to a leakage-safe, source-backed semiconductor cohort. +## Externally blocked -Earnings Nowcast real-data safety infrastructure is implemented for deterministic Revenue/EPS ranges, consensus-relative classification, metric-specific canonical quarterly evidence, comparability checks, evidence-only directional signals, chronological walk-forward backtesting, explicit sample-sufficiency/calibration diagnostics, and a separate probability calibration gate. Versioned read-only append-only onboarding templates, validation, preview, readiness, and prospective collection planning are implemented. The committed fixture cohort is synthetic test evidence only. +| Stage | Classification | Evidence checked | Exact unblock condition | +| --- | --- | --- | --- | +| Priority 4 | `point_in_time_benchmark_universe_and_rights_required` | No permitted independently reviewed real package is on record. | Supply one bounded permitted package and independent expected-count/digest review; pass rights, identity, corporate-action, delisting, survivorship, cutoff, partition, reproduction, and leakage gates. | +| Priority 5 consensus | `permitted_point_in_time_consensus_and_rights_required` | No permitted exact-period point-in-time input or approved exact-source field rights are on record. | Configure one permitted source or reviewed CSV, approve exact-source rights/field scope, then validate and preview one ticker/period without inferring a provider. | +| Priority 5 peer | `trustworthy_peer_source_and_review_required` | No genuinely reviewed relationship is on record. | Provide source/as-of evidence and reviewer capacity; record role, rationale, comparability, and an explicit valuation-anchor decision. | +| Priority 6 | `hosted_account_and_controls_required` | No verified hosted identity/persistence environment or URL is on record. | Approve the exact environment and directly verify authentication, isolation, audit, retention, monitoring, backup, rollback, incident response, and named owner capacity. | +| Priority 7 | `accessibility_manual_review_environment_required` | Partial desktop/phone evidence exists; true zoom, forced colors, reduced motion, and screen-reader coverage are incomplete. | Provide a suitable review environment and complete the task protocol plus material-defect retests. | +| Priority 8 | `independent_reviewers_required` | The protocol is locally ready; zero independent sessions are on record. | Complete 10-20 independent target-persona sessions with anonymized evidence and retest material defects. | +| Priority 9 | `calibration_cohort_required` | Valid real leakage-safe calibration events: zero. | Accumulate at least 100 permitted events and pass the predeclared calibration/benchmark gates. | -1. Acquire permitted append-only historical quarterly actuals and point-in-time consensus snapshots with source references, publication/retrieval timestamps, and explicit Revenue/EPS comparability definitions for a narrow semiconductor cohort. -2. Use `make earnings-nowcast-prospective-plan` for future snapshot collection, then run the implemented onboarding validate/preview/readiness gates before any real-company packet; no automatic apply path exists. -3. Keep candidate peer/news signals separate from reviewed trusted evidence; signals explain context and never mutate forecast numbers. -4. Run chronological out-of-sample evaluation against latest-consensus and prior-year benchmarks. -5. Withhold numerical Beat/Miss probability until at least 100 valid events pass Brier-score, calibration-bin, and benchmark-improvement gates. +Unavailable dependencies are recorded once and not retried until relevant external state changes. This is the Approved Next-Stage Maturity Program; blocked work must move to the next safe executable priority. -Real semiconductor nowcast coverage remains `awaiting_point_in_time_consensus`; numerical probability remains `awaiting_calibration_evidence`. +## Later -**Stop rule:** do not substitute current analyst estimates for historical point-in-time snapshots, use post-cutoff evidence, infer numeric adjustments from text, claim predictive accuracy from fixtures, or predict post-earnings price movement. +### Focused price-history maintenance -## Next +`PROVIDER=auto` remains **Stooq, Yahoo**, optional IBKR read-only, then keyed FMP, Alpha Vantage, and Finnhub fallbacks. An explicit provider identity and commercial rights/`prices` scope are required before commercial fetch or mutation. -### P2: FMP One-Ticker Source Smoke +- `momentum-not-ready` is a readiness state, not a refresh instruction. +- Only `unreviewed preferred-history candidates` are default investigation candidates. +- Inspect `reviewed source-limited items` only with `INCLUDE_REVIEWED=1`. +- Use `make price-history-batch-closeout TOP_N=25` only when compatible reviewed evidence exists. +- stop on no readiness movement in reviewed scope; no identical source-limit retry unless source behavior or verified OHLCV changes; batch compatible proof evidence intentionally; never commit or push one proof row per ticker by default; pivot to the next roadmap item when no executable candidates. -**Goal:** add one controlled keyed free-tier fallback after the public pilot foundation is stable. +Provider setup/source-boundary review comes before candidate loops. The current source-proof queues have no unreviewed executable company candidates. Run `make provider-setup-checklist`; run `make trusted-data-pilot-candidates TOP_N=10` only after relevant source state changes. -1. Configure `FMP_API_KEY` outside Git in the ignored local key file or host secrets. -2. Run `make project-status-check`; only continue if it identifies a reviewed candidate scope. -3. Run `make fmp-smoke TICKER=` for one ticker. -4. Run `make imports-validate IMPORT_TICKERS=` and `make imports-preview IMPORT_TICKERS=`. -5. Apply only if validation passes, preview scope is intended, rejected rows are zero, and source provenance is present. -6. Record a supported, candidate-context-only, still-blocked, skipped, or excluded outcome before any larger batch. +### Focused peer expansion -**Dependencies:** an FMP key and an executable reviewed candidate. The current source-proof queues have no unreviewed executable company candidates, so provider reachability alone does not unlock coverage. +A 25-50 company trusted-peer pilot is governed by the Approved Next-Stage Maturity Program and begins only after the single reviewed relationship in Priority 5 proves trustworthy sourcing and repeatable review capacity. Candidate sector similarity is not peer proof. -**Stop rule:** no broad batch from setup alone. Provider setup/source-boundary review must happen before `make trusted-data-pilot-candidates TOP_N=10` only after source state changes. +### Research-oriented monitoring -### P2: Price History Maintenance +After source and workflow evidence support it, add source-linked filing/catalyst changes, thesis reminders, watchlists, and daily/weekly “what changed” recaps. Any summary must be cited to approved evidence and unable to change readiness, forecasts, or reviewed records automatically. -Price coverage uses `PROVIDER=auto` in this fixed order: **Stooq, Yahoo**, optional IBKR read-only when explicitly configured, then keyed FMP, Alpha Vantage, and Finnhub fallbacks. This maintenance lane is finite and read-only until a separately reviewed source-backed change is eligible for the import gate. +### Product-direction decision -1. Run the default executable queue: `make price-history-proof-queue TOP_N=25`. - - `momentum-not-ready` rows describe a readiness state, not a refresh instruction. - - `unreviewed preferred-history candidates` are the only default queue rows eligible for a narrow reviewed investigation. - - `reviewed source-limited items` are excluded from the default queue because they remain wait-only. -2. Use audit mode only to inspect reviewed source-limited items: `INCLUDE_REVIEWED=1 make price-history-proof-queue TOP_N=25`. -3. When compatible reviewed evidence exists, use `make price-history-batch-closeout TOP_N=25` to produce the read-only grouped closeout scaffold. It does not record proof rows, stage files, commit, or push. +Use `docs/PRODUCT_DIRECTION_DECISION.md` after hosted, independent-review, trusted-peer, data-economics, operating-capacity, and repeat-use evidence exists. Default direction remains a maintained personal/small-team research tool; an operated platform is not authorized by feature volume or test counts. -**Stop rules:** stop on no readiness movement in reviewed scope; no identical source-limit retry unless source behavior or verified OHLCV changes; batch compatible proof evidence intentionally; never commit or push one proof row per ticker by default; pivot to the next roadmap item when no executable candidates. +## Completed with evidence -### P3: 25-50 Company Trusted-Peer Pilot +Detailed evidence is in `docs/COMPLETED_MILESTONES.md`; this section is an index, not duplicated chronology. -**Goal:** address the largest analytical-depth gap without inferring trusted peers across the full universe. +### P0: Profile Truth And Local Research Change Workflow -1. Select 25-50 operating companies from a few clearly comparable industries. -2. Generate candidate peer context from SIC, industry, and product context; label it `candidate_context_only`. -3. Promote a relationship only after source-backed review captures peer source, review date, rationale, and as-of context. -4. Keep peer trend readiness separate from peer valuation readiness. -5. Require trusted peer price, fundamentals, and valuation inputs before relative valuation appears. +Implemented and regression-gated. Snapshot-only and source-backed changes stay distinct; missing change evidence is not a negative company signal. -**Dependencies:** a licensed or otherwise trustworthy peer relationship source and reviewed mappings. +### P0: Research Thesis And Evidence Journal -**Stop rule:** sector similarity is not trusted-peer proof. Do not target broad-universe peer readiness before the pilot has repeatable evidence. +Implemented with append-only provenance, invalidation, conflicting evidence, outcome review, and no automatic readiness or forecast promotion. -The implemented Peer Read-Through Map is the review surface for this future cohort. It does not satisfy the external trusted-relationship dependency or create broad peer coverage by itself. +### P0: Performance Release Candidate -## Later +Passed on the fixed `data/demo/manifest.json` profile with shell, first useful answer, warm/cold settle, and p90 contracts. `make commercial-beta-performance-gate` and `make commercial-beta-release-check` are the current rerun paths. -### P4: Optional Earnings And Analyst Estimates +### P1: Controlled Hosted Preview Verification + +Local deployment and operating contracts are complete; actual hosted verification remains externally blocked under Priority 6. + +### P1: Controlled Pilot Review + +The independent-session protocol is locally complete; actual sessions remain externally blocked under Priority 8. + +### P2: Scenario Lab - Implemented + +Scenario assumptions are explicit research context and cannot become recommendations, rankings, or deterministic forecast inputs. + +### Earnings Nowcast real-data safety infrastructure -Proceed only when a trusted provider supplies supported earnings actual/estimate fields, estimate period, source, and retrieval/as-of date. Date-only and target-price-only data remain `candidate_context_only`; optional context never unlocks DCF readiness or becomes a recommendation. +Deterministic synthetic-fixture software, point-in-time consensus contracts, actuals lineage, backtest, and calibration gates are implemented. Real-company coverage and predictive accuracy are not established. -### P4: Scheduler Maturity +### Bounded SEC cash-generation evidence -Add scheduled snapshot rotation, alerts, and source monitoring only after at least one provider pilot proves deterministic batch limits, provenance, rejection handling, and proof-ledger recording. Daily price and filing checks may be read-only; imports still require validation, preview, and source gates. The local Change Monitor is not itself a hosted alerting service. +The one-company adapter acceptance harness and bounded exact-source review started with NVIDIA Q1 FY2027; the later AMD Q1 FY2026 review established bounded two-company portability. They do **not** prove production activation, broad history, Q4 portability, or market validation. The preview does not activate Company Workbench or readiness in ordinary routes and does not prove broad company coverage. -### Later: Broader Peer Expansion +AMD Q1 FY2026 uses accession `0000002488-26-000076`. The one explicit user-flow composition can expose accepted preview evidence only through an opt-in route; it does not prove a second company at the historical NVIDIA-only milestone and never promotes production state. -Expand beyond the peer pilot only after trusted relationship sourcing, review capacity, and lane-level quality checks are repeatable. +Valuation and backtest safeguards reject non-finite valuation inputs; require a canonical real `YYYY-MM-DD` denominator period end; reject blank, malformed, and non-calendar denominator period ends; reject post-cutoff retrieval evidence; canonicalize Revenue/EPS independently through explicit `supersedes_source_ref` lineage; retain one event per ticker/period; withhold ambiguous leaves per metric so one metric does not suppress the other; and use cutoff-bounded prior-year benchmarks so post-cutoff revisions cannot leak. -### Later: Product Direction Decision +### Research Decision Lab -Use `docs/PRODUCT_DIRECTION_DECISION.md` after hosted-preview, controlled-pilot, and trusted-peer evidence exist. Choose explicitly among a portfolio-quality research prototype, maintained research tool, or operated research platform; keep the decision provisional while those dependencies remain external. +Implemented locally — Research Decision Lab. Stage 4 — Documentation and release evidence: completed locally. Read-only composition, Workbench integration, Monitor discipline review, and release evidence passed without trading or recommendation behavior. + +This does not prove source coverage, predictive accuracy, investment performance, independent adoption, hosted reliability, commercial demand, competitive superiority, or product-market fit. + +### Methodology and packaging maturity + +Current methodology maturity supports a transparent local research prototype and controlled beta candidate. It does not prove broad real-company coverage or market validation. Phone first-action density, answer-first layout, Advanced data health cards, auto-refresh status, Session Source Preflight, commercial-beta release evidence, and source-rights boundaries are locally verified product/package evidence only. ## Dependencies And Manual Gates -| Item | State | What the repo can do | What remains external | -| --- | --- | --- | --- | -| Hosted demo | repo-ready | deterministic demo profile, deployment guide, and local public checks | hosting account, verified public URL, browser review | -| FMP fallback | optional key missing | one-ticker smoke, validation, preview, provenance gate | `FMP_API_KEY` outside Git | -| Alpha Vantage / Finnhub | optional keys missing | capped fallback interfaces and source-state checks | provider keys and a reviewed use case | -| Trusted peers | source-gated | candidate/trusted state separation and proof workflow | reviewed source relationships and rationale | -| Earnings / estimates | intentionally locked | optional-context states and import gates | trusted provider/manual rows with supported fields | +| Item | Local state | Manual/external gate | +| --- | --- | --- | +| Readiness/data | fail-closed software and reviewed local artifacts | source rights, exact-source rows, validate/preview/apply, rebuild, and proof | +| Providers | provider-neutral contracts and capped adapters | explicit provider key/rights/scope and reviewed use case | +| Hosting | architecture and authorization contracts | approved account/environment plus direct operating evidence | +| Accessibility | partial direct desktop/phone evidence | remaining environment/tasks and material-defect retests | +| Reviewer validation | complete privacy-safe protocol | 10-20 independent sessions | +| Calibration | predeclared methodology | at least 100 valid permitted events | ## Success Gates -### Public Demo Gate +### Independent engineering gate -- `make demo-data-check` -- `make demo-dashboard-smoke` -- `make demo-dashboard-render-smoke` -- `make public-check` -- `make browser-qa-evidence` +- Current-head GitHub Actions result, not a previous revision. +- Full tests, dashboard startup, Personal Research route rendering, public wording, generated-artifact hygiene, and whitespace. +- Independent human review remains separate from automation. + +### Public/demo gate + +- `make dashboard-smoke` +- `make research-dashboard-render-smoke` - `make public-wording-check` +- `make public-check` +- `make commercial-beta-release-check` - `make pilot-readiness-check TOP_N=10` - `make diff-hygiene-summary` - `git diff --check` -### Source-Backed Apply Gate +### Source-backed apply gate -- A narrow, intended ticker scope. -- Source provenance and relevant as-of context. -- `make imports-validate IMPORT_TICKERS=` passes. -- `make imports-preview IMPORT_TICKERS=` is narrow and rejected rows are zero. -- Readiness and proof evidence are rebuilt after an approved apply. +A narrow intended scope, exact-source rights/provenance, validation, preview, rejected-row review, explicit apply/skip decision, rebuilt readiness, and proof-ledger evidence are all required. Candidate context, setup, screenshots, local contracts, or historical proof cannot substitute. ## Permanently Out Of Scope -- Broker execution, account actions, order routing, or auto-trading. -- Direct buy/sell instructions or investment recommendations. -- Fabricated prices, fundamentals, shares, peers, earnings, estimates, valuation inputs, or metrics. -- Promoting candidate peers, stale rows, screenshots, or provider setup into trusted readiness proof. +- Investment advice, direct buy/sell instructions, company rankings, expected-return scores, or automated stock picking. +- Broker execution, account imports, live holdings, order routing, or auto-trading. +- Model-generated sizing, allocation, stop-loss, take-profit, or post-earnings price prediction. +- Fabricated data, forecasts, probabilities, peers, events, sources, timestamps, rights, reviewers, demand, or recommendations. +- Promoting candidate context, stale rows, synthetic fixtures, screenshots, provider setup, or empty ledgers into trusted evidence. diff --git a/config/source_rights.yml b/config/source_rights.yml new file mode 100644 index 00000000..f65cdbfc --- /dev/null +++ b/config/source_rights.yml @@ -0,0 +1,33 @@ +sources: + - source_id: sec_companyfacts + display_name: SEC Companyfacts + permitted_use: source_backed_company_facts + commercial_use: approved + redistribution: derived_data_only + storage_limits: retain normalized filing facts and source references under the applicable records policy + attribution: SEC EDGAR with filing accession or source reference + rate_limits: fair-access only; identify requests with SEC_USER_AGENT and do not automate abusive retrieval + authentication: SEC_USER_AGENT + expected_freshness: filing-driven; review after a new filing becomes available + supported_fields: + - revenue + - shares_outstanding + - filing_dates + - operating_income + - cash_from_operations + - capital_expenditures + fallback_priority: 1 + - source_id: yfinance + display_name: yfinance / Yahoo Finance + permitted_use: research_only + commercial_use: unverified + redistribution: not_permitted_without_separate_rights_review + storage_limits: local research cache only; do not retain or redistribute in commercial mode + attribution: yfinance / Yahoo Finance when used in research mode + rate_limits: follow the upstream provider terms and avoid automated bulk retrieval + authentication: none + expected_freshness: market-data dependent; not a commercial freshness guarantee + supported_fields: + - prices + - company_context + fallback_priority: 90 diff --git a/docs/ACCESSIBILITY_EVIDENCE.md b/docs/ACCESSIBILITY_EVIDENCE.md new file mode 100644 index 00000000..56cc23c9 --- /dev/null +++ b/docs/ACCESSIBILITY_EVIDENCE.md @@ -0,0 +1,489 @@ +# Accessibility Evidence + +This document records direct local accessibility evidence for the supported +Personal Research workflow. It is an evidence log, not a WCAG conformance +claim. + +## 2026-07-23 partial workflow audit + +Scope: + +- Local Streamlit dashboard in Personal Research mode. +- Research Desk -> Discover -> Company Workbench -> Monitor. +- Desktop viewport: `1280x720`. +- Phone viewport: `390x844`. +- Browser: local Chromium-based browser. +- Company Workbench review ticker: `AVGO`. +- No readiness rebuild, data refresh, evidence recording, or repository + screenshot generation was performed. + +Directly observed: + +- All four routes rendered at desktop and phone widths without document-level + horizontal overflow. +- The document language was `en`. +- Visible form controls exposed labels or accessible names in the inspected + DOM. +- No duplicate element IDs were found in the inspected routes. +- A focused Data Health link showed a visible three-pixel teal outline. +- Research-only and no-account-action boundaries remained visible. +- The Monitor `Open Discover` action initially rendered white normal-size text + on Streamlit red, measured at approximately `3.3:1`. +- The local theme now gives primary link buttons a `#0b3b36` background and + border with white text. Runtime retest measured the Monitor action at + `12.4:1`. +- A route-preserving `Skip to page answer` link is now present in the + accessibility tree on all four Personal Research routes. Direct desktop and + phone retest verified the correct route, ticker, and open-state parameters + for the Company Workbench link and a focusable `tabindex="-1"` answer target. +- Primary route sections now follow each route `h1` with `h2` headings. + Direct desktop and phone DOM retest verified the complete Workbench sequence + from `What Changed` through `Advanced Evidence`; Monitor retains the nested, + non-empty `h3` for `Earnings evidence readiness`. +- The semantic correction was retested at `1280x720` and `390x844` with no + document-level horizontal overflow. New screenshots remain ephemeral under + `/tmp/stock-research-accessibility-audit-2026-07-23/` and are not repository + or release artifacts. + +Open findings: + +- The inspected routes still expose no `main` landmark. +- Current local Streamlit `1.59.2` containers expose layout and key parameters + but no semantic role or element parameter. A client-side workaround would + require unsafe JavaScript tied to framework-owned DOM selectors. That + approach is not accepted as a stable landmark implementation without a + separately reviewed design and direct runtime evidence. +- The skip link is structurally present and uses the existing visible-on-focus + CSS contract, but a complete keyboard traversal has not yet directly proved + first-focus placement, focus transfer, or return-path behavior. +- Framework help controls measured approximately `16x16` CSS pixels and + dataframe toolbar controls approximately `22x22`. Hidden native radio inputs + also measured small, but their visible labels are larger and require separate + hit-target review rather than treating the input box alone as the target. +- The inspected routes exposed no explicit live region. Dynamic update and + validation announcements still require task-level assistive-technology + testing. +- Browser automation did not prove the complete keyboard traversal order. +- Direct screen-reader tasks, 200% and 400% zoom, forced-colors behavior, + reduced-motion environment verification, and error-association tasks remain + untested. + +Classification: + +- `partial_local_accessibility_evidence` +- The contrast, route heading hierarchy, and route-preserving skip-link defects + above are fixed and directly retested within the stated scope. +- Priority 7 remains incomplete. Screenshots, DOM inspection, and one focused + contrast/semantic retest do not prove keyboard accessibility, + assisted-technology support, or WCAG conformance. + +Next safe local step: + +1. Execute `docs/ACCESSIBILITY_TASK_PROTOCOL.md` in a suitable review + environment and record direct keyboard traversal, focus, disclosure, + search, navigation, validation, and error-recovery evidence. +2. Complete zoom/reflow, forced-colors, and screen-reader tasks from the same + protocol. +3. Revisit the `main` landmark only through a stable Streamlit capability or a + separately reviewed design; do not inject an unverified DOM-mutation patch. + +The protocol is not completion evidence and no task result is inferred merely +because the protocol now exists. + +## 2026-07-26 same-page skip-link routing audit + +Scope: + +- Local Streamlit `1.59.2` dashboard in Personal Research mode. +- macOS `26.5.1` at a `1280x720` CSS viewport. +- Research Desk and ticker-bound Company Workbench route for `AVGO`. +- No readiness rebuild, source refresh, research-record save, screenshot + capture, or generated-artifact write. + +Directly reproduced: + +- Although the helper emitted no target attribute, the live Streamlit DOM + normalized `Skip to page answer` to `target="_blank"`. +- Activating that live link did not move focus to the answer target or retain + the current tab's route context. +- Existing tests asserted the constructed route URL and target element but did + not cover the live same-tab contract. + +Remediation and direct retest: + +- The skip link now uses the fragment-only destination + `#public-page-answer` plus explicit `target="_self"`. A fragment-only + destination retains every current mode, route, ticker, and disclosure query + parameter without triggering a Streamlit rerun that resets focus. +- The helper contract is covered for Public, Personal Research, and Operator + routes. +- Direct Company Workbench retest retained + `?mode=research&page=company-workbench&ticker=AVGO&open=1`, appended only + `#public-page-answer`, kept the same browser tab, scrolled the answer target + to the top of the viewport, and left `document.activeElement` on the + focusable `public-page-answer` target. + +Evidence boundary: + +- This closes the reproduced new-tab/rerun defect and proves the live + same-document target behavior in the stated environment. +- The available browser-control environment could not activate ordinary links + through its synthetic Enter input, including the unrelated `Open Discover` + control. K01 and K02 therefore remain `blocked_environment`, not + `passed_direct`; no complete keyboard-only traversal is claimed. +- Priority 7 remains incomplete. Direct keyboard, zoom, forced-colors, + reduced-motion, target-size, screen-reader, and stable-main-landmark evidence + remains required under `docs/ACCESSIBILITY_TASK_PROTOCOL.md`. + +## 2026-07-27 framework-control target-size audit + +Scope: + +- The same local Streamlit, macOS, Personal Research, and no-write boundary as + the preceding audit. +- Ticker-bound Company Workbench for `AVGO`. +- Desktop `1280x720` and phone `390x844` CSS viewports. +- The Workbench quarterly-trend disclosure was opened only to expose its + existing dataframe toolbars; no data or research state was changed. + +Directly reproduced: + +- The visible framework help target measured `16x16` CSS pixels. +- Visible dataframe `Show/hide columns`, `Download as CSV`, `Search`, and + `Fullscreen` targets measured `22x22` CSS pixels. + +Remediation and direct retest: + +- The first style correction enlarged the tooltip wrapper, not its nested + button. An expanded P02 audit caught the actual help button still at + `16x24`; this intermediate result is not completion evidence. +- The corrected shared style now reserves at least `24x24` CSS pixels for the + actual nested Streamlit tooltip/help buttons and dataframe toolbar buttons. + The phone Workbench `Open Data Health` action also retains its compact text + treatment with an explicit `24px` minimum height instead of the reproduced + `21px`. +- Final desktop retest measured the visible actual help button and all 16 + exposed dataframe toolbar buttons at exactly `24x24`. +- Final phone retest measured the visible actual help and dataframe buttons at + `24x24` and `Open Data Health` at `102x24`; document width remained equal to + the `390px` viewport width. + +Evidence boundary: + +- This directly closes the reproduced P01 size defect for the inspected + framework controls and current Streamlit markup. +- It does not prove pointer-spacing exceptions, every framework version, + complete P02 route-control coverage, keyboard order, zoom, forced colors, + reduced motion, screen-reader behavior, or WCAG conformance. +- Priority 7 remains incomplete under + `docs/ACCESSIBILITY_TASK_PROTOCOL.md`. + +## 2026-07-27 direct Chrome keyboard workflow audit + +Run metadata: + +- Run ID: `a11y-2026-07-27-chrome-keyboard-01`. +- Commit: `15c5270070238b91343a9f6eaa26f86527bfcaf2`. +- Route base: local `http://localhost:8501/`. +- Environment: macOS `26.5.1` build `25F80`; Google Chrome + `150.0.7871.182`; external display `3440x1440`; explicit CSS viewport + `1280x720`; 100% zoom; default colors and motion. +- Input: direct Chrome browser-control `Tab`, `Shift+Tab`, arrow, Enter, and + text-key input. Screen reader: `not_run`. Reviewer: + `Local supervised browser review`. +- The run used saved readiness only. It performed no refresh, readiness + rebuild, research-record save, screenshot capture, or generated-artifact + write. Browser-control input is direct functional evidence in this exact + environment; it is not independent human or assistive-technology review. + +Direct task results: + +- K01: `failed_reproducible`. After reload, the first focus sequence was + Workspace help button -> selected Workspace radio input -> selected route + radio input -> Streamlit main section -> `Skip to page answer`. Each of the + repeated shell controls therefore preceded the skip link. The inspected + controls had a visible three-pixel teal outline. +- K02: `passed_direct` in the recorded environment. Enter on the focused skip + link retained + `?mode=research&page=research-desk`, appended only + `#public-page-answer`, and moved `document.activeElement` to the + `public-page-answer` target. +- K03: `passed_direct` in the recorded environment. Arrow Down on the selected + route radio changed the URL to + `?mode=research&page=discover`, rendered the `Discover` `h1`, retained + keyboard focus, and showed the focus outline. +- K04: `failed_reproducible`. Search accepted `AVGO` and Enter, Tab, and Enter + opened the correct ticker-bound Company Workbench route without a pointer. + However, every result action exposed the same accessible text + `Open review`; neither the ticker nor company identity was present in the + link's accessible name. The action is operable but is not distinguishable + in a screen-reader links list or when encountered outside its visual row. +- K05: `failed_reproducible` at the first material focus defect. Native + disclosure `summary` elements received focus in the route sequence but had + computed `outline: none`; the complete disclosure traversal was stopped at + that first mismatch rather than promoted to a pass. +- K06-K09, Z01-Z03, C01-C02, M01, S01-S07, and remaining P02 coverage: + `not_run` in this run. + +Additional bounded observations: + +- An intentionally incomplete authoring validation was triggered with a + pointer only and saved no record. After the Streamlit rerun completed, the + `thesis_id is required` message was contained by + `data-testid="stAlertContainer"` with `role="alert"`. This DOM observation + does not pass keyboard task K06 or screen-reader task S06. +- The earlier apparent `23.2x18.7` skip-link target was measured while the link + was deliberately clipped off screen. When focused through the direct + keyboard path, the visible target measured approximately `132.6x38.2`. + No target-size defect is recorded from the clipped state. + +Root-cause and next-design boundary: + +- Streamlit renders the sidebar before the main section in DOM focus order. + The current skip link lives in the main section, so source-call ordering + cannot place it before sidebar controls. +- The shared focus selector covers links, buttons, form controls, explicit + roles, and nonnegative `tabindex`, but not native `summary`. +- Discover renders ticker-specific destinations with identical visible and + accessible `Open review` text. +- The narrow remediation should place one skip link as the first focusable + sidebar child, add ticker-specific accessible names while retaining the + concise visible action label, and include `summary:focus-visible` in the + shared focus contract. This is a proposed design, not an implemented fix. +- Priority 7 remains incomplete. Phone keyboard, complete K05-K09, zoom, + forced colors, reduced motion, screen-reader tasks, stable `main` landmark, + and remaining P02 coverage still require direct current evidence. + +## 2026-07-27 zoom-environment and phone-target follow-up + +Run metadata: + +- Run ID: `a11y-2026-07-27-reflow-targets-02`. +- Commit: `6aef0f8a9e89e8ec94d308095ca18ef33ea4177a`. +- Environment: the same macOS and Chrome versions as the preceding direct + keyboard run. Screen reader: `not_run`. Reviewer: + `Local supervised browser review`. +- The run used saved readiness and performed no refresh, readiness rebuild, + valid authoring preview, research-record save, screenshot capture, or + generated-artifact write. + +Zoom classification: + +- Chrome page-level keyboard input remained available for route tasks, but the + connected review surface did not pass browser-level zoom shortcuts through + to Chrome. Repeated `Meta`/`Control` plus zoom attempts left the CSS viewport + and device-pixel ratio unchanged. +- Z01-Z03 are therefore `blocked_environment`, not `passed_direct`. +- A separate viewport-only stress check at `640px` and `320px` found no + document-level horizontal overflow on Research Desk, Discover, Company + Workbench, or Monitor. This is bounded responsive-reflow evidence only. A + smaller viewport is not substituted for 200% or 400% browser zoom. + +Phone target and spacing follow-up: + +- At a `390x844` CSS viewport, every route was allowed to complete its + Streamlit render before measurement. +- The first overlap scan included controls retained inside closed native + disclosures and reported false-positive geometry intersections. Excluding + closed disclosure contents removed those intersections; they are not + recorded as product defects. +- Visible main-content route controls, primary links, search controls, and + disclosure headers on the four routes had no measured target below + `24px`, no measured adjacent overlap, and no document-level horizontal + overflow in the inspected collapsed state. +- Opening `Add a reviewed research record` exposed the current thesis authoring + controls without entering or saving data. The Record type control measured + approximately `265.8x38`, Summary measured `297.8x92`, and + `Validate and preview` measured `299.8x40`; no adjacent overlap or + document-level horizontal overflow was measured. +- This advances P02 only as `partial_local_evidence`. Mobile-sidebar route + choices, every record-kind field set, pointer misactivation, and controls + revealed by all Advanced disclosures were not fully exercised and are not + promoted to `passed_direct`. + +Evidence boundary: + +- The run found no new implementation defect beyond K01, K04, and K05 from the + preceding direct keyboard audit. +- Priority 7 remains incomplete. The proposed K01/K04/K05 remediation still + requires design approval; Z01-Z03 require a true browser-zoom environment; + forced colors, reduced motion, screen-reader tasks, stable `main` landmark, + phone keyboard, and remaining P02 coverage remain open. + +## 2026-07-27 Monitor keyboard and landmark continuation + +Run metadata: + +- Run ID: `a11y-2026-07-27-monitor-landmark-03`. +- Commit: `1a8727706595f87df459eeb5c7c762dafad83577`. +- Route base: local `http://localhost:8501/`. +- Environment: macOS `26.5.1` build `25F80`; Google Chrome + `150.0.7871.182`; CSS viewport `3440x1208`; device-pixel ratio `1`; + default colors and motion. +- Input: direct Chrome `Tab` and `Shift+Tab` keyboard input plus read-only + accessibility-role inspection. Screen reader: `not_run`. Reviewer: + `Local supervised browser review`. +- The run used saved readiness only. It performed no refresh, readiness + rebuild, authoring input, research-record save, screenshot capture, or + generated-artifact write. + +Direct task results: + +- K08: `passed_direct` for the recorded current Monitor state. Forward + keyboard traversal reached the Research Discipline table controls and + accessible canvas text, the truthful no-change state, the explicit + `This is a monitoring state, not a stock ranking` boundary, the + `Open Discover` next action, and the Advanced disclosures. The weekly + summary reported `0 traceable items`; no fabricated change or ranked + company was presented. +- K09: `failed_reproducible`. Forward traversal wrapped back to the first + control and fourteen direct `Shift+Tab` steps reversed through the same + route without a keyboard trap. However, each focused native `summary` + again exposed computed `outline: none`, so the expected continuously + visible reverse-focus state did not pass. This is the same focus-contract + defect recorded under K05, not a separate remediation. +- Stable route-level `main` landmark: `failed_reproducible`. Direct + accessibility-role inspection returned zero `main` landmarks on Research + Desk, Discover, ticker-bound AVGO Company Workbench, and Monitor at the + exact recorded commit. This does not promote screen-reader task S02, which + remains `not_run`. +- C01-C02 and M01: `blocked_environment`. Both connected local browser + surfaces reported forced colors and reduced motion inactive and exposed + viewport control only; neither exposed a supported media-emulation or + platform-mode control. Default-mode inspection cannot substitute for the + named forced-colors or reduced-motion tasks. + +Evidence boundary: + +- This run extends direct desktop keyboard evidence only. It is not phone + keyboard, screen-reader, forced-colors, reduced-motion, independent-human, + hosted, or WCAG-conformance evidence. +- The pending narrow remediation still covers the first-focus skip placement, + ticker-specific Discover action names, and native-summary focus visibility. + A stable semantic `main` remains a separately named open design problem. +- Priority 7 remains incomplete. K06-K07, complete K05, phone keyboard, true + zoom, forced colors, reduced motion, S01-S07, the stable `main` fix, and + remaining P02 coverage still require direct evidence or remediation. + +## 2026-07-27 keyboard authoring validation and no-write exit + +Run metadata: + +- Run ID: `a11y-2026-07-27-authoring-k06-k07-04`. +- Commit: `b1dd457b574045c06496fdf4ec6772dddfc7d751`. +- Route base: local + `http://localhost:8501/?mode=research&page=workbench&ticker=AVGO&open=1`. +- Environment: macOS `26.5.1` build `25F80`; Google Chrome + `150.0.7871.182`; default colors and motion. +- Input: direct Chrome keyboard input. Screen reader: `not_run`. Reviewer: + `Local supervised browser review`. +- The run used saved readiness only. It entered no research content, produced + no valid preview, saved no record, captured no screenshot, and wrote no + generated artifact. + +Pre-run ledger state: + +- `data/research_thesis_journal.csv` existed with one header line and SHA-256 + `0c427c359c1bdb6d8c8410aece9b3a46831ffb9ccd91d401aeb7930b5f401717`. +- `data/catalyst_evidence.csv` and + `data/research_outcome_reviews.csv` did not exist. + +Direct task results: + +- K06: `failed_reproducible`. Enter opened + `Add a reviewed research record`. Nine direct Tab steps traversed Record + type, every empty thesis field, Supersedes Entry Id, and + `Validate and preview`; every inspected control had the shared visible + three-pixel focus outline. Enter on the validation button produced + `thesis_id is required` inside one `role="alert"` and exposed no + `Confirm and save` button. However, the affected Thesis Id control had no + `aria-invalid`, `aria-describedby`, or `aria-errormessage`, and focus + remained on `Validate and preview`. The global alert is bounded positive + evidence but does not satisfy the required affected-control association or + screen-reader announcement. +- K07: `passed_direct`. Keyboard activation of the selected Company Workbench + route radio followed by Arrow Down opened Monitor, retained active focus on + the selected Monitor route, and rendered the truthful Monitor state without + a trap. After exit, the thesis journal line count and SHA-256 were unchanged, + the catalyst and outcome ledgers remained absent, and no ledger path was + dirty. + +Evidence boundary: + +- This run does not pass S06 or S07 because no screen reader was used. +- The required K06 remediation is field-level invalid-state and error + association, followed by direct keyboard and supported screen-reader + retesting. Confirmation and persistence must remain unavailable for a + rejected draft. +- Priority 7 remains incomplete. K01, K04-K06, K09, phone keyboard, true zoom, + forced colors, reduced motion, S01-S07, stable `main`, and remaining P02 + coverage still require remediation or direct evidence. + +## 2026-07-27 phone keyboard and mobile-sidebar continuation + +Run metadata: + +- Run ID: `a11y-2026-07-27-phone-keyboard-05`. +- Commit: `c748b192dd4fe27f1c17f2feba07eaccd24e4619`. +- Route base: local `http://localhost:8501/`. +- Environment: macOS `26.5.1` build `25F80`; Google Chrome + `150.0.7871.182`; explicit CSS viewport `390x844`; device-pixel ratio `1`; + default colors and motion. +- Input: direct Chrome keyboard input plus read-only target geometry. + Screen reader: `not_run`. Reviewer: `Local supervised browser review`. +- The run used saved readiness only. It entered no research content, produced + no authoring preview, saved no record, captured no screenshot, and wrote no + generated artifact. + +Direct task results: + +- K01 phone: `failed_reproducible`. After reload, the first focus sequence was + Workspace help at approximately `x=-202`, the selected Workspace radio at + `x=-271`, the selected route radio at `x=-271`, the Streamlit main section, + then the visible `Skip to page answer` link. The first three controls were + in the closed off-canvas sidebar and could not expose perceivable focus in + the `390px` viewport. +- K02 phone: `passed_direct` on ticker-bound AVGO Company Workbench. Enter on + the visible skip link retained `mode=research`, the Workbench route, + `ticker=AVGO`, and `open=1`, appended only `#public-page-answer`, and moved + focus to the answer target. +- K03 phone: `failed_reproducible`. Arrow Down on the off-canvas selected + route radio changed the URL and rendered Discover, but the focused radio + remained at approximately `x=-271`. The route was keyboard-operable but did + not satisfy visible focus. The framework sidebar-open control measured + `0x0`, was hidden, and did not occur in the recorded Tab sequence. +- K04 phone: `failed_reproducible` for the same identity defect as desktop. + Keyboard input reached the visible `334x36` search control, entered and + applied `AVGO`, and opened the correct ticker-bound Workbench from an + approximately `86x44` action. The action's accessible name remained only + `Open review`; the preceding Advanced summary also repeated the + no-visible-outline defect. +- K05/K09 phone: not rerun end to end. The directly encountered focused + Advanced summary again had computed `outline: none`; no broader pass is + inferred. + +P02 mobile-sidebar result: + +- P02: `failed_reproducible` for the mobile sidebar route choices. Research + Desk, Discover, Company Workbench, and Monitor labels measured approximately + `104x50`, but all target boxes began at `x=-279` and ended left of the + visible viewport. The hidden `0x0` sidebar-open control provided no visible + pointer or keyboard entry to those choices. +- Visible main-route controls inspected in this run retained at least `24px` + on each measured target dimension, and Company Workbench retained + `document.scrollWidth == document.clientWidth == 390`. These bounded + positives do not override the off-canvas route-choice failure. + +Evidence boundary: + +- The new defect is mobile navigation visibility, not route-state logic: + off-canvas arrow navigation changed routes deterministically, but users + cannot rely on an invisible focused control. +- Remediation requires a visible, labelled, keyboard-operable mobile route + navigation entry plus direct keyboard and pointer retesting. It must not + duplicate route state, change research readiness, or move technical + evidence into the primary answer. +- Priority 7 remains incomplete. K01, K03-K06, K09, mobile navigation P02, + true zoom, forced colors, reduced motion, S01-S07, stable `main`, and + remaining disclosure-state coverage still require remediation or direct + evidence. diff --git a/docs/ACCESSIBILITY_TASK_PROTOCOL.md b/docs/ACCESSIBILITY_TASK_PROTOCOL.md new file mode 100644 index 00000000..da9a3b38 --- /dev/null +++ b/docs/ACCESSIBILITY_TASK_PROTOCOL.md @@ -0,0 +1,166 @@ +# Accessibility Task Protocol + +This protocol defines repeatable manual accessibility review for the supported +Personal Research workflow: + +`Research Desk -> Discover -> Company Workbench -> Monitor` + +It is an execution template, not test evidence. A completed row proves only the +named task in the recorded environment. No result may be inferred from a screenshot, +source inspection, an automated contract, or a different route. +No WCAG conformance claim may be made from a partial run. + +## Safety boundary + +- Use a local or explicitly approved private review environment. +- Use saved readiness only. Do not refresh, import, apply, or rebuild data. +- Do not save a research record. Workbench authoring may be inspected only up + to validation or a write-free preview. +- Do not enter personal, account, brokerage, portfolio, or confidential + research information. +- Do not stage screenshots, timing output, CSV, JSON, reports, sample reports, + or other generated artifacts. +- Findings must not weaken research-only, provenance, source-rights, + calibration, explicit-Q4, EPS split-basis, or fail-closed boundaries. + +## Required run metadata + +Record every field before starting: + +| Field | Required value | +| --- | --- | +| Run ID | Stable local identifier | +| Date and timezone | ISO date plus timezone | +| commit SHA | Exact checked-out revision | +| Route base URL | Local or approved private URL | +| Operating system | Name and version | +| Browser | Name and version | +| Input method | Keyboard and any assistive input | +| Screen reader | Name/version or `not_run` | +| Display | Resolution and scaling | +| Viewport | CSS width and height | +| Zoom | Browser zoom percentage | +| Color mode | Default, forced colors, or named contrast mode | +| Motion preference | Default or reduced motion | +| Reviewer | Non-sensitive identifier | + +Stop if the checked-out revision changes during the run. Start a new run ID +after any code change or environment change that can affect the result. + +## Result vocabulary + +Use exactly one state per task: + +- `passed_direct`: the reviewer directly completed the named task and observed + every expected result in the recorded environment. +- `failed_reproducible`: the task failed and the reviewer recorded exact + reproduction steps plus observed behavior. +- `not_run`: the task was not attempted. +- `blocked_environment`: the named browser, assistive technology, operating + mode, or review capability was unavailable. +- `not_applicable`: the task does not apply, with a written reason. + +Never promote `not_run`, `blocked_environment`, automated checks, screenshots, +or a result from another route to `passed_direct`. + +## Core keyboard tasks + +Run these tasks without a mouse or touch input. + +| ID | Mode | Task | Expected direct result | Failure evidence | +| --- | --- | --- | --- | --- | +| K01 | `keyboard_only` | Reload Research Desk and press Tab until the skip link or first repeated shell control receives focus. | Focus is visible; the actual order is recorded. If repeated shell controls precede the skip link, record a defect rather than inferring success from DOM presence. | Focused control sequence and first unexpected control | +| K02 | `keyboard_only` | Activate `Skip to page answer`. | The URL retains Personal Research mode and the current route parameters; focus moves to or immediately before the route answer without changing evidence state. | URL, focused element, and visible position | +| K03 | `keyboard_only` | Traverse the route rail and open Discover. | Every route choice is reachable, visibly focused, and operable without trapping focus. | Last reachable control and blocked action | +| K04 | `keyboard_only` | Use Discover search and open one readiness-backed company. | Search, result, and open action have understandable names; no ranking or recommendation language is announced. | Control name, announced text, and route | +| K05 | `keyboard_only` | Traverse the Company Workbench primary answer and each collapsed disclosure. | Reading/focus order follows the visible research answer; Advanced evidence remains optional; disclosures expose state and respond to Enter or Space. | First order mismatch or inoperable disclosure | +| K06 | `keyboard_only` | Open the authoring composer, enter a deliberately incomplete draft, and validate without previewing or saving. | Required-field errors are programmatically associated or otherwise announced at the affected control; no confirmation action appears before an exact valid preview. | Error text, focused control, and announcement | +| K07 | `keyboard_only` | Cancel or leave the composer, then open Monitor. | No research record is written; navigation remains operable and focus is not trapped. | Unexpected write, trapped focus, or lost route | +| K08 | `keyboard_only` | Traverse Monitor in its truthful empty or current saved state. | Weekly summary, Research Discipline Review, change state, and next action are understandable without implying a ranking or fabricated event. | Announced text and first misleading state | +| K09 | `keyboard_only` | Reverse through the current route with Shift+Tab. | Visible focus remains present and no keyboard trap appears. | Trap boundary and last reachable control | + +## Zoom and reflow tasks + +Run at the named browser zoom with the viewport and operating-system scaling +recorded. Local scrolling inside a genuinely two-dimensional data table may be +recorded separately; it does not excuse document-level horizontal overflow. + +| ID | Mode | Task | Expected direct result | +| --- | --- | --- | --- | +| Z01 | `zoom_200` | Complete Research Desk -> Discover -> Company Workbench -> Monitor at 200% zoom. | Primary answers, labels, controls, and research-only boundaries remain readable and operable without document-level horizontal scrolling. | +| Z02 | `zoom_400` | Repeat the core answer path at 400% zoom. | Content reflows to a single-column reading path; no primary content or action is clipped, overlapped, or hidden. | +| Z03 | `zoom_400` | Open each Workbench Advanced disclosure used in K05. | Disclosure content remains reachable; any table-local scrolling is bounded and does not move the whole document horizontally. | + +## Color, contrast, and motion tasks + +| ID | Mode | Task | Expected direct result | +| --- | --- | --- | --- | +| C01 | `forced_colors` | Traverse all four routes with the platform forced-colors mode active. | Focus, selected route, boundaries, status distinctions, and primary actions remain perceivable without color alone. | +| C02 | `forced_colors` | Inspect disabled, blocked, partial, and usable states. | Text or semantic labels preserve each distinction when authored colors are overridden. | +| M01 | `reduced_motion` | Load and navigate all four routes with reduced motion enabled. | No required information depends on animation; loading and disclosure state remain understandable. | + +## Screen-reader tasks + +Use a supported desktop screen reader and record its exact name, version, and +speech settings. Do not substitute an accessibility-tree snapshot for these +tasks. + +| ID | Mode | Task | Expected direct result | +| --- | --- | --- | --- | +| S01 | `screen_reader` | Read the page title, route heading, and research-only boundary on each route. | One route-level heading is announced and the boundary is available without reading Advanced evidence. | +| S02 | `screen_reader` | Use the landmarks and headings lists. | Available landmarks and the heading hierarchy match the current page. If no main landmark is announced, record the open defect. | +| S03 | `screen_reader` | Activate the skip link on each route, including a ticker-bound Workbench URL. | The destination and retained route/ticker parameters are announced or directly verifiable; focus transfer is understandable. | +| S04 | `screen_reader` | Operate Discover search and open a company. | Search purpose, result identity, readiness boundary, and open action are announced without recommendation language. | +| S05 | `screen_reader` | Traverse Workbench cards, disclosures, and withheld states. | Section names, status, evidence boundary, and one next research task are understandable in reading order. | +| S06 | `screen_reader` | Trigger the incomplete authoring validation used in K06. | The invalid state, error, and affected field are announced; no record is saved. | +| S07 | `screen_reader` | Observe a Streamlit rerun or validation update. | Material status changes are announced or the missing announcement is recorded as a reproducible defect. | + +## Target-size and pointer follow-up + +Measure the visible hit target, not only the hidden native input: + +| ID | Task | Expected direct result | +| --- | --- | --- | +| P01 | Measure framework help controls and dataframe toolbar controls used in the workflow. | Each material target meets the applicable target-size requirement or has a documented exception and adjacent-spacing evidence. | +| P02 | Measure route choices, primary links, disclosure headers, search controls, and authoring actions at phone width. | Targets do not overlap and can be activated without triggering an adjacent control. | + +## Finding record + +Create one record per failed task: + +```text +finding_id: +run_id: +commit_sha: +task_id: +route: +severity: +environment: +precondition: +steps: +expected: +observed: +focus_or_announcement: +artifact_location: +research_boundary_impact: +proposed_fix: +retest_run_id: +retest_state: +``` + +Use `critical`, `high`, `medium`, or `low` severity. Severity reflects blocked +task completion and misuse risk, not investment impact. + +## Completion rule + +The accessibility priority remains incomplete until: + +1. Every applicable task has `passed_direct` evidence in a suitable current + environment or a reviewed, explicitly bounded exception. +2. Every material failure has a fix and direct retest or a documented product + boundary with an owner. +3. Desktop and phone workflow evidence covers the same exact revision. +4. Automated semantic contracts and manual results agree. +5. Any public accessibility statement matches only the tested scope. + +The existence of this protocol is not completion evidence. diff --git a/docs/DASHBOARD_QA.md b/docs/DASHBOARD_QA.md index 63de9bd8..3ea2f7b0 100644 --- a/docs/DASHBOARD_QA.md +++ b/docs/DASHBOARD_QA.md @@ -2,6 +2,129 @@ This file records lightweight browser QA evidence for public-facing dashboard polish. +## 2026-07-18 Personal Research Evidence Detour Continuity + +Read-only AppTest coverage now verifies six Personal Research surfaces: Research Desk, Discover, Company Workbench, Monitor, Research Data Health, and Research Proof History. Data Health and Proof History stay inside Personal Research mode when opened from Company Workbench Advanced Evidence, preserve the selected ticker, and show **Return to Company Workbench** before existing evidence content. + +The same-mode detour does not change readiness or evidence state, add a route, expose Operator commands, refresh data, record a review outcome, or treat Proof History as an unlock. The active continuation contract prohibited new screenshots, so this pass proves route and render continuity only; it does not prove visual spacing, focus order, contrast, assistive-technology behavior, hosted behavior, or accessibility compliance. + +## Company Workbench Authoritative Task Contract + +Company Workbench renders one overall `ONE NEXT TASK` card. The change-answer contract explicitly distinguishes `none`, `snapshot_only`, and `source_backed`: no matching change renders a neutral no-queued-change badge, snapshot-only context renders only its own badge, and eligible source-backed context renders the source-backed badge. A change can win only when its answer also carries strict source-backed eligibility. Open items keep their suggested review task; still-blocked and intentionally deferred items preserve their existing wait and monitor routing plus the recorded wait condition. Forward View keeps lane-specific guidance and is not a competing overall task. This composition does not alter readiness or evidence states, which remain independent. + +A focused Streamlit AppTest regression renders the normal AVGO Company Workbench route with zero exceptions, a scoped no-queued-change Evidence Change card without a snapshot-only badge, exactly one `ONE NEXT TASK` marker, the current peer-evidence priority `Add peer mappings`, and one `FORWARD-VIEW LANE UNBLOCK` marker. It rejects the retired uppercase `NEXT RESEARCH TASK` kicker while retaining the established title-case `Next Research Task` section heading. This is deterministic local render evidence only, not current-data, source-rights, hosted, or visual-browser evidence. + +## Stale Readiness Continuation Gate + +When the selected profile is stale or incomplete, the stale readiness continuation gate makes `make readiness-preview TOP_N=20` the only continuation-safe action. Project-status next steps are suppressed, while provider setup details, coverage rankings, scheduled-operation details, and Advanced Data Health cards remain planning context only. Advanced Data Health cards derive the gate from the selected profile even when their cached preflight predates the gate. `make readiness` is a separate intentional reviewed write; the UI and CLI must not imply that the preview refreshes data, makes saved readiness current, or authorizes source execution. + +## 2026-07-18 Monitor Answer-First Live Review + +The default-profile Monitor route was reviewed before and after the hierarchy +change at `1280x720` and `390x844`. Before the change, five-company Earnings +Nowcast readiness interrupted the path between the weekly summary and the +actual research-change answer. After the change, the weekly summary is followed +by `Research change monitor`, the neutral empty-queue answer, and one Open +Discover action. Five-company readiness cards and full rows remain unchanged +inside the collapsed `Advanced: five-company Earnings Nowcast readiness` +drawer. + +At both widths, no details element was expanded, the technical readiness card +was absent from visible text while Advanced was closed, and the document width +matched the viewport exactly (`1280px` and `390px`). The browser recorded no +errors. The phone review confirmed the answer, Open Discover action, and all +three Advanced drawers in reading order without horizontal overflow. Computed +styles confirmed the primary action and its nested text use a white foreground +on the existing primary-button background; the action measured approximately +`110x40px`. + +Reviewed before/after screenshots were saved outside the repository and remain +unstaged. They prove local route hierarchy, responsive reflow, and visible +control styling only; they do not prove current market data, source rights, +hosted behavior, full keyboard or assistive-technology support, or predictive +validity. + +## 2026-07-18 Research Desk Answer-First Live Review + +The live default-profile Research Desk route was reviewed at `1280x720` and +`390x844`. The workspace boundary and Discover next action were followed by the +weekly research summary, four direct research answers, and the Open Discover +action. Focused-cohort scope, concise lane coverage, full matrices, and weekly +rows remained available inside the existing collapsed `Advanced Evidence` +drawer. + +At both widths, no details element was expanded by default and the hidden cohort +cards were absent from rendered visible text. The weekly summary, `What should I +review next?`, Open Discover, and Advanced Evidence remained present. The +document width matched the viewport exactly (`1280px` and `390px`), with no +horizontal overflow, and the browser recorded no errors. + +Reviewed screenshots were saved outside the repository in the temporary review +workspace and remain unstaged. They prove local layout and route composition +only; they do not prove current data, source rights, hosted behavior, or +predictive validity. + +## 2026-07-18 Company Workbench Answer-First Live Review + +The live default-profile Company Workbench route for NVDA was reviewed at +`1280x720` and `390x844`. The route rendered the workspace boundary and one +next action, then kept selected-company lane cards closed under +`Advanced: selected-company lane coverage`. The unchanged selected-ticker +answer remained visible and the full review continued through What Changed, +Business Trend, Valuation, Forward View, What Remains Withheld, Research +Conclusion, and Next Research Task. + +At both widths, no details element was expanded by default and the hidden lane +cards were absent from rendered visible text. The document width matched the +viewport exactly (`1280px` and `390px`), with no horizontal overflow. The phone +first view retained the research-only boundary and primary next action before +the selected-company content continued below it. + +Reviewed screenshots were saved outside the repository in the temporary review +workspace and remain unstaged. They prove local layout and route composition +only; they do not prove current data, source rights, hosted behavior, or +predictive validity. + +## 2026-07-18 Discover Answer-First Live Review + +The live demo-profile Discover route was reviewed from the selected worktree at +`1280x720` and `390x844`. In both viewports, the Personal Research header and +research-only boundary are followed directly by `Which stock can I review?`, +the readiness-backed search control, and the existing Company Workbench actions. +Focused-cohort scope and lane-coverage cards now remain closed under +`Advanced: cohort readiness context` after the selection task. + +The phone viewport reported a `390px` document width at a `390px` viewport, +with no horizontal overflow and no details element expanded by default. The +selector remained limited to the same deterministic cohort, and its links +continued to target Company Workbench in Personal Research mode. This was a +presentation-order change only; no readiness, data, ranking, or route contract +changed. + +Reviewed screenshots were saved outside the repository in the temporary review +workspace and remain unstaged. They are product-layout evidence only; +they do not prove data freshness, source rights, hosted behavior, or predictive +validity. + +## 2026-07-17 Commercial Beta Research Workflow Live Review + +The live local Streamlit app was reviewed in the in-app browser at `1280x720` +and `390x844` across Research Desk, Discover, Company Workbench, and Monitor. +Each route rendered from the selected worktree, kept the research-only boundary +visible, preserved one next action, kept Advanced evidence closed, and showed no horizontal overflow at either viewport. + +The first settled Company Workbench review exposed a real `ArrowInvalid` +failure when immutable Forward View evidence reached the display table. The +display adapter now serializes that immutable nested evidence into deterministic +JSON text. A fresh server restart and repeat NVDA review completed through +Research Conclusion and Next Research Task without traceback text. Focused and +full tests plus the dashboard and public gates cover the corrected contract. + +This live review is product evidence only. It does not prove hosted behavior, +data freshness, licensed commercial source operation, external reviewer +success, or predictive validity. No screenshot from this session is committed; +generated capture output remains excluded. + ## 2026-07-11 Public Workflow Modernization The current public first-view contract is intentionally simpler than older @@ -35,6 +158,46 @@ exact current wording. Screenshot evidence is product evidence only. It does not refresh data, apply imports, unlock blocked fundamentals, peers, earnings, analyst estimates, valuation inputs, or prove current readiness counts. +## 2026-07-21 Public Desktop And Phone Workflow Review + +A fresh local review covered all five public pages—Home, Stock Selector, +Single-Stock Report, Data Health, and Proof History—at desktop and `390x844` +phone viewports. Every page passed its current review with its first answer and +primary handoff visible, Advanced/raw detail collapsed, no horizontal overflow, +and no traceback. + +Only the Single-Stock Report needed a later regression correction. Its +direct-open loading state placed three large quick-read cards before the +evidence handoff even though the completed report used the compact answer. +The loading state now renders the same compact selected-ticker answer before +provider and report work, preserving Selected ticker -> `Use now` -> `Still +withheld` -> `Open Data Health`. Browser measurement at `390x844` verified the +44px handoff fully inside the viewport with at least 50px of bottom clearance, +no horizontal overflow, and no traceback. Desktop remains a four-column layout +with the same handoff visible. + +Data Health and Proof History remain answer/evidence destinations. This review +adds no invented calls to action, does not turn either route into a readiness +unlock, and does not change readiness, source, research, or generated-artifact +state. The earlier screenshots predate this regression fix and remain +historical presentation evidence only; their old measurements do not prove the +corrected loading state. The later result was verified through live browser +layout measurement, and no new screenshot artifact was created. Neither form +of local presentation evidence proves data freshness, source rights, hosted +behavior, accessibility compliance, external reviewer behavior, or predictive +validity. + +The review-note gate preserves the corrective history without understating the +finished audit. `resolved` is a direct successful review; +`resolved_post_fix` is a successful fresh recapture after a correction. The +current raw counts remain nine `resolved` rows and one `resolved_post_fix` row, +while the aggregate gate reports `10/10` and `share_review_ready`. Only those +two explicit labels are successful. Unknown labels—including values that merely +start with `resolved`—and deferred, environment-limited, skipped, blocked, or +pending rows remain fail closed. This is screenshot-based local workflow QA, +not WCAG, keyboard, screen-reader, hosted, external-reviewer, freshness, or +market-validation evidence. + ## V1 Public UI Replacement QA This is the current replacement-readiness browser QA contract for the public diff --git a/docs/DATA_STRATEGY.md b/docs/DATA_STRATEGY.md index b8c7d0f2..1f12a37c 100644 --- a/docs/DATA_STRATEGY.md +++ b/docs/DATA_STRATEGY.md @@ -4,6 +4,36 @@ The product is CSV-first. Local rows are the source of truth; provider-assisted Provider-assisted does not mean provider-decided. A provider can help populate local price rows, but the product still validates local CSV coverage before momentum, liquidity, DCF, peer, or decision sections appear. Fundamentals, peer relationships, earnings, and analyst estimates need trusted source review before they become analysis inputs. +## Commercial Source Rights + +Commercial Research mode is fail-closed. It may use a source only when `config/source_rights.yml` records explicitly approved commercial use alongside permitted use, redistribution, storage, attribution, rate-limit, authentication, freshness, supported-field, and fallback details. Credentials and license documents stay outside Git. + +Run `python3 -m src.commercial_source_rights` to inspect the read-only registry, or add `--source ` for one commercial-mode decision. The current registry permits SEC Companyfacts subject to SEC fair-access requirements. `yfinance`/Yahoo Finance remains research-only until separate commercial rights are verified; the commercial gate refuses it rather than treating availability or a local cache as permission. + +The exact `sec_companyfacts` record now lists `operating_income`, `cash_from_operations`, and `capital_expenditures` after the bounded SEC filing review. This is a field-scope decision under the existing SEC record, not a new legal opinion, provider entitlement, broad-coverage claim, or production activation. `make sec-quarterly-cash-preview AS_OF=` retrieves only the exact Companyfacts, submissions, and primary-filing endpoints for the declared one-company pilot, keeps all payloads in memory, and prints a human review result. Technical extraction, exact-source rights, field scope, adapter acceptance, production activation, and saved readiness remain independent. + +Price availability and price provenance are separate. `make readiness-preview TOP_N=20` audits the exact latest usable price row for every proposed DCF promotion without writing files. Commercially reviewable row lineage requires `source`, `source_ref`, and `retrieved_at`, plus exact-source approval and registered `prices` scope. A market date, `as_of_date`, local CSV label or file timestamp, configured adapter, refresh warning, or recognizable OHLCV series cannot reconstruct missing provider identity. The current canonical gap should be solved prospectively with reviewed row-level evidence; do not mass-rewrite historical price rows or assign a guessed provider. + +When one permitted reviewed price export exists, normalize only that bounded file with explicit evidence metadata: `make price-normalize INPUT= TICKER= SOURCE= SOURCE_REF= RETRIEVED_AT= AS_OF=`. Then run `make price-validate AS_OF=` and `make price-preview AS_OF=`. A `lineage_complete` result proves field preservation only; `temporal_complete` separately proves the declared retrieval is no earlier than the next UTC day after the daily observation and no later than the explicit review cutoff. Verify exact-source commercial rights and registered `prices` scope before any reviewed `make price-apply AS_OF=`. Missing metadata remains review-required without being generated automatically. + +The same validation and preview now perform that registry join directly for technically valid staged rows. They report exact-source rights and registered `prices` scope as separate states and retain deterministic distinct-source blockers. Unknown, blank, or unverified source IDs remain review-required; a rights-approved source without `prices` scope also remains scope-review-required. These read-only results do not change row validity or authorize apply. + +If `COMMERCIAL_RESEARCH_MODE` is explicitly enabled, `price-apply` now enforces lineage, retrieval timing, rights, and registered price scope before mutation. Malformed, naive, too-early, post-cutoff, or cutoff-unreviewed retrieval evidence blocks apply in every mode; missing retrieval remains a separate research-compatible lineage gap but blocks Commercial Research apply. Apply carries the one validated staged frame through the merge instead of reading the import twice, then flushes a same-directory temporary CSV and atomically replaces the canonical path. Known validation failures create no backup or canonical mutation. This is not a concurrent-writer lock or a database transaction, and passage does not validate the payload, rebuild readiness, or record proof. + +Prospective point-in-time consensus uses the same exact-source principle with metric-specific scope. Collection preview reports append-only technical validity independently from commercial rights; non-empty Revenue and EPS values require literal `revenue_consensus` and `eps_consensus` support respectively. Explicit Commercial Research mode blocks an incomplete record before ledger or directory mutation, while ordinary research mode preserves the explicit reviewed local append path. The current registry contains no approved consensus source or either consensus scope. Do not add a provider record, infer an alias, or treat a configured key as permission without reviewed rights evidence. + +Provider source-row validation also derives this evidence directly; it no longer accepts a caller-supplied rights status. Technical schema/comparability acceptance remains independent, while only technically accepted rows receive row-level exact-source rights and populated-metric scope review. `historical_evidence_reviewable` means a supplied point-in-time row can proceed to review, not that it is licensed, collected, activated, fresh, or nowcast-ready. + +Every source-row validation call must declare its review cutoff. Only explicit `current_only` and `point_in_time` scopes are recognized, and every accepted row proves `snapshot_at <= retrieved_at <= review_cutoff`. Candidate context receives no temporal exemption. The normalized cutoff is evidence of the local review boundary only; it cannot establish when a provider published a value or authorize collection and use. + +For a reviewed multi-row consensus file, preview simulates the complete input in order against the existing ledger plus earlier technically valid proposed rows. Record uses that same batch result and does not begin appending until every technical row passes and, in Commercial Research mode, every required rights/scope state passes. A later known rejection therefore leaves the existing ledger byte-identical. The collector does not reorder revisions or infer missing lineage. One append handle narrows the mutation boundary but does not provide process locking, crash recovery, or a filesystem transaction guarantee. + +Saved consensus history is never trusted merely because its CSV header parses. Status, preview, and record validate every row and require a unique, linear root-to-leaf revision chain per ticker/period. Preview returns a receipt over the normalized cutoff, exact mode, full input, and full saved ledger. Record requires the same `AS_OF` and `PREVIEW_RECEIPT` and recomputes the decision before opening the destination; edits or intervening appends require a fresh preview. Source-review exports remain a separate schema and are never silently mapped into collection rows. + +Collection preview and upstream source-row validation use one immutable exact-source field-scope review for their registry decision. It preserves the caller's ordered, non-empty, unique required fields and reports rights approval, missing registered fields, and their conjunction without generating blocker copy or changing consumer-specific technical/write rules. This shared metadata decision does not prove the estimate payload, timestamps, comparability, reviewer intent, collection, activation, readiness, backtesting, or calibration. Price, DCF, fundamentals, and cash-generation reviews retain their separate domain requirements. + +When a reviewed upstream consensus export becomes available, use `SOURCE_INPUT=` and start with the read-only source gate: `make earnings-consensus-source-review INPUT=$SOURCE_INPUT PROVIDER= AS_OF=`. It requires an explicit provider and cutoff, validates CSV shape, and prints technical rejections plus independent rights and Revenue/EPS scope evidence without writing. The upstream source export and the prospective ledger row are distinct input contracts: the source export declares `history_scope`, while `COLLECTION_INPUT=` follows the checked-in prospective schema and declares `review_state`. After separate human review and explicit mapping that preserves the accepted evidence, run `make earnings-consensus-collection-preview INPUT=$COLLECTION_INPUT AS_OF=`. No command infers scope, transforms one file into the other, or writes a mapped file. Neither gate supplies permission, collection, activation, readiness, backtesting, or calibration; the later confirmed record command remains a separate mutation boundary. + ## Public Visitor FAQ The public workflow is Home -> Stock Selector -> Single-Stock Report -> Data Health -> Proof History. Use this data strategy as the explanation layer behind that path, not as a second navigation model. @@ -103,7 +133,9 @@ Before choosing a company, run `make universe-scope TICKERS=NVDA,META TOP_N=10` If you want to choose the next pilot from current local blockers, run `make project-status-check` first. Only run `make trusted-data-pilot-candidates TOP_N=10` when project-status-check shows executable company candidates; if it says current source-proof queues are exhausted, run `make provider-setup-checklist` instead. The candidate command ranks operating-company candidates and excludes ETF/index monitor examples from the company DCF pilot list. The default candidate output is compact so visitors can see the shortlist, quick path, compact review board, and safe loop without reading every row-level proof detail; use `make trusted-data-pilot-candidates TOP_N=10 VERBOSE=1` when you need full per-candidate file status, decision gates, rejected-row paths, and evidence expectations. If you want a batch operating view before drilling into individual names, run `make trusted-data-pilot-board TICKERS=MU,CRDO,HOOD,TSLA,META,A,APLD`; it prints the lane mix, outcome mix, lane-group workflows, per-ticker blocker rows, and suggested lane-group next step without writing a CSV. Lane-group workflows show candidate count, tickers, shared blocker theme, review command pattern, trusted row target, rejected-row report, proof command pattern, stop condition, and whether the lane is review-only, locked, or safe for dry-run batching. If you already know the lane group, run `make trusted-data-pilot-lane LANE=fundamentals_dcf`, `make trusted-data-pilot-lane LANE=peer_mapping`, `make trusted-data-pilot-lane LANE=peer_valuation_inputs`, `make trusted-data-pilot-lane LANE=optional_context_locked`, or `make trusted-data-pilot-lane LANE=price_coverage`; the command prints ordered lane-specific steps plus the evidence summary for what proves the lane, which rows/files are needed, which rejected-row reports matter, which readiness command confirms a change, and what remains blocked. Price coverage stays separate as a dry-run-first planning lane; fundamentals, peers, earnings, and analyst estimates remain review-required. If you want a one-company packet after choosing a lane group, run `make trusted-data-pilot-packet TICKER=CRDO`; it prints the before report, focused blocker check, lane review path, validate/preview gate, apply boundary, rejected-row report path, rebuild proof, and evidence row to record. If you want a CSV ledger for selected before-state proof paths, run `make trusted-data-pilot-evidence TICKERS=MU,CRDO`; it writes current modes, lane blockers, proof commands, trusted-row targets, rejected-row checks, and still-blocked reasons without changing source rows or readiness. If you want the copyable read-only checklist after choosing several names, run `make trusted-data-pilot TICKERS=NVDA,CRDO,META TOP_N=10`. The broader checklist remains available as `make trusted-data-pilot TOP_N=10`. Each command does not refresh, import, or edit local CSV files. -Before calling a run pilot-ready, use `make pilot-readiness-check TOP_N=10`. It checks GitHub sync, dirty-tree classification, generated CSV/report churn, readiness freshness, source-proof queues, reviewed proof-ledger status, the explicit `make public-check` gate, and research-only guardrails. Use `make pilot-share-brief` to write `outputs/pilot_share_brief.md` as the concise public/demo handoff; it does not refresh data or unlock blocked inputs. Then use `make pilot-readiness-packet` to write `outputs/pilot_readiness_packet.md` as the fuller reviewer packet. A `pilot-ready with manual gates` verdict is acceptable for a research pilot when generated churn is excluded, public-check still needs to be run, and source-proof lanes remain visibly blocked. A `blocked` verdict means fix sync, freshness, or uncommitted product files before sharing the pilot state. +Before calling a run pilot-ready, use `make pilot-readiness-check TOP_N=10`. It checks GitHub sync, dirty-tree classification, generated CSV/report churn, readiness freshness, source-proof queues, reviewed proof-ledger status, the explicit `make public-check` gate, and research-only guardrails. Freshness uses both file mtimes and declared source dates: if a source row declares a later date than the saved readiness build, the pilot and reviewed-batch gates stay stale even when checkout or restore mtimes appear current. The check is read-only; it does not rebuild readiness or create CSV/JSON artifacts. When freshness is stale, run `make readiness-preview TOP_N=20` to compute the same proposed stable readiness states in memory and compare them with the saved snapshot. The preview explains added/removed rows and newly ready, partial, or excluded feature transitions, including named behavior-equivalent DCF scope reasons; transition counts may overlap and are not current readiness totals. It also reviews false-to-true fundamentals and DCF changes separately for the exact canonical source value, source/as-of/durable-reference provenance, checked-in commercial-rights status, and registered support for required fundamentals fields. Composite or unregistered source values fail closed as exact identifiers; they are not split or granted the rights of one component. The preview prints to stdout only, creates no CSV, JSON, report, sample-report, screenshot, timing, directory, or bytecode artifact, and does not make saved readiness current. Technical promotion, method fit, provenance completeness, commercial permission, field scope, and complete DCF price provenance remain independent. It is inspection evidence only: an intentional reviewed `make readiness` run remains the separate unblock step and requires source and generated-artifact review. Use `make pilot-share-brief` to write `outputs/pilot_share_brief.md` as the concise public/demo handoff; it does not refresh data or unlock blocked inputs. Then use `make pilot-readiness-packet` to write `outputs/pilot_readiness_packet.md` as the fuller reviewer packet. A `pilot-ready with manual gates` verdict is acceptable for a research pilot when generated churn is excluded, public-check still needs to be run, and source-proof lanes remain visibly blocked. A `blocked` verdict means fix sync, freshness, or uncommitted product files before sharing the pilot state. + +The stale readiness continuation gate applies that boundary consistently to project status, Session Source Preflight, provider setup, the coverage frontier, Auto-Refresh Status, its runbook, and Advanced Data Health cards. While readiness is stale or incomplete, `make readiness-preview TOP_N=20` is the only continuation-safe command. Source availability, provider availability, scheduled operations, and ranked opportunities remain planning context only; their source, refresh, apply, proof, and rebuild commands are not executable routing. `make readiness` is a separate intentional reviewed write after source review and generated-artifact review. In Operator Data Health, start with the Pilot Reviewer Walkthrough before opening detailed tables. It compresses the pilot gate, leading manual gate, source-proof focus, packet command, and public-check boundary into one first-screen path so a reviewer can understand what is green, what remains manual, and why blocked source inputs stay blocked. diff --git a/docs/EARNINGS_NOWCAST_PILOT.md b/docs/EARNINGS_NOWCAST_PILOT.md index 9c6185fe..d4b7489d 100644 --- a/docs/EARNINGS_NOWCAST_PILOT.md +++ b/docs/EARNINGS_NOWCAST_PILOT.md @@ -38,6 +38,40 @@ make earnings-nowcast-prospective-plan OUTPUT_DIR=data/imports/earnings_nowcast Actuals and consensus files are required; reviewed signals are optional. The versioned schemas require source names, direct source references, fiscal periods, publication/retrieval timestamps, comparability definitions, and at least one supported metric. Exact duplicates, new rows, explicit append-only revisions, and unresolved conflicts are reported separately; a conflict makes the preview not packet-ready. Post-cutoff evidence is rejected. The prospective plan is scheduler-ready but does not fetch data or create its output directory. These commands never apply, overwrite, stage, commit, or push rows; there is intentionally no Earnings Nowcast apply command. +## SEC Quarterly Actuals Staging + +The read-only SEC staging command is `make earnings-nowcast-sec-actuals-stage TICKERS= OUTPUT_DIR= AS_OF=`. `OUTPUT_DIR` must be a new temporary/review directory or an existing directory marked by this generated stage; canonical `data/` and `data/imports/` paths and existing non-generated evidence directories are rejected. The command reports `automatic_apply=false`; it neither changes canonical evidence nor provides an apply path. The five-company pilot scope is NVDA, AMD, AVGO, MU, and QCOM, but a successful staging run is not real Nowcast coverage. + +Q1-Q3 lineage accepts only source-backed SEC Companyfacts duration facts with a 60-120 day quarter duration and a one-to-one fiscal identity/period-end mapping. Cumulative facts, ambiguous concepts, conflicting fiscal identities, and post-cutoff rows are rejected. Companyfacts EPS uses `companyfacts_split_basis_unverified`, so it cannot become EPS-ready without separate source-backed comparability. That sentinel is also withheld from Business Trend values, cohort EPS usability, and backtest target/benchmark math; Revenue remains independent. Q4 requires an explicit fiscal-Q4 result table and one period-end date in its selected value column; filing timestamps and submission report dates are not substitutes. Annual-minus-nine-month arithmetic, derived Q4 disclosures, guidance, and cross-exhibit metric combinations are prohibited. + +Historical actual evidence is append-only and cutoff-aware: later presentations remain separate revisions and never overwrite an earlier source-backed row. The stage summary surfaces accepted rows, rejected rows, missing Q4, direct source references, and detected fiscal-quarter continuity gaps; it never fabricates a missing period or metric. Revenue and EPS remain independent. EPS is withheld when its split-adjustment, share, operations, currency, or accounting basis cannot be kept within one source-backed comparable basis. + +Real pilot output remains `awaiting_point_in_time_consensus` until permitted historical consensus snapshots are available at each forecast cutoff. Numerical Beat/Miss probability remains `awaiting_calibration_evidence` until at least 100 valid out-of-sample events pass the calibration gates. + +## Cohort Readiness And Prospective Collection + +Use `make earnings-nowcast-cohort-readiness AS_OF=` to review NVDA, AMD, AVGO, MU, and QCOM as one evidence board. The board reports the latest actual period, next forecast period, Revenue/EPS history, explicit Q4 evidence, split-basis readiness, exact-period consensus count, backtest count, calibration count, state, blocker, and next action. It does not create a forecast. + +Use `make earnings-consensus-source-status` to inspect Alpha Vantage, FMP, Finnhub, and reviewed-CSV activation in deterministic order. A configured key is only `configured_unverified`; it is not evidence that historical snapshots, use rights, or comparable definitions are available. Current-only estimates remain `candidate_context_only` and cannot be used as reconstructed historical snapshots. + +Use `make earnings-consensus-collection-plan AS_OF=` for a weekly or pre-earnings plan and `make earnings-consensus-collection-status` for the append-only ledger status. The prospective record preserves snapshot identity, source reference, publication/retrieval cutoff, metric definitions, explicit revisions, and review state. Duplicate snapshots are rejected, cooldown is explicit, prior snapshots are never overwritten, and no automatic apply or readiness promotion exists. + +For prospective collection, start from `docs/templates/earnings_nowcast/prospective_consensus.csv` and set `COLLECTION_INPUT=`. Preview with `make earnings-consensus-collection-preview INPUT=$COLLECTION_INPUT AS_OF=` and retain the printed `preview_receipt`. Recording requires that exact unchanged input, ledger, cutoff, and mode: `CONFIRM_REVIEWED=1 make earnings-consensus-collection-record INPUT=$COLLECTION_INPUT AS_OF= PREVIEW_RECEIPT=`. The record command revalidates the complete saved ledger and proposed batch before mutation; a changed cutoff, input, ledger, or mode invalidates the receipt. It appends evidence only and does not create a forecast or promote readiness automatically. + +Preview keeps the append-only technical decision separate from commercial evidence. Each exact source is checked without aliases or composite-source inference; a populated Revenue value requires registered `revenue_consensus` scope and a populated EPS value independently requires `eps_consensus` scope. In ordinary research mode, the existing explicit reviewed append path remains available. In explicit Commercial Research mode, unapproved rights or any required missing scope blocks before the ledger or its parent directory is changed. The checked-in registry currently approves no prospective-consensus source or scope, so this guard does not unlock a real-company packet. + +Every saved ticker/period lineage must be one append-ordered root-to-leaf chain. Invalid rows, duplicate snapshot IDs or evidence identities, missing or cross-period parents, multiple roots, forks, cycles, reversed append order, and non-increasing revision timestamps fail closed with row or scope context. A proposed revision may supersede only the current leaf. This deterministic integrity check is not a concurrent-writer lock, database transaction, or crash-recovery guarantee. + +The upstream source-row validator uses the same evidence separation before a row reaches collection review. Callers cannot supply a rights label: the exact provider ID is joined to the checked-in registry, and each technically accepted row reports only the scopes required by its populated Revenue and EPS values. Technically invalid rows stay rejected without entering commercial-ready counts; current-only rows remain candidate context, and valid point-in-time rows are `historical_evidence_reviewable`, not activated evidence. This review result cannot collect, append, approve a payload, establish historical depth, or change nowcast, backtest, or calibration readiness. + +Both consensus paths now derive that exact-source rights-and-scope decision from one immutable helper. Ordered required and missing metric fields stay inspectable, blank or duplicate requirements fail locally, and composite source IDs remain unknown rather than being expanded. Each consumer still owns its technical status, blocker wording, and write decision. The shared result is registry metadata, not evidence that a payload, timestamp, comparability definition, reviewer decision, historical snapshot, or readiness gate is valid. + +Before collection preview, set `SOURCE_INPUT=` and review the upstream export with `make earnings-consensus-source-review INPUT=$SOURCE_INPUT PROVIDER= AS_OF=`. This read-only command requires the explicit provider and cutoff, rejects missing/duplicate headers and extra row values, preserves one-based row evidence, and renders technical, temporal, rights, and populated Revenue/EPS scope states in human or JSON form. Every result retains `auto_apply=false`; even a historical-reviewable, commercially complete row is not collected or activated. `SOURCE_INPUT` and `COLLECTION_INPUT` are distinct input contracts: the former requires explicit `history_scope`; the latter uses the existing prospective collection schema with `review_state`. Only after separate human payload/evidence review and explicit evidence-preserving mapping should collection preview run. The product performs no automatic mapping or file write, and recording remains a later explicitly confirmed mutation. + +That source-row review also requires an explicit UTC cutoff and a literal `current_only` or `point_in_time` scope. Both candidate and historical rows must satisfy `snapshot_at <= retrieved_at <= review_cutoff`; unknown scope, reversed timestamps, or post-cutoff evidence are rejected before rights/scope review. Cutoff passage proves only timestamp ordering against the supplied boundary. It does not prove publication availability, payload correctness, source permission, freshness, collection, activation, backtesting, or calibration. + +Multi-row preview and record use the same ordered virtual-ledger preflight. Each technically reviewable row becomes visible to the next proposed row in input order, so a valid explicit revision can reference an earlier row in the reviewed file while duplicates, reversed revisions, and same-period conflicts fail before record. Any deterministic technical blocker rejects the whole batch in every mode; any commercial-evidence blocker rejects the whole batch in explicit Commercial Research mode. Record then uses one append handle only after the applicable batch gate passes. This prevents known later rejections from causing partial input writes, but it is not concurrent-writer locking or crash-safe filesystem transactionality. + ## Signals Company news, industry indicators, macro evidence, and trusted peer earnings may provide directional explanation. Candidate peers remain `candidate_context_only`. Trusted signals require reviewed source evidence and can move the lane to `signal_context_ready`; they cannot mutate Revenue/EPS ranges or create a numeric adjustment. @@ -54,9 +88,12 @@ Run: ```bash FIXTURE=1 make earnings-nowcast-pilot TICKER=SYN1 AS_OF=2026-01-31T23:59:59Z +FIXTURE=1 make earnings-nowcast-readiness TICKER=SYN1 AS_OF=2026-01-31T23:59:59Z make earnings-nowcast-walkthrough AS_OF=2026-01-31T23:59:59Z ``` The `SYN1`-`SYN5` cohort and `SYN5-BACKTEST` walkthrough label are synthetic test evidence only. The walkthrough demonstrates baseline ready, Revenue ready with EPS withheld, candidate-peer-only context, post-cutoff blocking, non-company exclusion, and backtest-insufficient/un-calibrated behavior. It proves deterministic contracts, readiness, ranges, signal separation, CLI output, and withholding behavior. It does not prove real-company coverage, current data, predictive accuracy, or investability. +The fixture readiness command uses a schema-valid synthetic onboarding fixture and should report `baseline_ready`. The same readiness command without `FIXTURE=1` reads local real-data onboarding rows and must remain blocked until source-backed point-in-time consensus and actuals pass validation. + Real semiconductor coverage is `awaiting_point_in_time_consensus`. The next legitimate data step is a narrow append-only cohort with licensed or otherwise permitted historical consensus snapshots and source-backed actuals. Generated packets remain local and unstaged unless an exact artifact is intentionally reviewed. diff --git a/docs/HOSTED_DEMO_DEPLOYMENT.md b/docs/HOSTED_DEMO_DEPLOYMENT.md index b6a9804e..ba0717b3 100644 --- a/docs/HOSTED_DEMO_DEPLOYMENT.md +++ b/docs/HOSTED_DEMO_DEPLOYMENT.md @@ -17,6 +17,12 @@ make hosted-demo-readiness This command is read-only. It checks the root Streamlit entrypoint, runtime dependency manifest, hosted URL boundary, provider-secret boundary, and public verification commands. It does not deploy, open accounts, print secrets, refresh data, stage files, commit, or push. +## Private Beta Is A Separate External Gate + +The public demo deployment path does not provide private-beta authentication, private workspaces, per-user data separation, entitlements, audit retention, monitoring, or health checks. These remain `external_account_required` until an external hosting, identity, persistence, and monitoring setup has been configured and independently verified. + +Use [Private Beta Architecture](PRIVATE_BETA_ARCHITECTURE.md) for the required external setup sequence and the read-only local classification. A declared setup becomes `manual_verification_required`; it is not evidence that accounts, hosting, or user isolation are live. Keep all real credentials outside Git and do not claim private-beta availability before supervised verification with real test accounts. + ## Hosted URL Marker The repository includes `config/hosted_demo.env.example` as a blank handoff template for a future hosted app URL. Keep the real marker local and untracked: @@ -98,6 +104,16 @@ If the hosted deployment changes app startup, route behavior, or copy, also revi Home -> Stock Selector -> Single-Stock Report -> Data Health -> Proof History +For a controlled Commercial Research Beta, also verify: + +Research Desk -> Discover -> Company Workbench -> Monitor + +Run the same research performance contract against the verified base URL: + +```bash +make commercial-beta-performance-gate BASE_URL= +``` + Stop before publishing the hosted link if the first public view shows tracebacks, raw tables before the answer, command-heavy copy, missing research-only boundaries, or unavailable-provider claims. ## Post-Deploy Smoke Checklist @@ -111,6 +127,19 @@ Run this before replacing the GitHub link in README, LinkedIn, or portfolio copy 5. Open `/?mode=public&page=proof-history` and confirm proof history is evidence-only before raw ledger details. 6. Keep the hosted link private if any route shows a traceback, operator mode by default, stale data-freshness claims, or a missing research-only stop rule. +## Health Check And Rollback + +Before admitting reviewers, verify `/_stcore/health`, open every public and +research route above at desktop and phone widths, and confirm the host uses the +tracked `demo` profile. Keep the previous verified revision available as the +rollback target. If startup, route rendering, access control, secrets handling, +or timing fails, remove reviewer access, restore the previous verified +revision, and keep GitHub as the share link while the issue is investigated. + +Without a real host, the correct status is `external_account_required`. Do not claim private or authenticated access until a real host enforces and has been +tested for that boundary. A healthy URL alone does not prove authentication, +per-user isolation, data rights, monitoring, or reviewer success. + ## Link Decision Ladder Use this ladder before changing README, LinkedIn, or portfolio copy: diff --git a/docs/LINKEDIN_PROJECT_BRIEF.md b/docs/LINKEDIN_PROJECT_BRIEF.md index 99615a3a..e5bd453f 100644 --- a/docs/LINKEDIN_PROJECT_BRIEF.md +++ b/docs/LINKEDIN_PROJECT_BRIEF.md @@ -2,34 +2,35 @@ ## Short Version -I built a local, CSV-first stock research command center that checks whether a ticker has enough trusted data before showing deeper analysis. The main idea is simple: data readiness first, analysis second, research decision last. +I built a local Python and Streamlit portfolio beta for evidence-first company research. Its primary workflow is Research Desk -> Discover -> Company Workbench -> Monitor: choose a reviewable company, see what evidence can be used now, keep unsupported conclusions withheld, and monitor source-backed changes. -The dashboard and single-stock reports show what can be reviewed now, what is blocked by missing data, what is excluded because the method does not apply, and which trusted local input would unlock the next layer. +The product separates the main research answer from technical evidence. Company Workbench brings business trend, valuation, forward context, uncertainty, conclusion, and the next research task into one review surface; Data Health and Proof History remain available when the missing input or provenance is the question. -Best demos: `NVDA` for DCF-ready company review, `META` for valuation still gated by trusted fundamentals, `QQQ` for ETF/index monitor context, `MU` for standalone DCF with peer valuation still locked, and `CRDO` for a fundamentals-gated proof workflow. +The main idea remains simple: data readiness first, analysis second, research decision last. Empty or unverified valuation, catalyst, outcome, consensus, and calibration lanes stay withheld rather than displaying fabricated content. -Best first click: open the dashboard preview, then follow Home -> Stock Selector -> Single-Stock Report -> Data Health -> Proof History. +Best first click for the complete product: run the local app and follow Research Desk -> Discover -> Company Workbench -> Monitor. The shorter controlled Public demo remains Home -> Stock Selector -> Single-Stock Report -> Data Health -> Proof History. Two-minute external review path: -- GitHub-only review: start with the preview image, the five-page workflow map, and `docs/PUBLIC_DEMO_WALKTHROUGH.md`. +- GitHub-only review: start with the Workbench answer preview, the four-step Personal Research workflow, and `README.md`. - Controlled reviewer handoff: send `docs/PILOT_REVIEW_INVITATION.md` for one under-three-minute workflow review before sharing the detailed feedback template. -- Live dashboard review: run `make dashboard`, open `http://localhost:8501/?mode=public`, then follow Home -> Stock Selector -> Single-Stock Report -> Data Health -> Proof History. +- Live dashboard review: run `make dashboard`, open `http://localhost:8501/`, then follow Research Desk -> Discover -> Company Workbench -> Monitor. Use `http://localhost:8501/?mode=public` for the five-page controlled demo. - Hosted app status: no public hosted Streamlit URL is configured yet; share the GitHub project, curated screenshots, and local run instructions unless you separately deploy the app and complete `docs/HOSTED_DEMO_DEPLOYMENT.md`. -- Evidence boundary: `docs/assets/linkedin-public-dashboard.png` and screenshots show product UI only; `make status-check TOP_N=5` remains the source for current local counts. +- Evidence boundary: `docs/assets/linkedin-public-dashboard.png` is a real Workbench answer-first screenshot; screenshots are product evidence only. Do not publish data-readiness claims from the image. - Share boundary: controlled portfolio/demo evidence only, not open-source reuse, investment advice, broker integration, or data-freshness proof. - Coverage boundary: price coverage is broad, but fundamentals, share count, peer mapping, earnings, and analyst estimates still have blocked or locked areas. The product shows those gaps instead of treating them as complete. - Provider boundary: FMP, Alpha Vantage, and Finnhub are optional local provider fallbacks and are not configured by default. Do not claim full automated fundamentals, estimates, or provider-backed coverage unless a reviewed source path proves it. +- Link boundary: use the stable GitHub repository link only after this reviewed feature reaches the default branch. Until then, keep an existing stable Featured item or label any non-default review link `Draft engineering preview`. -Each public page now opens with one question, one short answer, one primary next action, and one stop rule. The public workflow is checked at desktop and mobile widths. The current-page shortcut is visible so visitors know where they are. +Each research destination opens with a usable answer, a withheld boundary, or a truthful wait state before Advanced Evidence. The four-step workflow and the five-page Public demo are checked at desktop and mobile widths. -Current review status is `review_limited`: deterministic public checks and repeated local cold/warm route timings pass, while external reviewer evidence and hosted-route verification are still unavailable. This is a truthful controlled-demo status, not a hosted-product or data-freshness claim. +Current review status is `share_review_ready` for the local GitHub/demo workflow: deterministic public checks, browser evidence, and repeated local cold/warm route timings pass. External reviewer evidence and hosted-route verification are still unavailable, so this remains a controlled-demo status, not a hosted-product or data-freshness claim. -The first story is the public workflow, not operator automation. Keep reviewed batch packets, provider setup, and validate / preview / apply mechanics as operator detail after the visitor understands the product. +The first story is company research, not operator automation. Keep reviewed batch packets, provider setup, validate / preview / apply mechanics, and provider-key activation as operator detail after the visitor understands the product. Operator details stay collapsed until someone intentionally leaves the public path. -Use the refreshed `docs/assets/linkedin-public-dashboard.png` thumbnail for the LinkedIn Featured card. +Use the reviewed `docs/assets/linkedin-public-dashboard.png` Workbench thumbnail for the LinkedIn Featured card only after the feature is present at the link target. ## Now / Next / Not Yet @@ -45,26 +46,24 @@ If someone asks what to do next, run `make next-stage` before opening operator p ## Suggested LinkedIn Post -I built a local Python and Streamlit stock research command center around one principle: +I built a local Python and Streamlit Stock Research Command Center around one principle: Data readiness first. Analysis second. Research decision last. -Instead of jumping straight to rankings, the project checks whether each ticker has enough trusted local data for the analysis being shown: price, momentum, liquidity, fundamentals, DCF inputs, peer context, earnings, and analyst estimates. +The core workflow is Research Desk -> Discover -> Company Workbench -> Monitor. It helps me choose a reviewable company, bring business trend, valuation, forward evidence, uncertainty, and the next research task into one place, then monitor only source-backed changes. -What I like most about the product is that missing data stays visible. If a ticker is not ready for DCF, peer comparison, earnings context, or analyst-estimate context, the dashboard says why and shows the next local proof step. If a method does not apply, such as operating-company DCF for an ETF/index proxy, the report labels it as excluded instead of failed. +What matters most is what the product refuses to invent. If actuals, consensus, valuation, catalysts, outcomes, backtesting, or calibration evidence is unavailable, the relevant conclusion stays withheld and the missing proof remains visible. Technical provenance stays under Advanced Evidence unless it is needed to understand the research answer. What it includes: -- A Streamlit command center dashboard. -- Market-wide readiness checks across a broad ticker universe. -- Single-stock Markdown reports with At A Glance, Reader Guide, Evaluation Snapshot, Proof Checklist, and Best Review Path sections. -- DCF-ready, standalone DCF, price/setup-only, monitor-only, and data-needed-before-analysis report modes. -- Source readiness notes and copyable local proof commands. -- Lane-level readiness operations and reviewed batch packets for capped, proof-first data work. -- CSV-first import, validation, preview, rejected-row, and readiness workflows. +- Research Desk for a bounded weekly research queue. +- Discover for readiness-backed company selection without a buy ranking. +- Company Workbench for an answer-first one-company review. +- Monitor for source-backed evidence changes and truthful wait states. +- Advanced Data Health and Proof History for provenance and missing-input review. - Research-only guardrails: no broker integration, no order routing, no auto-trading, and no direct buy/sell instructions. -The most important design choice was refusing to present every ticker as complete. The product is useful because it refuses to overclaim: ready data can be analyzed, blocked data is explained, and missing rows are treated as the next proof step. +This is a local portfolio beta, not a hosted product or market-validated service. The project demonstrates how I approach research workflow, evidence quality, product boundaries, and fail-closed decision support. GitHub: https://github.com/YuzeJ21/Stock-Analysis @@ -72,11 +71,11 @@ GitHub: https://github.com/YuzeJ21/Stock-Analysis LinkedIn Featured title: -`Stock Research Command Center | Readiness-First Stock Research Workflow` +`Stock Research Command Center | Evidence-First Company Research` LinkedIn Featured description: -`A Python + Streamlit portfolio project that checks stock data readiness before showing analysis. It separates ready, partial, blocked, and excluded states, keeps source-proof gaps visible, and stays research-only with no broker integration, no auto-trading, and no investment advice.` +`A local Python + Streamlit portfolio project for evidence-first company research. Research Desk -> Discover -> Company Workbench -> Monitor shows what can be used now, what remains withheld, and why. Research-only; no broker integration, auto-trading, or investment advice.` GitHub About description: @@ -92,7 +91,7 @@ About-section sentence: Optional LinkedIn post: -`I have been building a Stock Research Command Center as a portfolio project. The core idea is simple: data readiness first, analysis second, research decision last. The app checks whether stock data is ready before showing analysis, separates ready / partial / blocked / excluded states, and keeps source-proof gaps visible when inputs are missing. It is built with Python and Streamlit, and stays research-only: no broker integration, no auto-trading, no order routing, and no investment advice. The public share is a GitHub demo with real product screenshots and local run instructions, not a claim that every coverage lane is complete. This project reflects the kind of data-product work I care about: turning messy coverage, quality, and workflow problems into a guided user experience.` +`I have been building a Stock Research Command Center as a local Python and Streamlit portfolio project. The core workflow is Research Desk -> Discover -> Company Workbench -> Monitor: select a reviewable company, see the evidence that can be used now, keep unsupported conclusions withheld, and monitor verified changes. The app stays research-only with no broker integration, auto-trading, order routing, or investment advice. It is a GitHub demo with real product screenshots and local run instructions, not a hosted-product, complete-coverage, or market-validation claim.` What not to claim: @@ -102,21 +101,21 @@ What not to claim: - Do not imply there is a public hosted app link until a hosted Streamlit deployment exists and has passed the public share gates. - Do not imply FMP, Alpha Vantage, or Finnhub provider fallbacks are configured unless local keys are actually set and a reviewed one-ticker smoke has passed. -Featured thumbnail: use `docs/assets/linkedin-public-dashboard.png`. It is a real product screenshot of the public visitor path; use `make status-check TOP_N=5` for current local readiness counts because screenshot counts can become stale after local refresh/import work. Keep `docs/assets/operator-data-health-metrics-real.jpg` only for deeper operator-mode discussion. The plain GitHub URL card can use GitHub's generated OpenGraph image, so use LinkedIn Featured when you want the curated product screenshot. +Featured thumbnail: use `docs/assets/linkedin-public-dashboard.png`. It is a real Workbench answer-first screenshot showing one selected-company answer, usable and withheld evidence, the Data Health handoff, and the stop condition without publishing changing coverage figures. Keep `docs/assets/operator-data-health-metrics-real.jpg` only for deeper operator-mode discussion. The plain GitHub URL card can use GitHub's generated OpenGraph image, so use LinkedIn Featured when you want the curated product screenshot. LinkedIn can cache older GitHub preview images. If the Featured card still shows the old screenshot after GitHub is pushed, remove and re-add the Featured link, or refresh the URL through LinkedIn's post inspector before adding it again. ## Final LinkedIn Visual Checklist -Use this after GitHub is synced: +Use this after the reviewed feature is present at the link target: -1. Open your LinkedIn profile and confirm the Featured card title matches `Stock Research Command Center | Readiness-First Stock Research Workflow`. -2. Confirm the Featured card description says the project is research-only and mentions no broker integration, auto-trading, or investment advice. -3. Confirm the Featured image is `docs/assets/linkedin-public-dashboard.png` when you want the curated product screenshot. +1. Open your LinkedIn profile and confirm the Featured card title matches `Stock Research Command Center | Evidence-First Company Research`. +2. Confirm the Featured card description names Research Desk -> Discover -> Company Workbench -> Monitor and keeps the research-only, no-broker, no-auto-trading, and no-investment-advice boundaries. +3. Confirm the Featured image is the reviewed Workbench answer-first screenshot at `docs/assets/linkedin-public-dashboard.png`. 4. If LinkedIn shows a generated GitHub URL card instead, leave it only if you are okay with GitHub's OpenGraph image; otherwise remove and re-add the Featured item with the curated screenshot. -5. Confirm the link target is GitHub unless you have intentionally deployed a hosted Streamlit app. +5. Use the stable GitHub repository link only after this feature reaches the default branch. Until then, keep the stable item or label any non-default review link `Draft engineering preview`. 6. Open the GitHub link from LinkedIn and confirm the README starts with `External Reviewer Start Here` and names the GitHub/local-app boundary before deeper operator detail. -7. Stop before claiming screenshots prove current data freshness, coverage completion, provider-key activation, or investment advice. +7. Stop before claiming screenshots prove current data freshness, changing coverage figures, provider-key activation, hosting, market validation, or investment advice. ## Resume Bullet Options diff --git a/docs/METHODOLOGY.md b/docs/METHODOLOGY.md index d3d927e2..1a360198 100644 --- a/docs/METHODOLOGY.md +++ b/docs/METHODOLOGY.md @@ -15,6 +15,32 @@ Current public method version: **Methodology v1 - readiness-first deterministic This method is useful for a controlled research/demo product because it makes the data gate visible before analysis. It is not a complete valuation terminal, not investment advice, and not a recommendation engine. +### Readiness Promotion Evidence Review + +When saved readiness is stale, `make readiness-preview TOP_N=20` runs the production readiness logic in memory and keeps the proposed technical state separate from the evidence needed to review a rebuild. False-to-true fundamentals and DCF changes are checked against the exact canonical source value, source/as-of/durable-reference provenance, the checked-in commercial-rights decision, and the registered field list for Revenue, free cash flow, FCF margin, and shares outstanding. + +Composite or unregistered source values remain unknown exact identifiers; the method does not split them or borrow the rights decision of one component. Numerical completeness can therefore produce a proposed technical promotion while the evidence review remains blocked. DCF also depends on price evidence, and the current canonical price rows do not establish row-level provider provenance, so the fundamentals review never claims complete DCF commercial provenance. The preview writes nothing, does not edit source rights, does not make saved readiness current, and cannot authorize the separately reviewed `make readiness` boundary. + +The independent `DCF Price Lineage Review` inspects only false-to-true DCF promotions. For each ticker it parses canonical dates and positive closes, selects the greatest observation date, and requires exactly one row at that date. It then keeps five states independent: proposed technical DCF promotion, unique latest-row selection, complete row-level `source`/`source_ref`/`retrieved_at` lineage, exact-source commercial rights, and registered `prices` field scope. A missing usable row or duplicate latest date fails closed; the review does not choose an arbitrary duplicate. + +An observation date is not a retrieval timestamp. `as_of_date`, the `local:prices.csv` file-origin label, file modification time, adapter presence, refresh warnings, and OHLCV shape cannot establish the original provider or durable row reference. The current no-write inspection finds one unambiguous usable latest row for all 146 proposed DCF promotions, but no promoted row has complete lineage, approved exact-source rights, or registered price scope because the canonical source identifier is absent. This identifies an evidence gap; it does not invalidate local research prices, change technical readiness, or authorize a historical schema rewrite or readiness rebuild. + +For future reviewed rows, the manual normalization path accepts explicit `source_ref` and `retrieved_at` metadata and preserves them through validation, preview, and a later separately authorized apply. Validation reports row-level lineage completeness independently from technical OHLCV validity. It never substitutes normalization time for retrieval time, and an invalid retrieval timestamp remains blank. Complete preservation still does not establish commercial rights, registered `prices` support, freshness, reviewer acceptance, or apply/rebuild authorization. + +Staged validation and preview also evaluate the exact retained `source` value against the checked-in registry. The rights decision uses the existing commercial-eligibility contract, while price scope requires literal `prices` membership in the same exact record. Approved, unverified, unknown, blank, and mixed batches remain explicit; composite values are not split and aliases are not inferred. Invalid technical rows are excluded from these counts. The joined review adds evidence states without changing technical validity, lineage completeness, merge counts, source rights, or apply/readiness authorization. + +Explicit Commercial Research mode then applies a pre-mutation conjunction: every valid staged row must have complete lineage, approved exact-source rights, and registered `prices` scope. Failure returns before backup or canonical write and lists the independent blockers. Research mode does not inherit this commercial license gate, so existing local research compatibility remains intact. A passing guard is necessary for the commercial mutation path but is not payload review, freshness proof, readiness promotion, or automatic apply authorization. + +Direct provider refresh uses the same exact-source commercial metadata boundary before it can fetch or mutate. Each concrete provider has one fixed source ID; CLI aliases cannot borrow another record's rights, and an automatic ladder reviews each reachable leg independently. Commercial Research mode filters or refuses unapproved and scope-incomplete legs before provider execution, then rechecks the exact selected provider before adding fetched rows or status output. A missing or changed identity fails closed. Research mode retains its existing provider order and local refresh behavior. This enforcement does not approve any provider, add row-level lineage to canonical history, validate a payload, or make readiness current. + +Focused-cohort saved-row coverage applies field scope per lane rather than reusing one source-level approval. Technical availability, source/reference provenance, exact-source commercial rights, and registered scope remain separate for margins, free cash flow, cash, debt, shares, filing dates, earnings dates, each populated Revenue/EPS consensus metric, and every trusted-peer relationship. Cash and debt can therefore be partial independently, and Revenue-only consensus permission cannot unlock EPS. A date-only consensus row is not evidence. Candidate peers remain context-only. Commercial blockers are shown in the collapsed Advanced cohort evidence; research mode retains its source-backed local behavior. + +Adjusted price history in the same Commercial cohort additionally requires saved price readiness and at least one technically usable canonical date/positive-close row. Every retained history row must carry exact `source`, durable `source_ref`, and explicit `retrieved_at`, and its exact source must independently pass approved rights plus registered `prices` scope. One missing or unapproved row blocks the history rather than borrowing another row's permission. Current canonical history has no row lineage, so it remains local research context rather than commercially supported cohort evidence. Retrieval chronology remains a separate audit boundary. + +Canonical quarterly Revenue and EPS coverage applies the same field-specific commercial conjunction to accepted actual rows before a technical trend packet can mark the cohort lane usable. Every populated row for the metric needs source/reference/retrieval provenance, approved exact-source commercial rights, and literal `revenue` or `eps` scope. Revenue and EPS are independent: SEC Companyfacts' registered Revenue scope cannot unlock EPS. A passing commercial display review does not prove split-basis comparability, Q4 evidence, revision integrity, loader completeness, readiness, or nowcast activation; Research mode retains the existing packet-only behavior. + +The same preview summarizes newly ready, partial, and excluded feature transitions plus added or removed ticker rows. New DCF exclusions use the exact existing company-scope method and report a deterministic primary reason: non-operating asset type, acquisition/SPAC, closed-end fund, bank/bancorp, financial/insurance/mortgage, REIT, realty-trust/BDC, capital corporation, or nonpositive-revenue margin model. Reasons explain method fit; they are not company-quality labels. Transition counts can overlap for one ticker and are not current readiness totals. + Use this quick model card before relying on a page or report: | Check | What to verify | Boundary | @@ -23,7 +49,7 @@ Use this quick model card before relying on a page or report: | Freshness by lane | Latest price date, latest fundamentals filing date, peer review date, optional-context review date, and proof-ledger date. | Screenshots and sample reports are product evidence only; they do not prove current data freshness. | | Provenance | Source, as-of date, reviewed/import status, and whether the row is source-backed, candidate context, blocked, skipped, or excluded. | Metadata, candidate peers, or provider availability do not substitute for fundamentals, share-count, peer valuation, earnings, or analyst-estimate proof. | | DCF assumptions | WACC, terminal growth, forecast years, growth caps, FCF margin caps, and any normalization warning. | DCF output is scenario math, not a price target or instruction. | -| Peer context | Candidate peers are separated from trusted peer mappings and mapped-peer valuation inputs. | Candidate peers can guide review, but they are not trusted peer proof. | +| Peer context | Candidate peers are separated from trusted relationships, result read-through, and valuation-anchor inputs. | Candidate peers can guide review, but they are not trusted peer proof. | | Public share boundary | Public screenshots, walkthroughs, and QA evidence show product behavior. | They do not unlock blocked inputs or prove today's market/fundamental data. | ## Lane-Level Freshness Policy @@ -38,6 +64,8 @@ Freshness is evaluated per lane because a daily price series, an annual filing, | --- | --- | --- | | Price / momentum | Check the latest saved trading date when the project is opened; a daily after-close refresh is appropriate when current setup context is needed. | Show the latest date and short-history state. Do not imply intraday or real-time coverage, and do not fabricate missing OHLCV history. | | Fundamentals | Review after a verified quarterly or annual filing/source event. | Keep the filing/as-of date visible. A newer filing makes the prior row `review_due`; missing trusted fields remain blocked. | +| Quarterly business trend | Review only from a fully parseable canonical ledger of explicit, versioned quarterly actual rows available by the selected cutoff. | Any rejected canonical row blocks the complete dashboard trend/cohort packet; row-numbered reasons stay under Advanced. Revenue and EPS comparisons require compatible metric definitions and matching periods. Missing comparisons remain partial; ambiguous revisions and absent evidence remain blocked. Q4 is never derived from annual values. | +| Optional valuation, catalyst, and outcome evidence | Research-mode technical review remains separate. Commercial Research composition additionally reviews every used row against its exact source ID and literal `valuation_history`, `catalyst_evidence`, or `research_outcomes` registry scope. | One unknown, unapproved, or scope-incomplete used row blocks the supported/reviewed packet. Candidate catalyst context cannot satisfy supported evidence. Empty ledgers stay empty; blocker details stay under Advanced. | | Share count | Review after a validated capital-structure filing or explicit share-count fact. | Do not infer shares from price or market cap. Missing explicit evidence remains blocked even when metadata is current. | | DCF | Regenerate when a required fundamental, share-count, cash/debt input, price reference, or methodology version changes. | DCF inherits the weakest required input state. Stale or missing required inputs withhold current interpretation rather than silently reusing a prior conclusion. | | Trusted peers | Version and periodically review accepted relationships, and review again after a material business-model or segment change. | Missing source, rationale, reviewer, review date, or mapped-peer valuation inputs keeps the relationship candidate-only or blocked. | @@ -45,6 +73,24 @@ Freshness is evaluated per lane because a daily price series, an annual filing, A timestamp cannot turn an unsupported row into trusted evidence. Fresh metadata, screenshots, provider availability, or candidate context cannot substitute for the field-level source proof required by a readiness gate. +Quarterly business trend is descriptive and backward-looking. Sequential and year-over-year changes do not become a forecast, recommendation, or numerical adjustment to DCF or Earnings Nowcast. Revenue, EPS, operating margin, free cash flow, and FCF margin keep independent `ready`, `partial`, `blocked`, or `withheld` states. + +### Evidence-Integrity Valuation And Nowcast Backtest Method + +Historical valuation observations reject non-finite valuation inputs, require a canonical real `YYYY-MM-DD` denominator period end, reject blank, malformed, and non-calendar denominator period ends, and reject post-cutoff retrieval evidence. + +Walk-forward Nowcast target actuals and prior-year benchmarks canonicalize Revenue/EPS independently through explicit `supersedes_source_ref` lineage, retain one event per ticker/period, and withhold ambiguous leaves per metric so one metric does not suppress the other; they use cutoff-bounded prior-year benchmarks so post-cutoff revisions cannot leak. + +The quarterly cash-generation contract derives operating margin as reported operating income divided by compatible quarterly Revenue, free cash flow as **cash from operations + reported capital expenditures** while preserving the source-reported capital-expenditure sign, and FCF margin as derived free cash flow divided by compatible quarterly Revenue. Inputs must match currency, scale, accounting basis, duration basis, fiscal period, and period end. Cross-quarter comparisons use the stable accounting definition rather than treating different period-end dates as different measurement definitions. Missing, ambiguous, post-cutoff, zero-denominator, or incompatible inputs block only the affected metric. The contract is in-memory and descriptive; it does not write a source row, modify a canonical dataset, or promote DCF, Earnings Nowcast, peer, catalyst, outcome, backtest, or calibration readiness. + +The one-company adapter acceptance harness composes that derivation with the immutable commercial source-rights registry. It requires one ticker, one matching source ID, explicitly approved commercial use, explicit support for operating income, cash from operations, and capital expenditures, no unresolved cutoff/revision/component/compatibility blockers, and at least one period with all three derived metrics. Its only success state is a local review-routing state: **accepted_for_review is not production activation**. A passing candidate still requires the actual source payload, rights review, and human review before any production caller may supply it. + +The bounded SEC pilot now supplies that source-review evidence for NVIDIA Q1 FY2027 and AMD Q1 FY2026. It selects Revenue, operating income, cash from operations, and capital expenditures from one exact Companyfacts accession and three-month context, requires the matching SEC submissions `acceptanceDateTime`, and matches concept, context, magnitude, and inline-fact identity against the exact filed 10-Q. Companyfacts exposes the capex magnitude without a cash-flow sign, so the pilot records a negative capex observation only when the matching filed-table value is explicitly displayed as an outflow; that proof state is `explicit_filed_table_outflow`. Identical repeated inline disclosures may collapse to the first document fact, but conflicting Companyfacts values, contexts, accessions, primary documents, magnitudes, or timestamps block the complete preview. The read-only result remains `accepted_for_review`; it does not persist a row, activate Company Workbench, rebuild readiness, or establish another company or quarter. + +The AMD Q1 FY2026 accession `0000002488-26-000076` result proves bounded two-company portability for the same exact-filing method: one immutable identity, three official SEC endpoints, compatible quarterly components, timezone-aware acceptance, explicit filed-table capex outflow, exact rights scope, and complete withholding. It does not prove broad company coverage, and no third company is inferred from cohort membership. + +The explicit Company Workbench cash-generation preview composes that accepted result for one bounded route and fixed review cutoff. It applies **complete withholding** on any required metric, identity, rights, timing, accession, capex-sign, or compatibility failure: no partial numeric card or component value may leak into the primary answer. When accepted, the primary answer shows the three descriptive metrics with a preview state, never `ready`; **Advanced-only technical lineage** retains source references, accession, timestamps, definitions, component values, and blockers. This composition is read-only, makes no deterministic forecast change, and leaves the normal canonical Workbench route unchanged. + ## Profile Truth And Change Review Method The dashboard resolves exactly one data profile for each run: `Demo`, `Local Research`, or `Default`. Its trust strip separates: @@ -69,6 +115,18 @@ Preview validates one prospective entry without writing. Recording requires expl Confidence describes the reviewer's confidence in the documented hypothesis at that timestamp. It does not measure expected return, allocation size, or transaction direction. An empty journal is `not_started`; a thesis without a source-backed invalidation condition is `incomplete`; and a passed review date is `overdue`. +## Research Decision Lab Method + +The Research Decision Lab is a read-only composition of the selected-profile report, Thesis Journal, Decision-Process Scorecard, Research Outcome Review, and unresolved source-backed change items. Its review loop is: + +```text +Research plan -> evidence -> invalidation -> scenario -> review trigger -> learning +``` + +The six lanes are derived independently: `Plan` reflects only reviewer-authored thesis state; `Evidence` reflects only reviewed supporting or conflicting evidence; `Invalidation` requires an explicit source-backed invalidation condition; `Scenario` follows existing DCF readiness and visible-assumption gates; `Review trigger` follows unresolved evidence changes or reviewer-authored review dates; and `Learning` follows reviewed outcome evidence and its exact-source commercial-rights decision. Decision Lab lanes remain independent: a documented thesis cannot unlock Scenario, reviewed Learning cannot clear an Evidence conflict, and current readiness cannot create journal history. + +The next process step follows a deterministic documentation order: unresolved conflicting evidence, overdue review, missing thesis, missing invalidation, missing evidence, unscheduled review, DCF-ready but invisible assumptions, then continued monitoring. This order is not company attractiveness, expected return, portfolio priority, or a transaction signal. Monitor preserves the existing focused-cohort order and never reorders companies by process severity or market value. + ## Public Workflow Boundary The public page order is a reading workflow, not an analysis shortcut. @@ -226,6 +284,7 @@ Peer analysis is separate from standalone DCF. - Missing peer mappings block the mapping proof path; mapped peers with missing price, fundamentals, market cap, or valuation fields block the peer valuation inputs proof path. - Sector or industry fallback context, if shown, must be labeled as fallback and not trusted manual peer data. - Contextual earnings read-through additionally requires a source-backed peer actual and explicit target/peer fiscal periods. Calendar proximity or business similarity is not inferred as period comparability. +- Peer valuation uses a stricter independent gate. A relationship may anchor peer medians only when it has source and as-of provenance, a reviewed `core_peer` or `secondary_peer` role, a relationship rationale, an economic comparability basis, and explicit `valuation_anchor_eligible=yes`. Aspirational, negative, excluded-close, and not-clean roles remain context-only. Legacy mappings without these fields stay visible as relationship context but cannot enter peer medians. This prevents the report from pretending that peer valuation exists when only partial peer data is available. @@ -342,7 +401,7 @@ The product uses the same readiness proof ladder in the dashboard, single-stock | --- | --- | --- | --- | | 1. Prices | Trusted local price rows. | Price/setup review, trend context, basic risk context when enough history exists. | Fundamentals, DCF, peers, earnings, and estimates. | | 2. Fundamentals / DCF inputs | Trusted company fundamentals with revenue, free cash flow or FCF margin, shares outstanding, and source metadata. | Fundamental field review and standalone DCF assumptions, scenarios, sensitivity, and fair value/share math. | Peer-relative valuation and optional earnings/estimate context. | -| 3. Source-backed peers | Trusted peer mappings first, then mapped-peer price, fundamentals, market cap, and valuation inputs. | Peer trend context first, then peer-relative valuation only when mapped-peer valuation inputs pass readiness. | Peer premium/discount or peer DCF comparison when peer mappings or mapped-peer inputs are incomplete. | +| 3. Source-backed peers | Trusted peer mappings first, then explicit peer role, economic comparability and valuation-anchor review, then mapped-peer price, fundamentals, market cap, and valuation inputs. | Peer trend context first, then peer-relative valuation only when at least two eligible anchors and their mapped-peer valuation inputs pass readiness. | Peer premium/discount or peer DCF comparison when roles, comparability, anchor decisions, mappings, or mapped-peer inputs are incomplete. | | 4. Optional context | Trusted earnings and analyst-estimate CSV rows. | Earnings timing context and analyst-estimate context. | Optional sections remain unavailable when those rows are missing. | Each step is permission to review a specific analysis layer, not permission to invent the next layer. Price-ready does not mean fundamentals-ready. Fundamentals-ready does not mean DCF-ready unless all required DCF fields pass. DCF-ready does not mean peer-ready. Peer-ready does not mean earnings or analyst estimates are available. @@ -358,17 +417,113 @@ The safe local sequence is: This ladder is why empty or partial outputs are useful: they show the first trustworthy proof step instead of hiding the gap behind a weak conclusion. +### Point-in-Time Universe Validation + +Implemented locally: read-only immutable-package status/preview with ten independent states: manifest, technical, temporal, identity, membership, corporate action, delisting, source rights, reproduction, and leakage. + +Completed review and remediation chronology is preserved in +`docs/internal/POINT_IN_TIME_UNIVERSE_REVIEW_HISTORY.md`. Priority 4 remains +externally incomplete; this methodology retains the current validation, +resource, reproduction, and evidence boundaries. + +Local resource budgets for one supplied package: preview sample 100 rows; manifest 1 MiB; each contract CSV 32 MiB; four contract snapshots combined 64 MiB; source-rights registry 4 MiB; declared rows 250,000 per contract; package traversal 32 entries. + +Every source-bearing raw evidence row is reviewed independently for exact +source rights even when technical parsing fails. Walk-forward minimum history +uses only distinct, otherwise qualifying evaluations for the same universe +strictly before the classified cutoff. Identity, membership, and event evidence +requires publication no later than retrieval. Snapshot inputs must be regular +files and are read from one descriptor with at most the declared limit plus one +byte retained for overflow detection. Deeply nested manifest JSON or +source-rights YAML fails with a stable readable input error instead of a +traceback. Duplicate JSON/YAML mapping keys and malformed contract headers +also fail nonzero, traceback-free, and write-free through the direct validator +and CLI/Make boundaries. + +These local bounds do not prove scale, hosted reliability, or market readiness. + +No permitted independently reviewed real dataset, accepted expected count/digest, or source-rights proof is on record. + +The deterministic reproduction contract is `membership_count_and_sha256_at_cutoff_v1`. It binds each declared universe and evaluation cutoff to the eligible membership count and SHA-256 digest without falling back to current constituents or ticker identity. + +The only operating entries for this method are +`make point-in-time-universe-status MANIFEST=` and +`make point-in-time-universe-preview MANIFEST= TOP_N=20`. Both are +read-only local inspection: they do not fetch a provider, write normalized or +rejected rows, apply data, rebuild readiness, or activate analysis. + +Synthetic fixtures remain test-only and local software evidence cannot complete Priority 4. + +Priority 4 remains open until one bounded permitted real dataset is independently reviewed, reproduces the expected membership count and digest, and passes rights, identity, corporate-action, delisting, survivorship, cutoff, partition, reproduction, and leakage gates. + +This local evidence does not change independent readiness for actuals, consensus, Revenue, EPS, valuation, catalysts, outcomes, backtesting, or calibration. + +It does not provide investment advice; numerical probability remains unavailable without calibration; Q4 evidence and EPS split-basis compatibility remain explicit; synthetic evidence stays test-only; candidate peer evidence remains candidate-context-only. + +The synchronized Priority 4 local validator is frozen unless a newly +reproduced Critical or Important defect requires reopening it. One bounded +permitted real dataset remains the exact external exit gate. While that +dataset and the independent Priority 5 source and peer evidence are +unavailable, Priority 6's provider-neutral authorization contract is complete +locally; hosted implementation remains environment-dependent. Priority 7 +accessibility remediation is the next safe executable local lane. Local +contracts cannot prove hosted authentication, isolation, audit storage, +retention, monitoring, rollback, or operated capacity. + +If a permitted dataset or its rights evidence is unavailable, record that +external dependency and its exact unblock condition once. Do not retry an +unavailable provider or infer source access, field scope, rights, independent +review, or real-data validation from the local package result. + ## 12. Earnings Nowcast Pilot Method The Earnings Nowcast pilot is a separate readiness-gated lane. It does not reuse generic optional-context readiness and does not let an earnings date, current target price, candidate peer, or provider availability unlock a forecast. The deterministic baseline requires at least five source-backed prior quarterly actuals, the matching prior-year fiscal quarter, and an exact forecast-period point-in-time consensus snapshot available at the forecast cutoff. Fiscal-period rows are canonicalized before history is counted: exact duplicates count once, an explicit source reference can supersede a prior row, and unresolved conflicting values block only the affected metric. Revenue and EPS are evaluated independently and must use comparable currency, unit scale, accounting basis, EPS share/operations basis, and split treatment. A stable Revenue history may produce a range while incompatible or unstable EPS remains withheld. Each snapshot records the fiscal period, cutoff, expected report date, forecast horizon, model version, immutable input hash, freshness state, and source IDs. +SEC actual staging has a narrower primary-source lineage rule. Q1-Q3 accepts only 60-120 day SEC Companyfacts duration facts with a one-to-one fiscal identity/period-end mapping; cumulative facts and comparative facts without a source-backed original identity are rejected. Companyfacts EPS is explicitly marked `companyfacts_split_basis_unverified`, which cannot match an `as_reported` or split-adjusted consensus definition. Q4 is accepted only from an explicit fiscal-Q4 result table in a SEC-filed exhibit whose selected value column states one period-end date; filing timestamps and submission report dates are not substitutes. It is never derived by annual-minus-nine-month arithmetic, and guidance, ambiguous headers, or Q4 metrics spanning separate exhibits remain withheld. Evidence is append-only and cutoff-aware, so complete revision chains select the latest source-backed presentation without replacing historical evidence. The stage reports source-backed quarter-continuity gaps without inventing a missing fiscal period or value. Revenue and EPS readiness stay separate; EPS is withheld whenever split-adjustment, share, operations, accounting, currency, or scale basis is not source-backed and comparable across its usable history. The same sentinel is enforced downstream: Business Trend never displays or compares its EPS value, commercial cohort scope cannot override it, and backtesting cannot use it as a target or prior-year EPS outcome. Revenue evaluation remains independent. + +Filed-Q4 split basis has its own fail-closed proof boundary. Missing or malformed primary split language records `primary_split_basis_unverified`, not `as_reported`. Both unverified sentinels fail the shared downstream predicate, and arbitrary nonempty basis text is not treated as proof. Only a supported declared basis token or an explicitly parsed dated split statement can unlock EPS comparability; Revenue remains independent. + The model combines recent sequential behavior and same-quarter year-over-year seasonality with fixed versioned weights. It emits ranges and a `higher`, `aligned`, or `lower` consensus-relative classification. It does not accept a text-generated numeric adjustment. Peer earnings, company news, industry indicators, and macro evidence are directional context only; candidate peers remain `candidate_context_only`, while a reviewed trusted source may raise the context state without changing the numerical baseline. -Historical evaluation is chronological walk-forward only. The target actual and later consensus snapshots are excluded from model inputs and used only for scoring after the forecast. Fewer than 20 valid events is `backtest_insufficient`, not backtest-ready. Reports separate valid events from exclusions, group exclusion reasons, and include Revenue/EPS error, WAPE where valid, Revenue/EPS/joint interval coverage, directional accuracy, leakage failures, and latest-consensus/prior-year benchmarks. Numerical Beat/Miss probability stays unavailable until at least 100 valid out-of-sample observations pass finite-value, Brier-score, calibration-bin, and constant-rate benchmark gates. Every populated calibration bin reports its size, mean forecast probability, observed rate, and minimum-size status. +Historical evaluation is chronological walk-forward only. The target actual and later consensus snapshots are excluded from model inputs and used only for scoring after the forecast. A consensus row retrieved on or after the target report is a leakage failure even when its stated snapshot time is earlier. Conflicting rows at the same latest snapshot timestamp are excluded as an ambiguous revision, and a snapshot more than 90 days before the target report is excluded as stale. Fewer than 20 valid events is `backtest_insufficient`, not backtest-ready. Reports separate valid events from exclusions, group exclusion reasons, and include Revenue/EPS error, WAPE where valid, Revenue/EPS/joint interval coverage, directional accuracy, leakage failures, and latest-consensus/prior-year benchmarks. A sample that meets the event minimum but fails to improve the latest-consensus Revenue or EPS benchmark fails the validation verdict with an explicit gate. Numerical Beat/Miss probability stays unavailable until at least 100 valid out-of-sample observations pass finite-value, Brier-score, calibration-bin, and constant-rate benchmark gates. Every populated calibration bin reports its size, mean forecast probability, observed rate, and minimum-size status. + +The committed `SYN1`-`SYN5` cohort is synthetic test evidence only. The intended five-company SEC staging scope is NVDA, AMD, AVGO, MU, and QCOM, but real pilot output remains `awaiting_point_in_time_consensus` until exact historical consensus evidence is present. This proves neither real semiconductor coverage, predictive accuracy, nor data freshness. The pilot does not predict post-earnings price movement and does not provide investment advice. + +The cohort readiness board reports actual-history, Q4, split-basis, exact-period consensus, backtest, and calibration gates independently. Prospective collection preserves immutable snapshots and revisions; it does not reconstruct historical consensus from a current estimate. Provider configuration is an access state, not an evidence or rights state. + +Prospective collection preview also evaluates the exact declared source against the checked-in rights registry without splitting composite identifiers or inferring aliases. Technical `write_allowed` remains the append-only review decision. Commercial evidence separately requires approved rights plus literal `revenue_consensus` support when Revenue is populated and literal `eps_consensus` support when EPS is populated. Explicit Commercial Research mode conjuncts these states before a write and returns before filesystem mutation when either gate is incomplete; research mode retains its explicit reviewed local path. Passing this guard does not prove the estimate payload, point-in-time history, freshness, comparability, reviewer acceptance, calibration, or nowcast readiness. + +The earlier source-row validation boundary follows the same independence rule. Its exact normalized provider is resolved through the checked-in registry rather than a caller-declared rights label. Schema, fiscal-period, timestamp, value, and comparability failures determine technical rejection; only technically accepted rows enter commercial counts. Each accepted row requires `revenue_consensus` and `eps_consensus` scope only for the metrics it actually populates. Composite identifiers remain one unknown exact source, and `historical_evidence_reviewable` is a routing state rather than evidence activation or a readiness promotion. + +Collection preview and source-row validation share one pure commercial field-scope function. For one exact source ID it combines the existing rights decision with the literal ordered fields required by populated metrics; empty or duplicate field names are invalid, and unknown or composite IDs receive no alias expansion. This centralization changes no technical acceptance, blocker vocabulary, collection rule, or readiness state. Registry metadata cannot establish payload correctness, timestamp integrity, metric comparability, reviewer approval, collection, activation, backtesting, or calibration, and non-consensus evidence domains keep their own contracts. + +The supported source-review command is the read-only operating boundary before collection preview: `make earnings-consensus-source-review INPUT= PROVIDER= AS_OF=`. It reads the supplied upstream export without field enrichment, requires explicit provider identity and cutoff, and routes its ordered row mappings through the same validator. Human and JSON output expose rejected rows, candidate versus historical scope, rights, and metric scope; a completed review returns evidence even when the state is blocked. Invocation or ambiguous CSV-shape errors fail nonzero. The source-review export and prospective collection row are distinct input contracts; an accepted source row must be separately reviewed and explicitly mapped into the prospective schema, and the product neither infers fields nor writes that mapping. Neither outcome collects a row, changes readiness, or establishes activation. + +Pull-request engineering hygiene is range-based, not working-tree-based. The hosted event supplies exact base/head SHAs, checkout fetches their history, generated-artifact classification reads `BASE...HEAD`, and whitespace checks the same range. A clean checkout is therefore not treated as evidence that the PR changes no files. This automation is read-only engineering evidence, not source review, user validation, or merge approval. + +Temporal validation is fail-closed for both source categories. The caller supplies one explicit UTC review cutoff, each row declares exactly `current_only` or `point_in_time`, and technical acceptance requires `snapshot_at <= retrieved_at <= review_cutoff`. Equality is allowed; missing/unknown scope, reversed ordering, invalid UTC timestamps, and post-cutoff evidence are rejected before a commercial row is counted. The cutoff does not infer publication time or prove provider availability, rights, payload correctness, freshness, collection, nowcast readiness, backtesting, or calibration. + +Batch collection preserves input order and evaluates each row against an in-memory virtual ledger containing saved evidence plus earlier technically reviewable proposed rows. Technical and commercial batch decisions remain separate. An empty input or any technical blocker rejects the complete record operation; explicit Commercial Research mode additionally requires every row's commercial evidence to pass. The CLI preview exposes the same row states and ordered blockers used by record, so a deterministic later rejection cannot leave earlier proposed rows appended. No row is reordered or repaired. This contract does not claim concurrent-writer exclusion or crash-safe transactionality. + +The saved prospective ledger is itself an input to that decision and is revalidated in full before status, preview, or record. Every row must satisfy the schema, snapshot IDs and evidence identities must be unique, and each ticker/period must form exactly one append-ordered, timestamp-increasing root-to-current-leaf revision chain. Preview produces a deterministic receipt bound to the normalized cutoff, Commercial Research mode, complete proposed batch, and complete saved ledger. Record requires the same cutoff and receipt and recomputes the decision before mutation; any changed input or intervening ledger write requires a new review. The receipt is local integrity evidence only, not proof of source truth, rights, reviewer approval, process locking, or crash recovery. + +Daily price lineage uses one temporal rule across normalization, staged validation/preview/apply, and DCF promotion review. For observation date `D`, local review uses `D + 1 day 00:00 UTC` as the earliest conservative availability boundary. A declared retrieval must carry an explicit timezone offset, must not precede that boundary, and must not exceed the explicit timezone-aware review cutoff. This does not infer an exchange close or provider publication time. Missing retrieval remains an independent lineage gap; malformed, naive, too-early, post-cutoff, or cutoff-unreviewed retrieval cannot become trusted commercial evidence or enter a canonical apply. Apply writes the one previously validated in-memory staged frame through same-directory atomic replacement, without claiming concurrent locking or crash-safe transactionality. + +Historical valuation regime context is computed only from observations where the denominator was publicly available by the matching numerator timestamp. Each metric definition forms a separate segment, and fewer than eight compatible observations remains `insufficient_history`. The output is a descriptive range and percentile, never a cheap/expensive label or recommendation. + +Research outcome review is an append-only learning loop tied to an original thesis and a closed observation window. Catalyst evidence is a cutoff-safe timeline of reviewed events. Neither uses price returns, sentiment scoring, or narrative adjustments to grade the company, grade the researcher, or change a numerical model. + +### Forward View V1 + +Forward View is a deterministic composition of existing evidence, not a second forecast or valuation engine. It keeps five lanes separate: explicit quarterly Revenue/EPS trend, source-backed DCF bull/base/bear scenarios, trusted peer read-through, reviewer-authored thesis evidence, and the independently gated Earnings Outlook. + +Scenario values appear only when DCF readiness passes, source metadata retains both source and source reference, and all three bounded scenarios calculate from the same saved report. Stale profile evidence is labeled partial. Candidate peers, news context, generated narrative, and journal text cannot change Revenue, EPS, DCF assumptions, valuation scenarios, or probabilities. Numerical surprise probability remains governed by the Earnings Nowcast calibration gate. + +Every Forward View packet shows its source cutoff, saved-profile freshness, model version, withheld fields, and one next research task. It accepts the production stock-report provenance contract (`provider`, `retrieved_at`, `official`, freshness, and notes) or an explicit source/source-reference pair, and preserves that provenance under Advanced evidence. Any stale, mixed, missing, or unknown saved-profile freshness downgrades otherwise usable sections to review-due rather than presenting them as current. -The committed `SYN1`-`SYN5` cohort is synthetic test evidence only. It proves deterministic software behavior, not real semiconductor coverage, predictive accuracy, or data freshness. The pilot does not predict post-earnings price movement and does not provide investment advice. +The Earnings Outlook lane loads only a real source-backed packet for the exact fiscal period named by the selected report. It never silently chooses a different real-company forecast period, never displays synthetic fixture evidence in the workbench, and reads the independent `probability_available` calibration gate before changing probability wording. Missing or incompatible evidence fails closed. The result describes plausible assumption cases and research wait conditions; it does not predict post-earnings price direction, rank companies, or provide an investment recommendation. ## 13. Methodology Limits diff --git a/docs/OPERATOR_GUIDE.md b/docs/OPERATOR_GUIDE.md index 66063e5a..2b8755c8 100644 --- a/docs/OPERATOR_GUIDE.md +++ b/docs/OPERATOR_GUIDE.md @@ -127,6 +127,41 @@ The dashboard and single-stock report use plain modes before showing detailed ta ## Data Proof Workflows +### Prospective Per-Field Proof + +The Stage A ledger is prospective-only. It preserves one exact reviewed ticker/field identity and revision chain without treating older batch narratives as structured evidence: legacy narrative proof is not upgraded. No sample field-proof rows are checked in, and an absent ledger is a valid empty state. A present empty, header-only, malformed, forked, or otherwise invalid ledger fails closed. + +Inspect status first, then preview one separately supplied reviewed CSV: + +```bash +make prospective-field-proof-status +make prospective-field-proof-audit +make prospective-field-proof-preview INPUT= AS_OF= +``` + +These inspection commands are read-only. Preview reports `technical_write_eligible` and `commercial_evidence_eligible` independently, so research-mode technical recording cannot be mistaken for commercial permission. The preview receipt binds ledger, input, cutoff, commercial mode, and source-rights registry. Review the proposed identities, timestamps, revision chain, source reference, payload digest, reviewer disposition, rights decision, registered field scope, and both blocker lists before recording. + +The Stage B audit is also read-only. It explains append order, normalized scopes, revision numbers, current versus superseded rows, reviewer dispositions, active-head blocker categories, and latest review time. It reports `preview_receipt_persisted=false` and `receipt_revalidation_required=true`: save the exact stdout receipt with the reviewed input if a later explicit record is intended. Audit does not activate readiness, does not update canonical data, and does not activate Company Workbench. It creates no report or JSON file; `JSON=1` writes structured output to stdout only. + +Record only the exact reviewed preview: + +```bash +make prospective-field-proof-record INPUT= AS_OF= PREVIEW_RECEIPT= CONFIRM_REVIEWED=1 +``` + +Record is an explicit append. It repeats validation and rejects any changed ledger, input, cutoff, commercial mode, or source-rights registry. Recording uses cooperative local locking for writers that follow this protocol. It is not crash-safe, not a database transaction, and does not protect against writers that do not cooperate or replace the path outside the protocol. + +This primitive does not activate readiness, does not update canonical data, does not update proof-readiness reconciliation, and does not activate Company Workbench, dashboards, reports, or legacy ledgers. A proof identity does not establish payload truth, source rights, commercial eligibility, freshness, or reviewer independence. Any readiness, reconciliation, canonical, or Workbench mapping requires a separate design. + +Before reusing any historical `supported`, `auto_supported`, or `human_reviewed_supported` batch outcome as evidence for current work, reconcile it with current saved readiness: + +```bash +make proof-readiness-reconciliation TOP_N=20 +make proof-readiness-reconciliation TICKERS=ARCT TOP_N=20 +``` + +`historical_supported_currently_blocked` means the older proof outcome and the current saved state disagree. Current saved readiness remains authoritative: inspect fresh source evidence before restarting that lane. `explicit_ticker_change` means the latest supporting proof explicitly names the ticker in `changed_tickers`. Scope membership alone is not ticker-level support. `current_canonical_row_missing` and other current blocker codes describe current saved inputs. Current blocker diagnosis does not establish the historical cause. The command is read-only and does not restore canonical data, rewrite proof history, promote readiness, fetch a provider, validate payload truth, or prove source rights, field scope, provenance, or commercial use. Its counts describe the current local snapshot only. + Use targeted proof commands instead of broad refreshes by default: ```bash diff --git a/docs/PERFORMANCE_RELEASE_GATE.md b/docs/PERFORMANCE_RELEASE_GATE.md index 2d3514db..2316fc88 100644 --- a/docs/PERFORMANCE_RELEASE_GATE.md +++ b/docs/PERFORMANCE_RELEASE_GATE.md @@ -46,6 +46,37 @@ The release run used five warm runs and one server-cold run for every public rou Local verdict: **passed**. The result does not remove the separate hosted-preview and external-review gates. +## Commercial Beta Research Workflow Result + +The research workflow was measured on 2026-07-18 from immutable release-candidate +commit `e930bd0e1b1062c029a7633a226db8dbc03a506b` using the same tracked demo +snapshot and environment. The run covered Research Desk, Discover, Company +Workbench, and Monitor at both viewports with one cold and five warm samples: +48 recorded route samples, zero failures, and no horizontal overflow. + +| Route | Viewport | Shell p90 | First useful p90 | Warm full p90 | Cold full | +| --- | --- | ---: | ---: | ---: | ---: | +| Research Desk | 1280x720 | 0.238s | 1.934s | 2.137s | 2.881s | +| Research Desk | 390x844 | 0.202s | 1.991s | 2.153s | 2.875s | +| Discover | 1280x720 | 0.211s | 1.864s | 2.444s | 2.768s | +| Discover | 390x844 | 0.208s | 1.842s | 2.474s | 2.964s | +| Company Workbench | 1280x720 | 0.224s | 1.783s | 2.856s | 3.162s | +| Company Workbench | 390x844 | 0.216s | 1.812s | 2.797s | 3.218s | +| Monitor | 1280x720 | 0.208s | 1.962s | 2.213s | 2.826s | +| Monitor | 390x844 | 0.231s | 1.811s | 2.246s | 2.679s | + +Reproduce the research contract and browser evidence with: + +```bash +make commercial-beta-performance-contract +make commercial-beta-performance-gate +``` + +The generated evidence path is +`/tmp/stock-command-center-commercial-beta-performance.json`. Keep it out of +Git. This local result does not prove hosted performance, external-user task +success, licensed broad data operation, or predictive accuracy. + ## Reproduce ```bash diff --git a/docs/PERSONAL_RESEARCH_MODE.md b/docs/PERSONAL_RESEARCH_MODE.md new file mode 100644 index 00000000..5224204a --- /dev/null +++ b/docs/PERSONAL_RESEARCH_MODE.md @@ -0,0 +1,96 @@ +# Personal Research Mode + +Personal Research is the default local workspace for repeated company review. It composes existing readiness, Single-Stock Report, Change Monitor, Thesis Journal, Scenario Lab, peer-context, freshness, and Earnings Outlook capabilities. It does not introduce a second calculation or data-persistence system. + +## Workflow + +1. **Research Desk** starts with a traceable weekly summary, what changed, what is blocked or stale, what to review next, and the Discover action. Deterministic cohort scope, concise lane coverage, and full matrices remain under collapsed Advanced Evidence. +2. **Discover** starts with the readiness-backed Stock Selector, limits it to the focused cohort, and opens one company directly in Company Workbench. Cohort scope and lane-coverage context remain available under a collapsed Advanced section after the selection task. +3. **Company Workbench** starts with the selected company's compact usable/withheld answer, then follows `What Changed -> Research Decision Lab -> Business Trend` before valuation, forward context, withheld inputs, conclusion, and one authoritative next research task. The Decision Lab's next process step remains separate. Technical lane-coverage and Decision Lab evidence remain under collapsed Advanced sections. +4. **Monitor** follows `Weekly Research Summary -> Research Discipline Review -> Research change monitor`. The discipline table preserves focused-cohort order and shows process state, due lanes, and one next process step without grading or ranking companies. An empty source-change queue remains a neutral wait state with one Discover action, not proof that nothing changed. Five-company Earnings Nowcast readiness remains under collapsed Advanced evidence. + +Company Workbench arbitrates one overall next task. Its change context is explicitly `none`, `snapshot_only`, or `source_backed`, so an empty queue receives a neutral no-queued-change badge and is never mislabeled as snapshot evidence. Only a change carrying separate explicit source-backed eligibility can win: open review keeps the event's suggested task, `still_blocked` keeps its `wait_for_evidence` condition, and `intentionally_deferred` keeps its `monitor` condition. Snapshot-only context receives no source-backed badge and cannot outrank the existing ordered Research Conclusion priority. Forward View guidance stays lane-specific and does not compete for the overall task. This presentation leaves readiness and evidence states independent and unchanged. + +Data Health and Proof History remain available through **Advanced Evidence**. Operator mode remains the place for source setup, validation, preview, proof, and maintenance commands. Public mode retains the controlled five-page demonstration. + +Data Health and Proof History stay inside Personal Research mode when opened from Company Workbench Advanced Evidence. Both preserve the selected ticker and show a direct **Return to Company Workbench** action before evidence content; a missing ticker returns to Research Desk instead of inventing a company. This navigation does not change readiness or evidence state, record a review outcome, expose Operator commands, or promote blocked inputs. + +## Research States + +Personal Research may route work only as: + +- `review_now`: verified evidence changed or a reviewed task is open. +- `monitor`: no immediate evidence task is available. +- `wait_for_evidence`: required source proof or freshness is unavailable. +- `excluded`: the analysis is not applicable. + +These are workflow states. They are not rankings, expected-return claims, investment recommendations, or transaction instructions. + +## Research Decision Lab + +Company Workbench shows exactly one compact six-lane research-process summary for Plan, Evidence, Invalidation, Scenario, Review trigger, and Learning after `What Changed`. It composes saved evidence only and cannot replace `Use now`, `Still withheld`, the Data Health handoff, Research Conclusion, or Next Research Task. Identities, evidence summaries, and technical details remain under `Advanced: Decision Lab evidence`. + +Monitor derives the same contract independently for each saved focused-cohort ticker. One invalid ticker becomes unavailable without changing another ticker, and display order remains the existing cohort order rather than process severity, market value, or expected return. If nothing is due, the truthful answer is: **No process item is currently due from saved reviewer-authored evidence.** This does not claim that no market event, risk, or external research need exists. Source-change state stays separate in the later Research change monitor. + +## Truth Boundaries + +- Missing trend, valuation, peer, earnings, estimate, or nowcast inputs stay unavailable rather than inferred. +- Candidate peers and news context cannot become trusted peer proof or modify numerical forecasts. +- Source-backed peer relationships do not automatically become valuation anchors. Peer role, economic comparability, and anchor eligibility are reviewed independently; legacy or context-only rows are withheld from peer medians. +- Numerical Beat/Miss probability remains withheld without calibration evidence. +- Scenarios remain bounded, session-local assumption tests and do not change canonical data. +- The Change Monitor and Thesis Journal do not mutate readiness or source rows. +- Broad universe tracking does not imply broad analysis readiness. + +## Focused-Use Strategy + +The current saved profile deterministically selects up to 25 eligible operating companies or ADRs with price-ready evidence. Active-universe and deeper ready lanes affect review order only; they do not create a score, expected return, or recommendation. If fewer than 25 eligible companies exist, the cohort reports `awaiting_reviewed_source` and is never padded. + +## Focused Cohort Coverage + +Research Desk composes a read-only coverage matrix for every focused-cohort company. The matrix separates adjusted daily price history, quarterly Revenue, quarterly EPS, margins, free cash flow, cash/debt, shares outstanding, trusted peers, filing dates, earnings dates, and exact-period point-in-time consensus. Each lane is labeled `usable_now`, `partial`, `candidate_context_only`, `blocked`, or `excluded` from saved source evidence only. The weekly summary and four direct research answers render first; concise cohort cards and the full company-by-lane matrix remain under Advanced Evidence. + +Closing Advanced Evidence does not remove or combine cohort states. A DCF-ready flag does not fabricate quarterly actuals, earnings dates, or consensus. Candidate peers do not become trusted peers. Missing source provenance remains blocked, and non-company rows remain excluded rather than forced through operating-company analysis. + +In Company Workbench, the Peer Read-Through Map answers result-context and valuation-anchor questions separately. `core_peer` and `secondary_peer` are the only roles that can become anchors, and only with explicit source, as-of date, relationship rationale, comparability basis, and `valuation_anchor_eligible=yes`. Aspirational, negative, excluded-close, not-clean, candidate, and legacy-unreviewed relationships remain visible context without entering peer medians. + +Discover uses the same deterministic cohort but keeps the primary company-selection task first. Its search control and compact readiness-backed rows render before the collapsed cohort context, so a researcher can choose a company without reading technical coverage evidence. This order changes no cohort membership, readiness state, ranking, or Company Workbench route. + +Company Workbench keeps the existing selected-ticker coverage calculation but moves its cards under `Advanced: selected-company lane coverage`. The unchanged report renderer supplies the first expanded company answer and continues to show supported evidence, withheld inputs, source-backed changes, uncertainty, and one next task. Closing the lane cards does not infer coverage, combine readiness states, or unlock a report section. + +Monitor also carries one compact five-company Earnings Nowcast evidence answer. Company Workbench keeps historical valuation regime, catalyst evidence, and research outcome learning inside the existing Valuation, Forward View, and Thesis Journal sequence. Their raw rows stay under Advanced. These helpers do not add a route, refresh a provider, apply an import, or change canonical readiness. + +Historical valuation is descriptive only and requires a denominator that was public at the matching price timestamp. Research outcomes preserve reviewed learning without return attribution or skill scoring. Catalyst events require source and cutoff timestamps and cannot change forecasts, valuation inputs, readiness, or recommendations. + +Header-only templates live at `docs/templates/historical_valuation_observations.csv`, `docs/templates/research_outcome_reviews.csv`, and `docs/templates/catalyst_evidence.csv`. Outcome and catalyst inputs must be previewed before the explicit `CONFIRM_REVIEWED=1` record command. The canonical ledgers remain local working evidence unless one exact artifact is intentionally reviewed for a controlled package. + +The weekly summary is derived from deduplicated, source-backed Change Monitor events from the prior seven days plus reviewer-authored journal review dates. It writes no data and does not turn a missing event into a no-change claim. + +Quarterly business trend is descriptive evidence, not a forecast. Revenue and EPS comparisons require explicit versioned quarterly actuals with compatible definitions. Operating margin, free cash flow, and FCF margin now have a separate in-memory evidence contract and independent states, but production values remain withheld until a **reviewed quarterly source adapter** supplies explicit compatible observations. Sequential and year-over-year changes are withheld when matching periods or definitions are unavailable. Q4 is never derived from annual results; every Q4 component requires explicit filed-quarter evidence. Primary cards show the research answer only; component values, formulas, and source references stay in the collapsed Advanced quarterly evidence table. The local acceptance harness evaluates in-memory candidates only: **no adapter file is loaded or written**, and an accepted candidate is not supplied to Company Workbench or promoted into readiness. + +## Mobile First-Action Density + +At phone width, the compact profile context keeps Data profile, Sources through, Freshness, Price-ready, and DCF-ready visible in two rows. The route card omits only its duplicate freshness row while preserving the page, selected scope or ticker, next action, and research-only boundary. Discover therefore shows its search task and first review row sooner; Monitor shows the weekly state sooner; and Company Workbench shows `Selected Company`, a collapsed `Review path`, and the first-read answer without the full sequence consuming the viewport. + +This mobile first-action density improvement does not change readiness, source dates, coverage, evidence, forecasts, valuation, or research conclusions. Desktop profile and route metadata remain unchanged. Company Workbench keeps the complete review path available through the same collapsed disclosure at desktop and phone widths. + +## SEC Quarterly Cash-Generation Pilot + +The read-only NVIDIA Q1 FY2027 pilot proves that one exact SEC accession can supply compatible Revenue, operating income, cash from operations, and explicitly signed capital expenditures for adapter review. Run `make sec-quarterly-cash-preview AS_OF=` only when `SEC_USER_AGENT` is configured. The command fetches three exact SEC endpoints in memory and writes no cache or generated artifact. + +Its success state is deliberately narrow: **accepted_for_review is not production activation**. Company Workbench continues to withhold real-company operating margin, free cash flow, and FCF margin until a separate activation review connects compatible observations. The pilot does not change saved readiness, Earnings Nowcast, consensus, valuation, catalysts, outcomes, backtesting, calibration, or another company or quarter. + +## Explicit Company Workbench Cash-Generation Preview + +The bounded route `?mode=research&page=company-workbench&ticker=NVDA&open=1&cash_preview=1` adds one explicit, read-only **Cash-generation review preview — not production evidence** below the canonical Business Trend answer. The fixed review cutoff is `2026-07-20T23:59:59-04:00`. The normal Company Workbench route does not call the preview loader and continues to show only canonical production evidence and its existing withheld states. + +AMD Q1 FY2026 accession `0000002488-26-000076` is the second exact filing available through the explicit `cash_preview=1` route. NVIDIA and AMD now provide bounded two-company portability through the same immutable loader, complete-withholding contract, preview-only cards, and Advanced lineage. Normal Company Workbench routes remain canonical and never load the preview. + +The preview is all-or-nothing: operating margin, free cash flow, and FCF margin appear only when an accepted NVIDIA Q1 FY2027 or AMD Q1 FY2026 SEC evidence packet passes the complete identity, source-rights, cutoff, accession, acceptance-time, component, capex-sign, and compatibility contract. Any required failure withholds all three preview metrics. Accession, source URL, timestamps, component values, definitions, and blockers stay in the collapsed Advanced evidence section. The route writes no cache or canonical data, creates no readiness or generated artifact, and cannot promote any product lane. + +## Repeated Review Routine + +- **Daily or after a source refresh:** open Research Desk, review traceable changes, then use Monitor for unresolved source tasks. +- **Company review:** use Discover, open one Company Workbench, read What Changed, Business Trend, Valuation, Forward View, and What Remains Withheld before recording a conclusion. +- **Weekly:** review the weekly summary, overdue journal reviews, and wait conditions. A no-change summary means no traceable saved event in the review window, not that the company had no real-world change. +- **Operator handoff:** use Data Health or Proof History only when source proof, blocked inputs, or event evidence is the question. diff --git a/docs/PILOT_FEEDBACK_CLOSEOUT_CHECKLIST.md b/docs/PILOT_FEEDBACK_CLOSEOUT_CHECKLIST.md index c9b54bad..62faf3f2 100644 --- a/docs/PILOT_FEEDBACK_CLOSEOUT_CHECKLIST.md +++ b/docs/PILOT_FEEDBACK_CLOSEOUT_CHECKLIST.md @@ -1,9 +1,9 @@ # Pilot Feedback Closeout Checklist -Use this checklist after 5-10 external reviewers complete the public workflow: +Use this checklist after 10-20 independent target-persona reviewers complete the Commercial Research Beta workflow: ```text -Home -> Stock Selector -> Single-Stock Report -> Data Health -> Proof History +Research Desk -> Discover -> Company Workbench -> Monitor ``` This closeout is product-workflow evidence only. It is not data proof, source proof, data freshness proof, investment advice, broker integration, or a trade instruction. @@ -16,7 +16,7 @@ Keep the working feedback log outside the repository: cp docs/PILOT_REVIEW_FEEDBACK_LOG_TEMPLATE.csv /tmp/stock-command-center-pilot-feedback.csv ``` -Before any reviewed feedback artifact is committed, remove names, account details, investment opinions, price targets, trade decisions, portfolio information, and any other personal information. +Before any reviewed feedback artifact is committed, remove names, contact details, account details, investment opinions, price targets, trade decisions, portfolio information, and any other personal information. Delete rows with `consent_confirmed=no` or `consent_withdrawn=yes`. Delete remaining working rows on their `retention_delete_after` date unless they have become intentionally reviewed, anonymized aggregate evidence. ## 2. Classify Every Reviewer Row @@ -32,6 +32,12 @@ Each row should end in one of these product outcomes: Do not classify a reviewer preference as a source-proof defect unless it names a missing trusted source row, validate/preview/apply failure, or proof-ledger gap. +Before aggregating results, confirm that each included row records task success, +time to first useful answer, readiness comprehension, evidence tracing, +authoring friction, trust, misuse risk, perceived performance, repeat-use +intent, and the most important missing workflow. Missing fields remain missing; +do not infer or backfill reviewer answers. + ## 3. Fix Only Reproducible Product Issues Fix issues in this order: diff --git a/docs/PILOT_REVIEW_FEEDBACK_LOG_TEMPLATE.csv b/docs/PILOT_REVIEW_FEEDBACK_LOG_TEMPLATE.csv index b3ec5abc..fcc14ca0 100644 --- a/docs/PILOT_REVIEW_FEEDBACK_LOG_TEMPLATE.csv +++ b/docs/PILOT_REVIEW_FEEDBACK_LOG_TEMPLATE.csv @@ -1,2 +1,2 @@ -review_label,review_path,ticker_or_example,started_at,where_started,usable_now,blocked_or_excluded,next_expected_action,time_to_first_answer_minutes,confusion_point,reviewer_signal,closeout_outcome,task_success,moderator_help_required,readiness_comprehension,misuse_risk,trust_in_evidence,perceived_performance,repeat_use_case,reproducible_issue_route,reproducible_issue_viewport,reproducible_issue_observed,reproducible_issue_expected,reproducible_issue_impact,evidence_pointer,notes -R01,GitHub-only or local demo profile,NVDA or chosen route,YYYY-MM-DD,Home / README / other page,Reviewer words only,Reviewer words only,Reviewer words only,3,Exact page label or route if any,clear / confused / blocked_by_environment / suggestion_only,clear / reproducible_ui_issue / documentation_gap / environment_limited / intentionally_deferred,completed / completed_with_help / not_completed,none / one_prompt / multiple_prompts,correct / partial / incorrect,none / uncertain / mistook_as_recommendation_or_live_terminal,high / medium / low,responsive / noticeably_slow / appeared_frozen,Research-readiness task or none,,,,,,,No names account details investment opinions price targets trade decisions or portfolio information +review_label,review_path,ticker_or_example,started_at,consent_confirmed,consent_withdrawn,retention_delete_after,where_started,usable_now,blocked_or_excluded,next_expected_action,time_to_first_answer_minutes,confusion_point,reviewer_signal,closeout_outcome,task_success,moderator_help_required,readiness_comprehension,evidence_trace_result,authoring_friction,misuse_risk,trust_in_evidence,perceived_performance,repeat_use_case,repeat_use_intent,most_important_missing_workflow,reproducible_issue_route,reproducible_issue_viewport,reproducible_issue_observed,reproducible_issue_expected,reproducible_issue_impact,evidence_pointer,notes +R01,controlled research workspace or local demo profile,NVDA or chosen route,YYYY-MM-DD,yes / no,yes / no,YYYY-MM-DD,Research Desk / other page,Reviewer words only,Reviewer words only,Reviewer words only,3,Exact page label or route if any,clear / confused / blocked_by_environment / suggestion_only,clear / reproducible_ui_issue / documentation_gap / environment_limited / intentionally_deferred,completed / completed_with_help / not_completed,none / one_prompt / multiple_prompts,correct / partial / incorrect,completed / completed_with_help / not_completed,none / low / medium / high,none / uncertain / mistook_as_recommendation_or_live_terminal,high / medium / low,responsive / noticeably_slow / appeared_frozen,Research-readiness task or none,yes / maybe / no,Reviewer words only,,,,,,,No names account details investment opinions price targets trade decisions or portfolio information diff --git a/docs/PILOT_REVIEW_FEEDBACK_TEMPLATE.md b/docs/PILOT_REVIEW_FEEDBACK_TEMPLATE.md index d88063f5..2de7e696 100644 --- a/docs/PILOT_REVIEW_FEEDBACK_TEMPLATE.md +++ b/docs/PILOT_REVIEW_FEEDBACK_TEMPLATE.md @@ -1,23 +1,29 @@ # Controlled Pilot Review Feedback -Use this template with 5-10 external reviewers after they open the GitHub demo package or the local `demo` profile. It measures product clarity and workflow reliability only. It does not collect market data, validate a source, change readiness, or support an investment conclusion. +Use this template with 10-20 independent target-persona reviewers after they open a verified controlled delivery path or the local `demo` profile. It measures product clarity and workflow reliability only. Owner-led, automated, fixture, and screenshot sessions do not count. It does not collect market data, validate a source, change readiness, or support an investment conclusion. + +Before the session, tell the reviewer what will be observed, that participation is voluntary, that they may stop or withdraw their row, and when the working note will be deleted. Record only an anonymous session label and the minimum product feedback below. A withdrawn row must be deleted from the working log and must not appear in aggregate evidence. ## Reviewer Task -Ask the reviewer to follow the public path without operator instructions: +For the Commercial Research Beta, ask the reviewer to follow the current research path without operator instructions: ```text -Home -> Stock Selector -> Single-Stock Report -> Data Health -> Proof History +Research Desk -> Discover -> Company Workbench -> Monitor ``` -Ask them to use one example ticker and answer these four questions in their own words: +Ask them to use one example ticker and answer these eight questions in their own words: 1. Where did you start? 2. What could you use now? 3. What was blocked or excluded? -4. What would you do next? +4. What evidence supports one conclusion? +5. What was difficult to author or update? +6. What would you do next? +7. Would you use this workflow again for a real research task? +8. What is the most important missing workflow? -Do not coach the answers. The purpose is to see whether the public workflow is self-explanatory in under three minutes. +Do not coach the answers. The purpose is to see whether the research workflow is self-explanatory in under three minutes. ## Optional Task-Based Pilot @@ -39,18 +45,52 @@ Record the scorecard as workflow evidence only: | Misuse risk | `none`, `uncertain`, or `mistook_as_recommendation_or_live_terminal`. | | Trust in evidence | `high`, `medium`, or `low`, with a short reason about provenance or blocker clarity. | | Perceived performance | `responsive`, `noticeably_slow`, or `appeared_frozen`, with the affected route. | +| Evidence trace | `completed`, `completed_with_help`, or `not_completed` when tracing one conclusion to its source evidence. | +| Authoring friction | `none`, `low`, `medium`, or `high`, with the affected record or step. | | Repeat-use case | Short research-readiness task they would repeat, or `none`. | +| Repeat-use intent | `yes`, `maybe`, or `no`, with an optional short reason. | +| Most important missing workflow | One reviewer-stated missing workflow, or `none`. | Do not ask whether the reviewer would buy or sell a security. Do not collect a price target, portfolio position, account information, or personal investment decision. +## Commercial Research Beta Tasks + +Use this sequence for the Commercial Research Beta after the reviewer can open +the controlled research workspace: + +```text +Research Desk -> Discover -> Company Workbench -> Monitor +``` + +1. Start at Research Desk. +2. Identify the focused cohort and freshness state. +3. Use Discover to select one reviewable company. +4. Open Company Workbench. +5. Explain what can be used now. +6. Identify one withheld input. +7. Review Business Trend, Valuation, and Forward View boundaries. +8. Use Monitor to determine whether verified evidence changed. +9. State why the product is research-only. + +Capture task success, time to first useful answer, readiness comprehension, +evidence tracing, authoring friction, misuse risk, evidence trust, +perceived performance, repeat-use case and intent, and the most important +missing workflow. Do not fabricate reviewer sessions, completion rates, quotes, or findings. +If no reviewer can access a verified delivery path, classify the +stage as `awaiting_external_review` rather than treating local test evidence as +pilot evidence. + ## Capture Sheet -Record one row per review session outside the repository unless the note contains no personal information. Use `docs/PILOT_REVIEW_FEEDBACK_LOG_TEMPLATE.csv` as the copy/paste header when you want comparable rows across 5-10 reviewers; keep the working copy outside Git unless it is intentionally anonymized review evidence. +Record one row per review session outside the repository unless the note contains no personal information. Use `docs/PILOT_REVIEW_FEEDBACK_LOG_TEMPLATE.csv` as the copy/paste header when you want comparable rows across 10-20 reviewers; keep the working copy outside Git unless it is intentionally anonymized review evidence. | Field | Record | | --- | --- | | Review label | Anonymous session label such as `R01`; do not use a full name. | -| Review path | GitHub-only or local demo profile. | +| Consent confirmed | `yes` only after the reviewer agrees to the session and minimal anonymous capture. A `no` session does not begin. | +| Consent withdrawn | `yes` requires deletion of the row; do not retain it as evidence. | +| Retention/delete after | The date by which the working row will be deleted unless it becomes intentionally reviewed, anonymized aggregate evidence. | +| Review path | Controlled research workspace or local demo profile. | | Ticker/example | The example ticker or route the reviewer chose. | | Where did you start? | Their first page or artifact opened. | | What could you use now? | Their description of supported analysis or context. | @@ -60,9 +100,9 @@ Record one row per review session outside the repository unless the note contain | Confusion point | Exact page, label, or route that caused confusion, if any. | | Reviewer signal | `clear`, `confused`, `blocked_by_environment`, or `suggestion_only`. | | Closeout outcome | `clear`, `reproducible_ui_issue`, `documentation_gap`, `environment_limited`, or `intentionally_deferred`. | -| Task scorecard | Record task success, moderator help, readiness comprehension, misuse risk, trust, perceived performance, and repeat-use case only when the optional task pilot was run. | +| Task scorecard | Record task success, moderator help, readiness comprehension, evidence trace, authoring friction, misuse risk, trust, perceived performance, repeat-use case and intent, and most important missing workflow only when the task pilot was run. | -Do not record names, account details, or investment opinions. Do not ask reviewers for a stock recommendation, price target, trade decision, or personal portfolio information. +Do not record names, account details, or investment opinions. Do not record contact details. Do not ask reviewers for a stock recommendation, price target, trade decision, or personal portfolio information. Delete withdrawn rows immediately and delete non-evidence working rows by their recorded retention date. ## Structured Log Template diff --git a/docs/PILOT_REVIEW_INVITATION.md b/docs/PILOT_REVIEW_INVITATION.md index 8817458c..5bbe1236 100644 --- a/docs/PILOT_REVIEW_INVITATION.md +++ b/docs/PILOT_REVIEW_INVITATION.md @@ -7,7 +7,7 @@ Thanks for reviewing the Stock Research Command Center. This is a controlled por Please spend under three minutes following this path without operator guidance: ```text -Home -> Stock Selector -> Single-Stock Report -> Data Health -> Proof History +Research Desk -> Discover -> Company Workbench -> Monitor ``` Use any example ticker offered by the app. As you go, answer in your own words: @@ -15,16 +15,22 @@ Use any example ticker offered by the app. As you go, answer in your own words: 1. Where did you start? 2. What could you use now? 3. What was blocked or excluded? -4. What would you do next? +4. What evidence supports one conclusion? +5. What was difficult to author or update? +6. What would you do next? +7. Would you use this workflow again? +8. What is the most important missing workflow? The product is successful only if you can see what is usable, what stays withheld, and the next safe page without treating the workflow as advice. ## What To Send Back -Reply with the four answers above, plus any exact page, label, route, or viewport that was confusing. A screenshot is useful only when it shows a reproducible product issue. +Reply with the answers above, plus any exact page, label, route, or viewport that was confusing. A screenshot is useful only when it shows a reproducible product issue. Do not send names, account details, investment opinions, price targets, trade decisions, or portfolio information. +Participation is voluntary. You may stop at any time or ask the operator to delete your anonymous row. Before starting, the operator must tell you what will be captured and the deletion date for the working note. + ## Boundaries - This feedback is product-workflow evidence only, not data proof, source proof, or data-freshness proof. diff --git a/docs/PILOT_RUNBOOK.md b/docs/PILOT_RUNBOOK.md index 2a865fc0..c768c705 100644 --- a/docs/PILOT_RUNBOOK.md +++ b/docs/PILOT_RUNBOOK.md @@ -114,7 +114,9 @@ make stock-report-md TICKER= Coverage gate: No broad coverage batch should run from setup alone. Provider setup is only an activation boundary: it can activate a source, but readiness changes still require validate, preview, rejected-row review, source provenance, apply/skip decision, rebuilt readiness, and proof ledger evidence. Do not retry exhausted proof queues until new source-backed rows, keyed provider data, reviewed manual rows, or changed blockers exist. -Use 5 to 10 operating companies for the controlled pilot. ETF/index examples such as QQQ and SMH are useful monitor-context demos, but they are not operating-company DCF targets. +Use 5 to 10 operating companies as the fixed research cohort and run 10 to 20 task-based reviewer sessions. ETF/index examples such as QQQ and SMH are useful monitor-context demos, but they are not operating-company DCF targets. + +Measure each session with the privacy-safe feedback template: **Task success**, **Time to first answer** (specifically, the first useful research answer), **Readiness comprehension**, **Evidence trace**, **Authoring friction**, **Misuse risk**, **Trust in evidence**, **Perceived performance**, **Repeat-use case and intent**, and **Most important missing workflow**. Before starting, confirm voluntary participation, withdrawal rights, minimal anonymous capture, and a working-note deletion date. These measures test workflow value and comprehension only; they do not validate market data, model accuracy, demand, or investment outcomes. ## 5. Refresh Data Safely @@ -189,9 +191,9 @@ make imports-validate IMPORT_TICKERS= make imports-preview IMPORT_TICKERS= ``` -For a 25-50 company peer pilot, keep the working source-review sheet outside Git until rows are reviewed. The template is a collection aid only: candidate/context rows stay `candidate_context_only`, and only source-backed relationships that pass `peer-mapping-writeback-guard`, validation, preview, rejected-row review, rebuilt readiness, and proof recording can become trusted peer mappings. +For a 25-50 company peer pilot, keep the working source-review sheet outside Git until rows are reviewed. The template is a collection aid only: candidate/context rows stay `candidate_context_only`, and only source-backed relationships with a reviewed peer role, relationship rationale, economic comparability basis, and explicit valuation-anchor decision that pass `peer-mapping-writeback-guard`, validation, preview, rejected-row review, rebuilt readiness, and proof recording can become trusted peer mappings. Only reviewed `core_peer` and `secondary_peer` rows marked `valuation_anchor_eligible=yes` may enter peer medians; all other roles remain context-only. -Use the extra template fields (`source_type`, `source_title`, `source_accessed_date`, and `source_evidence_note`) to keep reviewer evidence defensible before copy/paste into the import guard. They are review-only fields, not import columns. The guard still accepts only the trusted peer mapping fields needed for `data/imports/peers.csv`; do not bypass it by pasting the full review sheet into the import file. +Use `peer_role`, `relationship_rationale`, `comparability_basis`, and `valuation_anchor_eligible` as import fields so the reviewed decision survives into Company Workbench and valuation readiness. The extra source-document fields (`source_type`, `source_title`, `source_accessed_date`, and `source_evidence_note`) remain review-only. Do not bypass the guard by pasting the full review sheet into the import file. Earnings and analyst estimates: @@ -297,7 +299,7 @@ If `make dashboard-smoke` cannot bind a local socket in a restricted environment Exit the controlled pilot when: -- 5 to 10 selected operating-company packets have outcome states recorded as `supported`, `candidate_context_only`, `still_blocked`, `skipped`, or `excluded`. +- The fixed 5 to 10 operating-company cohort has truthful outcome states and 10 to 20 anonymous task-based reviewer sessions are completed or explicitly `awaiting_external_review`. - Every supported lane has source proof, validation, preview, rejected-row review, rebuilt readiness, regenerated report, and proof-ledger evidence. - Operators can complete the workflow from dashboard, runbook, and CLI commands without guessing the next gate. - Public/release checks pass in the target environment. diff --git a/docs/PRIVATE_BETA_ARCHITECTURE.md b/docs/PRIVATE_BETA_ARCHITECTURE.md new file mode 100644 index 00000000..3d71e69c --- /dev/null +++ b/docs/PRIVATE_BETA_ARCHITECTURE.md @@ -0,0 +1,74 @@ +# Private Beta Architecture + +## Boundary + +The repository contains a deterministic readiness contract for a future private beta. It does not implement runtime authentication, private workspaces, hosted persistence, user accounts, entitlements, audit storage, monitoring, health checks, incident response, rollback, owner capacity, or hosting. Real user evidence remains `awaiting_external_review`. + +The contract is available through `src.private_beta_readiness.build_private_beta_readiness()`. It accepts only declared review facts; it does not inspect environment variables, scan files, open accounts, contact a host, or read secret material. + +| Classification | Meaning | Required action | +| --- | --- | --- | +| `local_ready` | Repository-side guidance is present, while account-backed capabilities remain external. | Complete the external setup below. | +| `external_account_required` | An individual capability needs a real hosted account or service. | Do not represent the capability as available. | +| `external_operations_required` | An operating control needs a staffed hosted rehearsal, not only repository guidance. | Assign owners and rehearse the control in the actual environment before claiming it. | +| `manual_verification_required` | An external setup has been declared, but it has not been independently verified. | Verify the live behavior before any access or product claim. | +| `unsafe_secret_blocked` | A tracked or otherwise unsafe secret condition has been declared. | Remove it from tracked files, rotate it outside the repository, then repeat the review. | + +## Data Boundaries + +Private-beta data, when it exists, must be partitioned by authenticated user and workspace. Per-user watchlists, thesis journals, saved scenarios, audit events, and entitlement data belong in the external private-beta service, not in tracked repository files, public demo data, screenshots, generated reports, or logs. + +The future service must enforce workspace authorization server-side for every read and write. Client-side visibility, UI routing, or a workspace identifier alone does not establish isolation. Research evidence and readiness remain source-bound and research-only; private access does not turn incomplete data into a trusted input or an investment recommendation. + +Credentials, tokens, account identifiers, license documents, and secret values remain outside Git. The repository may carry only blank templates and variable names. Usage and error telemetry must exclude credentials and research-workspace contents unless a separately reviewed retention policy authorizes a minimal, documented record. + +## External Setup Sequence + +1. Create the chosen hosting, identity, and managed-persistence accounts outside this repository. +2. Configure an identity provider, an allowed-user invitation path, session expiry, and account recovery; verify them with non-production test accounts. +3. Create private workspaces and server-side authorization rules that scope watchlists, journals, scenarios, and uploaded evidence to the authenticated user and workspace. +4. Configure encrypted platform secret storage. Add only the required variable names, never secret values, to repository templates; rotate any credential exposed outside that store. +5. Configure audit events for sign-in, workspace membership, export, deletion, and entitlement changes. Define a retention and deletion policy before retaining beta-user data. +6. Configure role and entitlement rules, including revocation, and test that unauthorized users cannot read or modify another workspace. +7. Configure privacy-safe usage and error monitoring, alert routing, and health checks for the hosted entrypoint and backing services. +8. Define incident response severity, escalation, reviewer-access shutdown, evidence preservation, communication ownership, and recovery criteria. +9. Keep the previous verified revision available, rehearse rollback in the hosted environment, and record who can execute and verify recovery. +10. Confirm owner capacity for source failures, access incidents, reviewer support, and recovery; a named document without available coverage is not operating proof. +11. Run a supervised external verification with real test accounts. Record the outcome as `awaiting_external_review`, `manual_verification_required`, or another evidence-backed state; do not infer readiness from setup alone. + +## Local Contract + +### Provider-Neutral Authorization Policy + +`src.hosted_access_control.evaluate_workspace_access()` is a local, +provider-neutral, deny-by-default policy contract. It requires exact +authenticated-principal, active-membership, and workspace matches before an +explicit role/resource/action rule can allow a request. Thesis, evidence, +catalyst, and outcome research records remain append-only, and every allow or +deny result carries a privacy-safe audit obligation that a future approved +adapter must record. + +The evaluator performs no authentication, persistence, audit storage, +retention, monitoring, network, provider, dashboard, ledger, readiness, or +generated-artifact operation. This local contract does not prove hosted +authentication, private-workspace isolation in a deployed service, audit +storage, retention execution, monitoring, rollback, incident response, or +operated capacity. All such states remain external until directly verified in +the actual approved environment. + +The classifier reports authentication, workspaces, user data separation, secrets, audit, retention, entitlements, monitoring, and health checks independently from incident response, rollback, and owner capacity. It is intentionally read-only. + +A local runbook does not prove that an incident owner is available, rollback works on the host, or recovery can be staffed. Without a real rehearsal, those controls remain `external_operations_required`; declaring external setup moves them only to `manual_verification_required`. + +```python +from src.private_beta_readiness import build_private_beta_readiness + +readiness = build_private_beta_readiness() +assert readiness.classification == "local_ready" +``` + +Passing `external_setup_declared=True` produces `manual_verification_required`, not a hosted or authenticated claim. Passing `unsafe_secret_detected=True` produces `unsafe_secret_blocked`; pass only the declared condition, never a credential or secret value. + +## Non-Claims + +No current repository state proves that a private-beta host exists, authentication is live, user data is separated, retention works, entitlements are enforced, audit logs are stored, monitoring receives events, health checks run, incident response is staffed, rollback succeeds, or owner capacity is available. Do not present this contract as commercial launch readiness, hosting evidence, or user validation. diff --git a/docs/PRODUCT_DIRECTION_DECISION.md b/docs/PRODUCT_DIRECTION_DECISION.md index eff14b7c..bddc02d0 100644 --- a/docs/PRODUCT_DIRECTION_DECISION.md +++ b/docs/PRODUCT_DIRECTION_DECISION.md @@ -43,7 +43,7 @@ Evidence expected: | Criterion | Evidence required | Current state | | --- | --- | --- | -| Reviewer demand | Observed task completion, repeat-use case, and return intent from 5-10 controlled reviewers. | `awaiting_external_review` | +| Reviewer demand | Observed task completion, repeat-use case, and return intent from 10-20 controlled reviewers. | `awaiting_external_review` | | Operating burden | Measured deployment, refresh, review, incident, and support work. | Local gates known; hosted burden unknown. | | Data licensing | Written rights and attribution boundaries for every operated provider/source. | Controlled repo license exists; operated data rights are not established. | | Provider reliability | Repeated source availability, deterministic limits, rejection handling, and provenance. | Optional keyed providers remain unconfigured; source-proof queues are exhausted. | @@ -55,9 +55,10 @@ Evidence expected: - Local five-route desktop/mobile performance gate: passed on the fixed demo snapshot. - Public package: pilot-ready with manual gates. -- Hosted URL: `external_account_required`. +- Independent-beta protocol: locally ready for the current four-route Commercial Research Beta workflow, with a complete privacy-safe scorecard; no independent sessions are on record. +- Hosted URL and enforced private access: `external_account_required`. - Controlled reviewer findings: `awaiting_external_review`. -- Trusted-peer expansion: `awaiting_reviewed_source`. +- Trusted-peer expansion and any commercial-use peer rights: `awaiting_reviewed_source`. - Optional earnings and estimates: intentionally locked until trusted rows exist. ## Decision Rule @@ -67,8 +68,8 @@ Do not infer demand from repository activity, screenshots, test counts, or the c Revisit the decision only after: 1. A hosted or otherwise reproducible external delivery path is verified. -2. Five to ten reviewers complete the task-based pilot. +2. Ten to twenty reviewers complete the task-based pilot. 3. Critical trust, comprehension, performance, and methodology findings are closed or intentionally deferred. 4. A 25-50 company trusted-peer pilot has actual reviewed source relationships, or is explicitly rejected as uneconomic. -Until then, the truthful label is **performance-verified controlled research prototype, preparing for external pilot**. +Until then, the truthful label is **local Commercial Research Beta foundation, preparing for controlled external validation**. diff --git a/docs/PROVENANCE_CONTRACT.md b/docs/PROVENANCE_CONTRACT.md index df09067d..c4a12eba 100644 --- a/docs/PROVENANCE_CONTRACT.md +++ b/docs/PROVENANCE_CONTRACT.md @@ -29,17 +29,61 @@ Every analysis-ready record or report section should expose, directly or through | Share count | Explicit filing or trusted source fact with date/context. | Per-share DCF math; shares are never inferred from price or market cap. | | DCF | Price, revenue, free cash flow or FCF margin, shares, method assumptions, and company eligibility. | Fair-value scenario math and valuation interpretation. | | Candidate peers | Industry, SIC, product, or other contextual suggestion. | Candidate context must not satisfy trusted-peer readiness. | -| Trusted peers | Source-backed relationship, review rationale, source/as-of date, and required peer inputs. | Peer-relative valuation and comparative conclusions. | +| Trusted peers | Source-backed relationship, review rationale, source/as-of date, reviewer-assigned peer role, economic comparability basis, explicit valuation-anchor decision, and required peer inputs. | Peer-relative valuation and comparative conclusions. | | Earnings and estimates | Trusted provider/import fields, fiscal period, source, and retrieval/as-of context. | Optional readiness or consensus interpretation from date-only or target-only rows. | | Earnings Nowcast | Prior quarterly actuals plus an exact-period point-in-time consensus snapshot, all timestamped no later than the forecast cutoff; model version and input hash are mandatory. | Revenue/EPS range, consensus-relative classification, and every probability output. Candidate signals never satisfy this gate. | +Trusted-peer relationship provenance and valuation-anchor eligibility are independent. A source-backed mapping remains relationship evidence, but peer medians additionally require a reviewed `core_peer` or `secondary_peer` role, explicit relationship rationale, economic comparability basis, and `valuation_anchor_eligible=yes`. Missing fields, other roles, and legacy mappings remain context-only. No role or comparability field may be inferred from sector, industry, peer group, popularity, or existing row count. + ## Earnings Nowcast Point-In-Time Contract Every forecast event must preserve `ticker`, `fiscal_period`, `as_of_timestamp`, expected report date and forecast horizon when known, source publication/retrieval timestamps, direct source references, Revenue/EPS metric definitions, `model_version`, `input_snapshot_hash`, readiness/freshness states, and source IDs. Historical consensus snapshots are append-only evidence. An exact duplicate is not re-added; a revised or currently visible estimate is retained as a separate revision and must not overwrite the snapshot that was knowable at a prior cutoff. Duplicate actual rows cannot inflate quarterly history, and conflicting actuals remain blocked unless an explicit `supersedes_source_ref` resolves the revision chain. +SEC quarterly actuals follow the same append-only cutoff boundary. Q1-Q3 requires a 60-120 day SEC Companyfacts duration fact with a one-to-one source-backed fiscal identity/period-end mapping. Companyfacts EPS is marked `companyfacts_split_basis_unverified` until separate primary evidence proves comparability. Q4 requires one explicit result table and one selected-column period-end date in a SEC-filed primary-source exhibit; filing metadata cannot supply that date, and annual-minus-nine-month derivation is forbidden. Staging may identify source-backed quarter-continuity gaps, but it must not infer the missing period, Revenue, EPS, or fiscal basis. Revenue and EPS are independently ready, and EPS is withheld if its split-adjustment, share, operations, accounting, currency, or scale basis is not comparable within the source-backed history. The unverified sentinel cannot enter Business Trend values/comparisons, cohort EPS usability, target EPS backtest outcomes, prior-year EPS benchmarks, or consensus EPS benchmarks. A Revenue-bearing backtest event can remain Revenue-evaluable, but an EPS-only sentinel target is excluded. Stage output is review-only, always declares `automatic_apply=false`, and is rejected for canonical data/import paths or existing non-generated evidence directories. + +An explicit Q4 table proves the quarter and values, not the EPS split basis by itself. If filed primary text does not contain a parseable split-basis statement, extraction records `primary_split_basis_unverified`. That sentinel and the Companyfacts sentinel are blocked by the same downstream predicate; unknown arbitrary basis strings are not proof. Explicit dated split proof remains a separate usable state. + +## Quarterly Cash-Generation Evidence Contract + +Operating income, cash from operations, and reported capital expenditures use a separate versioned observation contract with ticker, fiscal period, period end, value, currency, scale, accounting basis, duration basis, source, immutable source reference, publication time, retrieval time, and explicit revision lineage. Q4 observations must carry `explicit_filed_quarter`; annual-minus-nine-month derivation, filing-metadata period substitution, and inferred Q4 values remain forbidden. Exact duplicates may collapse, one explicit revision leaf may supersede an older source reference, and unresolved conflicting leaves block only the affected component. + +Derived operating margin, free cash flow, and FCF margin exist only in memory and retain all component source references for Advanced evidence. The implementation adds **no new data file, writer, template, or generated artifact**. Production remains withheld until a reviewed source adapter supplies compatible observations; synthetic observations remain test-only. These derived points cannot write canonical inputs, alter deterministic forecasts, or unlock valuation, consensus, catalysts, outcomes, backtesting, or calibration. + +The one-company adapter acceptance result is also in-memory. It records deterministic identity, source-rights, supported-field, cutoff, revision, component, compatibility, complete-period, and explicit-Q4 decisions without fetching or persisting a payload. Every result preserves `production_activation=false` and `readiness_promotions=()`. `accepted_for_review` proves only that a candidate batch passed the local contract; it does not change the source-rights registry, authorize storage or redistribution, establish a reviewed real-company source, or promote any product readiness state. + +The SEC quarterly pilot adds a read-only source boundary without changing that activation contract. Companyfacts supplies exact accession, three-month context, concept, and magnitude; the matching submissions row supplies the timezone-aware UTC `acceptanceDateTime`; and the primary filing supplies exact inline-XBRL fact IDs plus the filed-table capex outflow presentation. Filing-date midnight and retrieval time are never substituted for publication time. A source reference points to the exact accession document and inline fact. The live NVIDIA Q1 FY2027 and AMD Q1 FY2026 previews are accepted source-review results only: they write no cache, CSV, JSON, report, screenshot, timing, canonical row, or readiness artifact. + +AMD Q1 FY2026 accession `0000002488-26-000076` uses the same bounded two-company portability contract as NVIDIA. Both results retain `production_activation=false`, `readiness_promotions=()`, `persistence=false`, exact component references, and no canonical write. Unsupported tickers fail before fetch and expose no configured filing identity. + +The explicit Company Workbench preview carries the same immutable boundary into one user-flow composition. Every result retains `production_activation=false`, `readiness_promotions=()`, and **no canonical persistence**. A successful preview may render only the accepted in-memory values and exact lineage at the fixed review cutoff; failure withholds the complete preview. It cannot create a canonical observation, update readiness, become a forecast input, establish another company or quarter, or convert source-review acceptance into production evidence. + The target-period actual and every source published after the cutoff are evaluation evidence only and must never enter forecast inputs. Trusted peer/news signals require explicit source, publication time, excerpt hash, review state, and trusted-peer relationship evidence where applicable. They remain directional context and cannot create a numeric adjustment or numerical probability. -The synthetic fixture packet is software-test evidence only. Real output remains `awaiting_point_in_time_consensus`, and numerical probability remains `awaiting_calibration_evidence` until the documented out-of-sample gates pass. +The synthetic fixture packet is software-test evidence only. The five-company SEC staging scope (NVDA, AMD, AVGO, MU, and QCOM) is not a coverage claim. Real output remains `awaiting_point_in_time_consensus`, and numerical probability remains `awaiting_calibration_evidence` until the documented out-of-sample gates pass. + +Prospective consensus snapshots are append-only. Each row preserves a unique snapshot ID, ticker, fiscal period, snapshot and retrieval timestamps, source and durable reference, Revenue/EPS values when present, complete comparability definitions, expected report date, review state, and an explicit superseded snapshot ID for revisions. Current-only provider payloads remain candidate context and cannot be relabeled as historical point-in-time evidence. + +Consensus collection preview preserves two independent decisions. Technical validity covers schema, cutoff, immutable identity, revision lineage, cooldown, and explicit review state. Commercial evidence uses the exact source ID, explicit commercial approval, and the registered scope required by each populated metric: `revenue_consensus` for Revenue and `eps_consensus` for EPS. Unknown, composite, unverified, or scope-incomplete sources fail closed without inferred provider identity. Explicit Commercial Research mode refuses the append before directory or ledger mutation when commercial evidence is incomplete; research mode keeps its reviewed local append contract. A passing guard cannot certify payload correctness, source freshness, comparability, historical depth, calibration, or readiness. + +The prospective ledger is validated as a whole before status, preview, or record. Each ticker/period must form exactly one append-ordered, timestamp-increasing root-to-current-leaf chain with unique snapshot IDs and unique evidence identities. Missing parents, cross-scope revisions, multiple roots, forks, cycles, reversed order, and non-leaf supersession fail closed. Batch preview emits a deterministic receipt bound to the normalized review cutoff, Commercial Research mode, complete proposed input, and complete existing ledger; record recomputes that receipt immediately before mutation and refuses any mismatch. This narrows the reviewed mutation boundary but does not claim multi-process locking or crash-safe transactionality. + +Price provenance separates field presence from temporal integrity. `retrieved_at` is complete only when it has an explicit timezone and the shared daily-price validator proves `observation availability <= retrieved_at <= review cutoff`; a parseable naive value is not UTC evidence. Normalization, staged preview/apply, and DCF lineage use the same blockers. A declared invalid retrieval blocks canonical apply before backup or write, while an entirely missing retrieval remains visibly incomplete and can stay technical research context only. Successful apply uses the exact once-read validated frame and an atomic same-directory replacement. Atomic replacement does not prove source truth, publication timing, reviewer intent, locking, or recovery across backup and replace. + +Consensus source-row validation cannot receive provenance permission as a caller label. It resolves the exact provider against the same registry and returns registry-derived rights plus row-specific required and missing metric scopes only for technically accepted rows. Candidate context and point-in-time reviewability remain visible when commercial evidence is incomplete, but neither state can authorize append, activation, readiness, backtesting, or calibration. Invalid rows never become commercially ready merely because their source record is approved. + +The prospective collector and source-row validator obtain their registry-derived decision from the same immutable commercial field-scope review. The result retains the exact source ID, rights state, ordered required fields, ordered missing fields, and the combined metadata gate; it rejects blank or duplicate requirements and never infers aliases or composite membership. Consumer-specific technical states, blockers, and mutation rules remain separate. This decision cannot prove the payload, timestamps, comparability, reviewer intent, collection, activation, freshness, readiness, backtesting, or calibration. + +`make earnings-consensus-source-review INPUT= PROVIDER= AS_OF=` exposes that source validation as a read-only gate before collection preview. It accepts no default provider or cutoff, preserves original one-based accepted and rejected row positions, refuses blank/duplicate headers and undeclared extra values, and writes neither normalized rows nor a report file. This upstream contract requires explicit `history_scope`; it is distinct from the prospective collection contract's `review_state`. A human-reviewed, evidence-preserving mapping into the existing prospective schema is a separate step with no automatic transformer or writer. The returned rights and scope evidence comes only from the checked-in registry; it cannot prove payload correctness, durable-reference validity, publication availability, collection, activation, readiness, backtesting, or calibration. + +Each source-row validation result also preserves its normalized review cutoff. A row must declare an exact supported history scope and satisfy `snapshot_at <= retrieved_at <= review_cutoff` before commercial evidence is evaluated. Rejected rows retain their original one-based positions, and valid sibling rows are reviewed independently. This local cutoff guards leakage and ordering; it does not manufacture a publication timestamp, source reference, permission, freshness state, or activation decision. + +A multi-row prospective input is one ordered review batch. Preview builds a virtual ledger from saved rows and earlier technically reviewable proposed rows, preserving complete row-level technical and commercial evidence. Record consumes that same batch decision and refuses the entire mutation when any technical blocker exists or, in Commercial Research mode, any rights or populated-metric scope is incomplete. The saved ledger remains unchanged after a deterministic batch rejection. Successful record appends all reviewed rows in supplied order through one handle; it does not rewrite earlier evidence. This is validation consistency, not a guarantee against concurrent writers, process interruption, or filesystem failure. + +Historical valuation observations preserve the numerator timestamp, denominator period end, denominator availability timestamp, metric definition ID, retrieval time, and source reference. A denominator unavailable at the historical price timestamp is rejected. Definition changes create separate history segments instead of a mixed regime. + +Commercial Research historical valuation, supported catalyst events, and reviewed research outcomes add a separate exact-source eligibility boundary. Every used row must have approved rights and literal `valuation_history`, `catalyst_evidence`, or `research_outcomes` scope respectively. Permission is never inferred across sources or lanes. Any failed row withholds the scoped supported/reviewed result while preserving a blocker reason under Advanced. Research-mode technical review remains independent, candidate catalyst rows remain candidate-only, and empty ledgers remain empty. + +Research outcome and catalyst ledgers are append-only reviewed evidence. Outcomes retain the original thesis entry, observation window, review time, source, and learning; they contain no return or skill score. Catalysts retain publication, retrieval, effective time, event type, evidence state, and reviewer. Neither ledger mutates source data, readiness, forecasts, probabilities, DCF assumptions, or recommendations. ## Freshness Rules @@ -69,6 +113,38 @@ The `local` profile is an ignored mutable workspace for refreshed research data. Both profiles remain research-only: no broker execution, order routing, auto-trading, direct buy/sell instructions, or fabricated values. +## Readiness Promotion Evidence Review Contract + +The stdout-only readiness preview treats a false-to-true fundamentals or DCF flag as a proposed technical change, not as proof that its source is correct, commercially permitted, current, or complete. For each proposed promotion it recovers the exact canonical fundamentals source value, as-of date, durable source reference when supplied, exact checked-in commercial-rights decision, and registered support for `revenue`, `free_cash_flow`, `fcf_margin`, and `shares_outstanding`. + +Source identifiers are exact. A composite or unregistered value is not split, normalized to a registered component, or granted inferred rights. Missing or duplicate canonical rows, missing source/as-of/reference fields, unapproved exact sources, and missing registered field support fail closed in the evidence review without changing the technical readiness frame. The fundamentals review does not establish price-source provenance required by DCF. Even a complete review is inspection evidence only; it cannot write an artifact, mark stale readiness current, edit the rights registry, or authorize the separate reviewed make readiness rebuild. + +### DCF Price-Lineage Review + +For each false-to-true DCF promotion, the no-write preview reviews the exact latest canonical price row independently. A usable selection requires a parseable observation `date`, a positive numeric `close`, the greatest date for the ticker, and exactly one row at that date. Missing usable rows and duplicate latest-date rows fail closed rather than selecting by row order. + +The selected row must carry an exact `source`, durable `source_ref`, and parseable `retrieved_at`. The exact source must be commercially approved and its checked-in rights record must explicitly support `prices`. Technical promotion, row selection, lineage completeness, commercial rights, and registered field scope remain separate states. Missing or composite sources are not split, normalized, inferred, or granted borrowed rights. + +The market observation date, `as_of_date`, local file name or modification time, adapter/provider availability, refresh warning text, and numerical row shape are not substitutes for provider identity, retrieval time, or a durable source reference. The audit changes no technical readiness, canonical row, valuation result, source-rights record, freshness state, or reviewer decision. A complete result would still be inspection evidence, not authorization for `make readiness`; an incomplete result does not by itself invalidate the row for local research context. + +The prospective manual price path can preserve explicit `source_ref` and `retrieved_at` values alongside `source`. These values must be reviewer supplied; the normalizer generates no fallback reference or retrieval timestamp. Validation normalizes a valid retrieval timestamp to UTC and reports lineage completeness separately from technical row validity. Preview retains the summary, while apply remains a distinct reviewed mutation with the existing backup and merge boundary. Preserved lineage is necessary but not sufficient: exact-source rights, registered `prices` scope, payload review, freshness, apply intent, and rebuilt readiness remain independently required. + +Validation and preview join every exact retained source ID to the checked-in rights record without splitting composite IDs or inferring aliases. They report commercial eligibility and literal `prices` field support separately for approved, unverified, unknown, blank, and mixed batches. Technically invalid rows do not enter these counts. The result is review evidence only: it cannot edit the registry, prove the payload, authorize apply, or promote readiness. + +Explicit Commercial Research mode enforces lineage completeness, exact-source rights approval, and registered `prices` scope immediately before staged-price mutation. A failed gate returns without backup creation or canonical write and retains the preview evidence for review. Research mode preserves its separate local apply contract. Guard passage does not certify the payload, reviewer decision, source freshness, readiness build, or proof record. + +Direct price refresh has a separate defense-in-depth mutation boundary. Concrete Stooq, Yahoo, FMP, Alpha Vantage, Finnhub, and IBKR implementations expose exact source IDs; automatic-ladder labels must equal the child identity. In explicit Commercial Research mode, every reachable source must independently have approved rights and literal `prices` scope before fetch, and the exact source that returns rows is rechecked before it can enter the merge or status set. A supplied source with missing, changed, composite, unapproved, or scope-incomplete identity is refused without canonical or status mutation. Provider aliases and ladder labels cannot grant or borrow rights. This local guard does not supply row-level `source_ref` or `retrieved_at`, approve a checked-in source, prove provider access or payload quality, or authorize readiness rebuild. + +Focused-cohort saved rows use lane-specific provenance and scope. A technically populated value needs an exact source plus durable source reference, approved commercial rights, and the literal registered field for that lane before Commercial Research coverage becomes usable. Margin aliases are reviewed as their exact populated margin field; FCF aliases require `free_cash_flow`; cash and debt are independent; shares require `shares_outstanding`; filing and earnings dates require `filing_dates` and `earnings_dates`; each populated consensus metric requires `revenue_consensus` or `eps_consensus`; and every trusted relationship row requires `trusted_peers`. One supported field never grants a sibling field. Candidate relationships do not enter the trusted decision. These Advanced coverage decisions do not edit canonical evidence, readiness, source rights, forecasts, valuation, or peer roles. + +Focused-cohort adjusted price history conjuncts saved readiness with canonical row evidence. At least one row must have a valid date and positive adjusted close or close; every retained row must carry exact source, durable reference, and retrieval timestamp, and every exact source must have approved rights plus literal `prices` scope. Mixed histories fail closed without provider inference. This display review does not validate retrieval chronology, rewrite canonical history, or change research-mode readiness. + +Focused-cohort quarterly Revenue/EPS display eligibility conjuncts the existing technical trend state with a metric-specific commercial review of every populated accepted canonical row. Each row needs exact source, durable reference, retrieval timestamp, approved commercial rights, and literal `revenue` or `eps` scope. One unapproved, unknown, or scope-incomplete populated row blocks only that metric. Revenue permission never grants EPS permission. This review does not repair rejected rows, resolve revision conflicts, verify EPS split basis, establish explicit-Q4 evidence, change readiness, or activate Earnings Nowcast. + +Before that metric-specific review, dashboard consumption treats the canonical quarterly-actual CSV as one integrity unit. If any row fails the canonical contract, accepted sibling rows cannot build a Business Trend or focused-cohort quarterly packet. The blocked packet carries the original CSV row number and validation reason for Advanced display only. This boundary does not delete, rewrite, infer, or repair canonical evidence. + +The preview also preserves the method reason for newly excluded DCF transitions. Named reasons are a deterministic explanation of the existing scope decision; absent metadata remains unexplained instead of inferred. Ready, partial, excluded, added, and removed transition counts are independent and may overlap. They are not current readiness totals, evidence of company quality, a ranking, or a recommendation. + ## Research Change Event Contract A research change event requires two comparable generated snapshots from the same selected profile. Each event preserves a deterministic event ID, ticker, event family/subtype, prior/current values, source and source reference, source publication time when available, retrieval and detection time, selected profile, prior/current snapshot identities, evidence status, materiality, and a research-only review task. @@ -77,12 +153,22 @@ Publication time, retrieval time, and detection time are different facts and mus Event review outcomes are append-only evidence. The latest valid outcome controls whether an event remains in the open queue, but historical rows are retained. Review resolution never mutates canonical sources or readiness. Candidate context, provider reachability, or a detected event cannot become trusted evidence without the existing source review, validation, preview, rejected-row, apply, readiness rebuild, and proof gates. +GitHub pull-request hygiene evidence is bound to the event's explicit base and head commit SHAs. Generated-artifact classification and whitespace must inspect that same three-dot commit range after sufficient history is fetched; a clean checkout working tree is not an acceptable substitute. The gate has read-only repository permission and does not establish source rights, data correctness, human review, deployment approval, or merge safety by itself. + ## Research Thesis Journal Contract `data/research_thesis_journal.csv` is the canonical reviewed, append-only journal ledger. Every row identifies schema version, entry ID, selected profile, ticker, thesis ID, entry type, recorded/effective timestamps, reviewer, summary, evidence direction, source, durable source reference, source publication timestamp, confidence, review due date, and `supersedes_entry_id`. Source publication and effective timestamps cannot be later than the recorded timestamp. Evidence, catalyst, risk, and invalidation rows require source provenance. Thesis revisions must remain within the same profile, ticker, and thesis chain and must preserve the prior row. Generated report text, detected changes, queue outcomes, and synthetic fixtures cannot create a reviewed journal entry automatically. +## Research Decision Lab Contract + +`src/research_decision_lab.py` composes immutable selected-profile results; it is not a source, ledger, readiness system, or report generator. It writes no journal, outcome, source, readiness, proof, report, screenshot, or timing artifact. Company Workbench uses the selected report, journal, scorecard, outcome status, and change items. Monitor loads the saved journal and outcome ledgers once, derives each focused ticker independently, and converts an invalid ticker input into one unavailable state without promoting or suppressing another ticker. + +Plan, Evidence, Invalidation, Scenario, Review trigger, and Learning retain separate evidence identities and fail-closed states. A valid lane cannot promote, repair, or clear another lane. Missing journal or outcome rows create `not_started` states; invalid evidence creates `unavailable`; blocked or excluded DCF remains blocked or excluded; commercial outcome-rights failures remain `commercial_evidence_blocked`. Candidate context, generated narrative, technical price history, and synthetic fixtures cannot populate reviewer-authored lanes. + +The deterministic Decision Lab identity binds the selected profile, ticker, lane states, evidence summaries, and next process step. The Monitor table preserves the saved focused-cohort order; identity detail stays under Advanced. These identities prove deterministic local composition only. They do not prove source truth, predictive accuracy, investment performance, reviewer adoption, hosted reliability, demand, or product-market fit. + ## Scenario Lab Contract The Scenario Lab never writes canonical data, readiness, reports, or proof records. It derives an immutable input identity from the selected profile, source-backed valuation input, provenance, and bounded session parameters. Only DCF-ready operating companies with source references, revenue, FCF margin, and shares can display numerical scenario math. If the selected row is blocked or excluded, the product shows the reason and withholds baseline, adjusted, sensitivity, and terminal-value figures. diff --git a/docs/PUBLIC_DEMO_WALKTHROUGH.md b/docs/PUBLIC_DEMO_WALKTHROUGH.md index 4c76919a..e7f67b64 100644 --- a/docs/PUBLIC_DEMO_WALKTHROUGH.md +++ b/docs/PUBLIC_DEMO_WALKTHROUGH.md @@ -9,7 +9,7 @@ Use this when sharing the project from GitHub or LinkedIn. The walkthrough is re - Share under the controlled portfolio/demo license; do not describe the repository as open source or reusable software. - Keep the demo research-only: no investment advice, broker action, order routing, auto-trading, or direct buy/sell instructions. - No public hosted Streamlit URL is configured yet; the shareable path is the GitHub project, curated screenshots, and tracked `make demo-dashboard` instructions. Use `docs/HOSTED_DEMO_DEPLOYMENT.md` only when preparing a verified hosted app link. -- Public UX status is `review_limited`: repeated local cold/warm evidence passes, but hosted-route verification and external reviewer workflow evidence remain pending. +- Public UX status is `share_review_ready` for the local GitHub/demo workflow: public checks, browser evidence, and repeated local cold/warm evidence pass. Hosted-route verification and external reviewer workflow evidence remain pending before calling it a hosted or externally validated product. - FMP, Alpha Vantage, and Finnhub are optional provider fallbacks and are not configured by default. Provider setup does not prove coverage until a reviewed source-backed smoke, validation, preview, and readiness rebuild pass. - Coverage is intentionally readiness-gated rather than complete. Fundamentals, share count, peer mapping, earnings, and analyst estimates can remain blocked or locked while the product is still useful. - Stop before claiming a blocked lane is ready unless source proof, validation, preview, apply, rebuilt readiness, and proof history all support it. diff --git a/docs/PUBLIC_RELEASE_CHECKLIST.md b/docs/PUBLIC_RELEASE_CHECKLIST.md index 7266db25..6b430b3d 100644 --- a/docs/PUBLIC_RELEASE_CHECKLIST.md +++ b/docs/PUBLIC_RELEASE_CHECKLIST.md @@ -6,7 +6,7 @@ Use this checklist before sharing the repository on GitHub or LinkedIn. - Keep the top of `README.md` focused on what the project does, why it matters, and how to run it. - Confirm `README.md` starts with `External Reviewer Start Here` so a GitHub or LinkedIn visitor sees the GitHub/local-app boundary, current-readiness source, and stop rules before deeper operator detail. -- Keep the guided product flow near the top: README preview, `docs/PUBLIC_DEMO_WALKTHROUGH.md`, then `make dashboard` and the Home -> Stock Selector -> Single-Stock Report -> Data Health -> Proof History path. +- Keep the primary product flow near the top: README Workbench preview, `make dashboard`, then Research Desk -> Discover -> Company Workbench -> Monitor. Keep Home -> Stock Selector -> Single-Stock Report -> Data Health -> Proof History as the secondary controlled Public demo. - Keep terminal proof commands secondary: use `make status-check TOP_N=5` and `make stock-report-md TICKER=NVDA` only after the visitor understands the public workflow. - Keep `make stock-report TICKER=NVDA` available for optional local report-data inspection, but prefer `make stock-report-md` for LinkedIn/GitHub visitors. - Keep `docs/OPERATOR_GUIDE.md` linked from the README as the deeper local workflow guide so LinkedIn visitors see a short landing page first and advanced users still have exact commands. @@ -34,10 +34,10 @@ Use this checklist before sharing the repository on GitHub or LinkedIn. - Keep the pilot evidence packet visible: baseline readiness, before report, focused blocker check, lane review path, validate/preview gate, apply boundary, rejected-row check, rebuild proof, and still-blocked evidence row. - Include current readiness numbers only when they are clearly labeled as local snapshots. - Keep generated examples that help visitors understand the product, such as `outputs/stock_reports/qqq.md` and `outputs/stock_reports/nvda.md`. -- Use `docs/assets/linkedin-public-dashboard.png` as the LinkedIn Featured thumbnail unless you intentionally want an operator-mode screenshot; treat image counts as illustrative and use `make status-check TOP_N=5` for current local counts. -- Use GitHub as the LinkedIn link target unless a hosted app has been separately deployed and verified. The current public-share package is GitHub plus curated screenshots plus local run instructions. +- Use `docs/assets/linkedin-public-dashboard.png` as the LinkedIn Featured thumbnail only when it is the reviewed `1200x627` Company Workbench answer-first capture. Do not publish changing coverage or readiness figures in the social image. +- Use the stable GitHub repository as the LinkedIn link target only after the reviewed feature reaches the default branch. Until then, keep the existing stable item or label any non-default review link `Draft engineering preview`. Do not substitute a hosted-app link until it is separately deployed and verified. - Before replacing the GitHub link with a hosted app link, complete `docs/HOSTED_DEMO_DEPLOYMENT.md` and rerun the public gates. -- Confirm the LinkedIn Featured description mentions Python + Streamlit, data readiness before analysis, research-only, no broker integration, no auto-trading, and no investment advice. +- Confirm the LinkedIn Featured description mentions Python + Streamlit, Research Desk -> Discover -> Company Workbench -> Monitor, usable versus withheld evidence, research-only, no broker integration, no auto-trading, and no investment advice. - Confirm LinkedIn copy does not imply complete coverage, provider-key activation, public hosted app availability, data freshness proof, or investment recommendations. - Run `make browser-qa-evidence` before sharing or replacing public screenshots. It shows the current public-share image recommendation, committed asset checks, current real-app capture status, route expectations, and capture boundaries without refreshing data or writing reports. - Run `make linkedin-share-check` for the final LinkedIn Featured-card checklist. It is read-only: it does not open LinkedIn, upload files, edit your profile, refresh data, stage files, commit, or push. diff --git a/docs/SCHEDULER_ACTIVATION_CHECKLIST.md b/docs/SCHEDULER_ACTIVATION_CHECKLIST.md index dfca1b56..83f34065 100644 --- a/docs/SCHEDULER_ACTIVATION_CHECKLIST.md +++ b/docs/SCHEDULER_ACTIVATION_CHECKLIST.md @@ -2,7 +2,19 @@ Use this checklist before turning any refresh command into a recurring job. -The default scheduler posture is read-only monitoring. Do not schedule unattended imports, applies, commits, pushes, hosted deployment changes, broker access, or recommendation workflows. +The default scheduler posture is read-only monitoring. Automatic application is disabled for every refresh lane. Do not schedule unattended imports, applies, commits, pushes, hosted deployment changes, broker access, or recommendation workflows. + +## Refresh Operations Contract + +Every scheduled review follows the same read-only lifecycle: + +`fetch -> normalize -> validate -> quarantine -> preview -> publish snapshot -> rebuild readiness -> detect changes` + +- A job plan records provider order, batch limit, freshness policy, schema identity, attempt history, state, and failure reason before any provider call. +- A failed provider path is not retried again in the same session. Retry caps apply across prior attempts; if every provider is unavailable, already attempted, or capped, record the blocked result and pivot. +- Missing or changed schema identity, missing provenance, duplicate rows, and stale rows are quarantined. Partial batches are withheld from preview, snapshot publication, readiness rebuild, and change detection. A batch that is both partial and invalid remains explicitly `partial_invalid`; neither condition is flattened away. +- A clean batch can become `ready_for_preview`, but it never publishes or applies automatically. Snapshot publication, readiness rebuild, and any later apply remain separate reviewed actions. +- The plan and status commands are descriptive only. They do not fetch, normalize, validate, quarantine, publish, rebuild, detect changes, or write data. ## 1. Start With Status-Only Monitoring @@ -13,14 +25,16 @@ make project-status-check make provider-setup-checklist make auto-refresh-status SCHEDULE=daily make auto-refresh-runbook SCHEDULE=daily +make refresh-operations-status SCHEDULE=daily +make refresh-operations-runbook SCHEDULE=daily make diff-hygiene-summary ``` Use this mode while provider keys, hosted URL setup, trusted peer rows, or optional context rows are still missing. -## 2. Activation Preconditions +## 2. Manual Review Preconditions -Do not schedule a mutating refresh or apply path until all of these are true: +Do not schedule a mutating refresh or apply path. Automatic application remains disabled even when every gate below is satisfied. These checks define the evidence needed for a separately reviewed, manual action: | Gate | Required evidence | | --- | --- | @@ -36,12 +50,16 @@ Do not schedule a mutating refresh or apply path until all of these are true: Provider setup alone is never activation proof. The compact activation shortcut is: source available, narrow scope, validation, preview, zero rejected rows, provenance, no fabrication, and proof recorded. +The default refresh-operations status and runbook never print stage, apply, or publication commands. They stop at source preflight and read-only scope preview. A mutating command belongs in a separate, human-reviewed handoff and is never part of the scheduler plan while `auto_apply=false`. + +Provider availability and attempt history are session inputs. An unavailable provider fails closed. A failed provider is skipped for the rest of that session, and the bounded retry cap prevents older failures from being retried indefinitely. The CLI accepts `--available-providers`, `--session-id`, `--retry-cap`, and repeated `--provider-attempt provider:session:outcome` arguments for already-reviewed scheduler state. + ## 3. Allowed Recurring Jobs | Schedule | Allowed now | Boundary | | --- | --- | --- | | Daily | Status checks, source preflight, hosted/demo readiness checks, public wording checks, dry-run price plans. | Do not auto-apply imports. | -| Daily after source smoke | Capped price refresh or SEC filing/share-count review for a reviewed scope. | Apply only through `make auto-apply-gate` plus validate/preview/proof gates. | +| Daily after source smoke | Capped price refresh or SEC filing/share-count review for a reviewed scope. | Produce a manual-review handoff only; automatic apply remains disabled. | | Weekly | Peer candidate review and candidate-context reporting. | Candidate peers stay `candidate_context_only` until source-backed relationships are reviewed. | | Optional | Earnings and analyst-estimate source ladder checks. | Date-only or target-price-only rows stay `candidate_context_only`. | @@ -50,7 +68,7 @@ The compact activation shortcut is: source available, narrow scope, validation, - Stop if `make project-status-check` says source-proof queues are exhausted. - Stop if FMP, Alpha Vantage, or Finnhub keys are missing and the selected provider requires a key. - Stop if the hosted URL is not configured or cannot be opened. -- Stop if validation fails, preview widens unexpectedly, rejected rows are present, provenance is missing, or fabricated values are detected. +- Stop if validation fails, preview widens unexpectedly, rejected rows are present, provenance is missing, schema identity changes, duplicate/stale rows are present, a batch is partial, or fabricated values are detected. - Stop if generated CSV/JSON/report churn would be staged by default. When a stop rule triggers, record the outcome as `still_blocked`, `skipped`, `excluded`, or `candidate_context_only`, then pivot to the next executable product or source-review item. @@ -68,4 +86,4 @@ make diff-hygiene-summary git diff --check ``` -Scheduling is operational monitoring. It does not change the research-only boundary, unlock blocked data, create recommendations, or turn the project into a hosted public data platform. +Scheduling is operational monitoring. It does not change the research-only boundary, unlock blocked data, create recommendations, turn the project into a hosted public data platform, or enable automatic data application. diff --git a/docs/TRUSTED_PEER_PILOT_SOURCE_TEMPLATE.csv b/docs/TRUSTED_PEER_PILOT_SOURCE_TEMPLATE.csv index 5dc39b40..5682a157 100644 --- a/docs/TRUSTED_PEER_PILOT_SOURCE_TEMPLATE.csv +++ b/docs/TRUSTED_PEER_PILOT_SOURCE_TEMPLATE.csv @@ -1,2 +1,2 @@ -ticker,peer_ticker,peer_group,sector,industry,source_type,source_title,source,source_accessed_date,as_of_date,relationship_rationale,source_evidence_note,reviewer,review_date,source_proof_status,import_row_ready,notes -TICKER,PEER,Comparable operating-company peer group,Source-backed sector,Source-backed industry,company filing / company website / investor deck / exchange profile / other trusted source,Source title or document name,https://source.example/path,YYYY-MM-DD,YYYY-MM-DD,Short reason the source supports this peer relationship,Short non-sensitive note describing the exact source evidence reviewed,reviewer-id,YYYY-MM-DD,source_backed / candidate_context_only,false,Template row only; do not import until source proof is reviewed and writeback guard passes; copy only import-schema fields into the guard +ticker,peer_ticker,peer_group,sector,industry,peer_role,comparability_basis,valuation_anchor_eligible,source_type,source_title,source,source_accessed_date,as_of_date,relationship_rationale,source_evidence_note,reviewer,review_date,source_proof_status,import_row_ready,notes +TICKER,PEER,Comparable operating-company peer group,Source-backed sector,Source-backed industry,core_peer / secondary_peer / aspirational_peer / negative_peer / excluded_close_peer / not_clean_comp,Reviewed business-model and economic comparability plus material caveats,yes / no,company filing / company website / investor deck / exchange profile / other trusted source,Source title or document name,https://source.example/path,YYYY-MM-DD,YYYY-MM-DD,Short reason the source supports this peer relationship,Short non-sensitive note describing the exact source evidence reviewed,reviewer-id,YYYY-MM-DD,source_backed / candidate_context_only,false,Template row only; do not import until source proof is reviewed and writeback guard passes; copy only import-schema fields into the guard diff --git a/docs/analysis_capability_audit.md b/docs/analysis_capability_audit.md index 4f612424..c600c341 100644 --- a/docs/analysis_capability_audit.md +++ b/docs/analysis_capability_audit.md @@ -103,6 +103,6 @@ Any external review or research input still has to be translated into determinis ## Supported-Today Assessment -The current functions are strong enough for a transparent local research prototype, single-stock review, market/ETF monitoring, and DCF-ready company analysis when trusted data exists. They are not yet a full-market data platform because fundamentals, peer data, earnings, and analyst estimates are intentionally sparse until trusted rows are imported. +The current functions are strong enough for a transparent local research prototype, single-stock review, market/ETF monitoring, and DCF-ready company analysis when trusted data exists. The calculation layer is working, but this is not real quantitative investing capability and is not yet a full-market data platform: checked-in observations do not establish current-market recency, and fundamentals, peer data, earnings, analyst estimates, point-in-time universe evidence, consensus, and calibration remain intentionally source-gated. -The next quality improvement is not more indicators or another broad source loop. It is a measured performance release candidate on the fixed demo profile, followed by controlled external review. Deeper trusted peer and provider coverage remains source-gated and should resume only when reviewed evidence exists. +The performance release candidate is complete. The next quality work is to separate calculation readiness from observation recency, close the approved accessibility defects, obtain one permitted independently reviewed point-in-time universe/benchmark package plus one consensus snapshot and reviewed peer relationship, and run 10-20 independent target-persona sessions. Do not add more indicators or restart broad provider loops merely to increase feature count. diff --git a/docs/assets/linkedin-public-dashboard.png b/docs/assets/linkedin-public-dashboard.png index 6d251a81..bd74333f 100644 Binary files a/docs/assets/linkedin-public-dashboard.png and b/docs/assets/linkedin-public-dashboard.png differ diff --git a/docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md b/docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md new file mode 100644 index 00000000..51d72c4f --- /dev/null +++ b/docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md @@ -0,0 +1,446 @@ +# Commercial Research Beta Continuation Goal Prompt + +Use this prompt to continue the Stock Research Command Center in a new Codex task. It is intentionally repo-truth-first: commit hashes, counts, readiness states, and external dependencies must be reverified rather than copied forward as facts. + +```text +/goal + +Continue the Stock Research Command Center in: + +/Users/yjian070/Documents/New project/.worktrees/personal-research-mode-mvp + +Objective: + +Advance the Stock Research Command Center from its current local Commercial Research Beta release-candidate stage through every applicable local, source, hosted-preview, beta-validation, evidence-depth, calibration, and operating-maturity gate. Continue one coherent verified slice at a time while any safe executable in-scope work remains. Do not redefine completion around the work that is already easy or local. + +Start from current repository truth, not chat memory. +Verify authoritative remote commit `69c49968e77bfd55fa259695089e1f34ac2fddfb` or a later descendant before relying on this implementation evidence. The original local worktree may still report older Git metadata because its index is read-only; compare the actual product tree and remote PR head instead of recommitting an already synchronized package. +When external evidence is unavailable, record its exact unblock condition once and continue to the next safe executable lane. +Never claim overall completion without direct current evidence for every applicable exit gate. +Do not run readiness rebuilds or generated-artifact commands without explicit approval. + +Persistence contract: + +1. Start every continuation from live repository, roadmap, generated-artifact, test, runtime, remote-branch, and PR truth. Chat memory and the expected-state notes below are navigation aids only. +2. Continue automatically while any safe, meaningful, in-scope local task remains. Do not ask for approval for ordinary reversible local implementation, testing, documentation, or draft-PR updates already authorized by this goal. +3. Do not mark the goal blocked because one lane, provider, dataset, hosted account, reviewer, source, or calibration cohort is unavailable when another executable workstream exists. +4. When an external dependency is unavailable, classify it once, record the exact unblock condition and last evidence, avoid identical retry loops, and move immediately to the next executable local task. +5. Recheck an external dependency only when a credential, supplied dataset, account, URL, reviewer cohort, provider entitlement, source-rights decision, or other relevant external state has verifiably changed. +6. Do not repeat exhausted provider probes, broad-coverage refreshes, speculative peer sourcing, or identical source-limit loops. +7. Work one coherent independently tested slice at a time. Finish verification, exact staging, commit, push, roadmap/docs updates, and draft-PR updates before beginning the next slice. +8. Preserve explicit approval requirements for merging, public deployment, external account changes, credential use, destructive actions, purchases, public communication, or material scope expansion. +9. Never fabricate or infer data, forecasts, probabilities, evidence, events, peers, roles, comparability, outcomes, timestamps, sources, rights, reviewer results, hosted properties, recommendations, or completion evidence. +10. Do not mark the objective complete until the requirement-by-requirement completion audit directly proves every applicable exit gate. Passing local tests is not proof of source access, hosting, external beta validation, evidence depth, calibration, or operating maturity. +11. If every remaining task genuinely requires unavailable external input or new authority, leave completion unclaimed, produce the exact dependency ledger and resume checklist, and follow the active goal system's strict blocked audit. “Non-blocking” means pivoting to executable work, not retrying an unavailable dependency forever or pretending it is complete. +12. Keep the goal active whenever any applicable gate remains incomplete or unproven. A single unavailable dependency is not a reason to stop while another safe executable local slice remains; completion requires direct evidence for all applicable gates, not persistence theater or repeated external retries. + +Expected lineage to verify, never assume: + +- Branch: `codex/personal-research-mode-mvp`. +- Draft PR: https://github.com/YuzeJ21/Stock-Analysis/pull/113. +- Current verified synchronization anchor: `88d7a9cd057f9e671360bf1203a6a253c0301675` or a later verified descendant; exact-head GitHub Actions run `30246863731` passed and the local full suite reported 4,215 passing tests. Reverify all three before reliance. +- Roadmap truth-reconciliation contract: `ROADMAP.md` is the concise current decision index with `Now`, `Next`, `Externally blocked`, `Later`, and `Completed with evidence`; detailed remediation history stays in its named evidence documents. Reverify the line-budget contract, focused documentation tests, release gates, commit, push, draft-PR update, and exact-head CI before calling the reconciliation slice complete. +- Priority 4 freeze synchronization anchor: remote commit `69c49968e77bfd55fa259695089e1f34ac2fddfb`; exact-head GitHub Actions run `30185232040` passed. Reverify both instead of treating the local read-only index as authoritative remote state. +- Evidence-quality lineage anchor: commit `781ba2481` or a later verified descendant. +- SEC quarterly cash-generation pilot anchor: commit `a262eda9f` or a later verified descendant. +- Explicit Company Workbench cash-generation preview anchor: commit `973bb32aa` or a later verified descendant. +- Bounded two-company cash-preview design anchor: commit `11ac530ae` or a later verified descendant. +- No-write readiness-preview lineage anchor: commit `1f72a6d90` or a later verified descendant. +- Stale readiness continuation gate lineage anchor: commit `54f3977d7` or a later verified descendant. +- Readiness promotion evidence lineage anchor: commit `5e6e01d1a` or a later verified descendant. +- Readiness change-cause lineage anchor: commit `8fd61e201` or a later verified descendant. +- DCF price-lineage review design/plan anchor: commit `cc85cb5f1` or a later verified descendant. +- Prospective price-lineage preservation design/plan anchor: commit `85495b088` or a later verified descendant. +- Staged price rights/scope review design/plan anchor: commit `b1970004d` or a later verified descendant. +- Commercial price-apply guard design/plan anchor: commit `aa12d0e5f` or a later verified descendant. +- Commercial direct-price refresh enforcement design/plan anchor: commit `5997d84bb` or a later verified descendant. +- Cohort saved-row field-scope enforcement design/plan anchor: commit `474445d17` or a later verified descendant. +- Cohort adjusted-price history scope design/plan anchor: commit `e466d083f` or a later verified descendant. +- Cohort quarterly Revenue/EPS field-scope design/plan anchor: commit `20f973f62` or a later verified descendant. +- EPS split-basis downstream enforcement design/plan anchor: commit `dc7ed7c18` or a later verified descendant. +- Evidence-integrity hardening anchor: commit `7d463bae7` or a later verified descendant. +- Prospective consensus source-rights guard design/plan anchor: commit `9b15fafac` or a later verified descendant. +- Prospective consensus batch-preflight design/plan anchor: commit `b2d5f85fe` or a later verified descendant. +- Consensus source-validation rights design/plan anchor: commit `d03cd572f` or a later verified descendant. +- Consensus source temporal-integrity design/plan anchor: commit `e48cab7e6` or a later verified descendant. +- Shared consensus commercial field-scope design/plan anchor: commit `b7013f161` or a later verified descendant. +- Consensus source-review command implementation anchor: commit `c7e81d667` or a later verified descendant. +- PR engineering gate hosted-verification anchor: commit `1fdcaaa78` or a later verified descendant; reverify the current revision's check instead of copying the earlier result forward. +- Public UX post-fix outcome-contract implementation anchor: commit `74eb4055e` or a later verified descendant. +- Prospective field-proof Stage A implementation anchor: commit `2c7398402` or a later verified descendant. +- Company Workbench answer-first handoff design anchor: commit `1a144c9a1` or a later verified descendant; reverify the implementation, responsive measurements, and current exact-head CI rather than treating the design commit as implementation evidence. +- Company Workbench answer-first handoff implementation anchor: commit `ca4772ffb` or a later verified descendant; reverify the current revision's responsive behavior and exact-head CI rather than copying this implementation result forward. +- Public packaging reconciliation design anchor: commit `5dead2a72` or a later verified descendant; reverify the README hierarchy, LinkedIn link boundary, curated Workbench image, answer-level performance marker, and current exact-head CI rather than treating the design as implementation evidence. +- Public packaging reconciliation plan anchor: commit `e9dc19f41` or a later verified descendant; verify the implementation and current branch state instead of assuming the plan was completed. +- Research Decision Lab approved-design anchor: commit `54e06e1c3`; composition anchor: `1cfed7490`; Company Workbench anchor: `a4786bb25`; Monitor anchor: `c7ad977b3`, or later verified descendants. Read `docs/superpowers/specs/2026-07-22-research-decision-lab-design.md`, reverify the current implementation and exact-head CI, and do not treat the design commit alone as product evidence. +- Point-in-time universe production-validator lineage anchor: commit `1361472bce6d23cc537ef222c3735bb640c9838a` or a later verified descendant. Task 8 documentation-evidence reliance requires commit `1ece7a3e4adc70450d6318c06571cc8bf54368b0` or a later descendant. Reverify the local implementation and every applicable current gate; neither anchor is permitted-real-data, provider-rights, independent-review, hosted, or commercial-validation evidence. +- Do not assume branch cleanliness, push state, or upstream alignment. The tracked PR readiness snapshot remains the June 7 snapshot and is stale under the declared-date policy; an excluded July 21 local generated working-data snapshot may exist but is not committed PR evidence. Reverify with Git and `make readiness-preview TOP_N=20`; the verified local run reported zero stable readiness changes, which does not authorize staging or a readiness rebuild. +- PR #113 must remain open and draft. Do not merge it. +- Generated CSV, JSON, readiness reports, stock reports, sample reports, screenshots, browser timing output, and other generated churn must remain excluded unless one exact artifact is intentionally reviewed and explicitly required. + +Current locally implemented capabilities to verify: + +- Personal Research Mode with Research Desk -> Discover -> Company Workbench -> Monitor. +- Company Workbench authoritative-task composition: the change answer explicitly distinguishes `none`, `snapshot_only`, and `source_backed`, so an empty queue receives a neutral no-queued-change label rather than a snapshot badge. One overall task gives priority only to a change with separate explicit source-backed eligibility. Open items keep their suggested task; still-blocked and intentionally deferred items preserve their existing wait/monitor routing and wait condition. Snapshot-only context is labeled separately and cannot outrank the ordered Research Conclusion priority. Forward View guidance is lane-specific, not a competing overall task; readiness and evidence states remain independent and unchanged. +- Answer-first hierarchy on all four Personal Research routes at desktop and phone widths. +- Company Workbench anchored answer handoff: a Workbench-only compact header retains the semantic page identity, ticker/profile scope, and research-only boundary while omitting duplicated header freshness/action metadata already carried by the profile strip and selected answer. The same fail-closed fast and final summary renders through one optional target before Review path and lane coverage, exactly once, with a ticker-preserving Data Health handoff. The selected answer has an explicit Personal Research desktop/phone style contract instead of depending on Public-mode-only CSS. Local AVGO measurement at `1280x720` verified a four-column answer; at `390x844`, the answer began near `409px`, Data Health ended near `669px`, the stop condition near `705px`, Review path near `746px`, lane coverage near `806px`, document width equaled viewport width, and one answer was present. These measurements are committed local implementation evidence on anchor `ca4772ffb`; they must still be reverified on later revisions and prove no readiness, source, hosted, accessibility, reviewer, demand, or market gate. +- Mobile first-action density that keeps all five profile facts visible in two phone rows, removes only duplicated route-card freshness on phone, exposes route tasks sooner, and preserves the complete Company Workbench review path in a collapsed disclosure without changing readiness. +- SEC quarterly actual lineage with explicit Q4-table and EPS split-basis boundaries. +- Filed-Q4 EPS uses `primary_split_basis_unverified` when primary split language is absent or malformed; an explicit Q4 table never supplies split proof by itself. Revenue remains independent and arbitrary nonempty basis labels do not unlock EPS. +- Earnings Nowcast readiness and five-company cohort board. +- Prospective append-only point-in-time consensus collection contracts. +- Prospective-only per-field proof operations: `make prospective-field-proof-status`, `make prospective-field-proof-preview INPUT= AS_OF=`, and explicit `make prospective-field-proof-record INPUT= AS_OF= PREVIEW_RECEIPT= CONFIRM_REVIEWED=1`. An absent ledger is a valid empty state; legacy narrative proof is not upgraded, and no sample rows are product evidence. Preview reports `technical_write_eligible` and `commercial_evidence_eligible` independently, and its preview receipt binds ledger, input, cutoff, commercial mode, and source-rights registry. The ledger does not activate readiness, does not update canonical data, does not update proof-readiness reconciliation, and does not activate Company Workbench. Any mapping requires a separate design. +- Historical Valuation Regime, Research Outcome Review, and Catalyst Evidence Timeline. +- Forward View, Scenario Lab, Source Freshness Timeline, Research Comparison, Peer Read-Through Map, and Decision-Process Scorecard. +- Local Decision Lab implementation is complete when current repository evidence reconfirms the immutable six-lane contract, Workbench placement, Monitor stable-order Research Discipline Review, per-ticker fail-closed isolation, responsive runtime behavior, release gates, exact staging, push, draft-PR update, and exact-head CI. It adds no route, ledger, readiness state, transaction field, position sizing, stop/profit rule, recommendation, or broker action. Do not reimplement it unless a current regression is directly reproduced. +- The local Decision Lab does not prove source coverage, predictive accuracy, investment performance, independent adoption, hosted reliability, commercial demand, competitive superiority, or product-market fit. +- Fail-closed provenance, source-rights, freshness, candidate-context, and synthetic-fixture controls. +- Shared pilot and reviewed-batch freshness that treats declared source dates newer than the saved readiness build as stale even when file mtimes look current; the read-only gate never runs `make readiness` or writes generated artifacts. +- No-write readiness impact preview via `make readiness-preview TOP_N=20`; it runs production readiness logic in memory, compares stable saved-versus-proposed states, writes nothing, and does not make saved readiness current. +- Fail-closed readiness promotion evidence review inside the same no-write command: proposed fundamentals/DCF promotions retain independent exact-source rights, source/as-of/durable-reference provenance, registered field-scope, and DCF price-provenance states. Composite or unregistered source values are not split or granted inferred rights. +- Semantic readiness change-cause review inside the same no-write command: added/removed and ready/partial/excluded transitions remain separate, while newly excluded DCF rows retain named behavior-equivalent company-scope reasons. Transition counts can overlap and exclusions are method-fit states, not negative company signals. +- Independent DCF price-lineage review inside the same no-write command: each proposed DCF promotion requires one unambiguous latest valid positive-close row, then keeps row-level `source`/`source_ref`/`retrieved_at`, exact-source rights, and registered `prices` scope separate. File origin, observation date, adapter availability, refresh history, and row shape cannot supply missing provider provenance. +- Prospective price-lineage preservation through manual normalization, validation, preview, and later reviewed apply: explicit `source_ref` and `retrieved_at` survive the staged path, while missing/invalid lineage remains independently review-required and is never generated from local file or processing metadata. +- Independent staged-price source review in validation and preview: each exact retained `source` value keeps commercial-rights and registered `prices` scope states separate from technical validity and lineage; unknown, blank, unverified, scope-incomplete, and mixed batches fail closed without aliases or provider inference. +- Explicit Commercial Research mode staged-price apply guard: incomplete lineage, unapproved exact-source rights, or missing registered `prices` scope returns before backup or canonical mutation; ordinary research mode retains the existing separately reviewed local path. +- Explicit Commercial Research mode direct-price refresh guard: every concrete provider has an exact source ID, automatic ladders admit only independently approved and `prices`-scoped legs before provider execution, and the selected exact source is rechecked before merge or status mutation. Missing, changed, composite, unapproved, or scope-incomplete identity fails closed; research mode retains the existing ladder. +- Field-specific focused-cohort saved-row review: margins, free cash flow, cash, debt, shares, filing dates, earnings dates, populated Revenue/EPS consensus, and every trusted-peer row keep technical availability, provenance, exact-source rights, and literal registered scope independent. Cash/debt and Revenue/EPS consensus cannot borrow sibling permission; candidate peers remain context-only and blockers stay under Advanced. +- Focused-cohort adjusted-price review: saved price readiness is necessary but not sufficient in Commercial Research mode; every retained canonical history row needs technical date/positive-close validity, exact `source`/`source_ref`/`retrieved_at` provenance, approved exact-source rights, and registered `prices` scope. Mixed or unlined history fails closed. +- Focused-cohort quarterly Revenue/EPS review: technical trend state is conjunctive with a metric-specific review of every populated accepted canonical row. Exact source/reference/retrieval provenance, approved rights, and literal `revenue` or `eps` scope remain independent; one mixed or unapproved row blocks only its metric, SEC Companyfacts Revenue scope cannot unlock EPS, and Research mode retains packet behavior. +- Central EPS split-basis sentinel enforcement: `companyfacts_split_basis_unverified` EPS observations cannot enter Business Trend values/comparisons, commercial cohort EPS usability, target or prior-year backtest outcomes, or consensus EPS benchmark math. Mixed verified/unverified trend history remains partial with named withheld periods, EPS-only sentinel targets are excluded, and Revenue remains independent. A direct filed-Q4 integration proves contiguous Revenue can be ready while Companyfacts Q1-Q3 EPS keeps EPS withheld. +- Evidence-integrity hardening: the final broad branch review found and the branch fixed the valuation and backtest gaps. Historical valuation observations reject non-finite valuation inputs, require a canonical real `YYYY-MM-DD` denominator period end, reject blank, malformed, and non-calendar denominator period ends, and reject post-cutoff retrieval evidence. Nowcast targets and prior-year benchmarks canonicalize Revenue/EPS independently through explicit `supersedes_source_ref` lineage, retain one event per ticker/period, and withhold ambiguous leaves per metric so one metric does not suppress the other; they use cutoff-bounded prior-year benchmarks so post-cutoff revisions cannot leak. It does not make stale readiness current or complete consensus, calibration, hosted, reviewer, operating, demand, or product-market-fit gates. The exact next external lane remains permitted point-in-time consensus review/collection preview. +- GitHub Actions runtime maintenance: the pull-request engineering gate pins `actions/checkout@v6` and `actions/setup-python@v6`; its contract rejects the retired Node 20 pins while preserving pull-request-only, read-only, exact-head, full-history, test, render, wording, PR-range hygiene, and whitespace behavior. Require a fresh exact-head hosted pass after this change; action-version maintenance proves no product, data, source, hosted-product, reviewer, calibration, demand, or market gate. +- Prospective consensus source-rights guard: preview keeps append-only technical validity, exact-source rights, and required `revenue_consensus` / `eps_consensus` scope independent; explicit Commercial Research mode blocks before ledger or directory mutation when rights or populated-metric scope is incomplete, while ordinary research mode retains explicit reviewed append compatibility. +- Prospective consensus ledger integrity: status, preview, and record validate every saved row and require one unique append-ordered, timestamp-increasing root-to-current-leaf chain per ticker/period. Preview receipts bind the exact normalized cutoff, mode, proposed input, and saved ledger; record requires and recomputes the receipt before mutation. This is deterministic pre-write integrity, not concurrent locking or crash-safe transactionality. +- Price temporal/apply integrity: normalization, staged validation/preview/apply, and DCF lineage share one explicit-timezone daily-price temporal decision. Declared retrieval must fall between next-day UTC availability and the review cutoff. Apply carries one validated staged frame through a flushed same-directory atomic replacement instead of re-reading the import; this is not source publication proof, concurrent locking, or a database transaction. +- Prospective consensus whole-batch preflight: preview and record simulate rows in supplied order against saved plus earlier technically reviewable evidence, reject empty or technically invalid batches before mutation, apply the commercial conjunction only in explicit Commercial Research mode, and append a valid batch through one handle. Deterministic later rejections cannot leave partial proposed rows. +- Registry-derived consensus source validation: caller labels cannot grant rights; technically accepted rows retain independent candidate or historical-reviewable state while exact-source commercial rights and each populated Revenue/EPS scope come only from the checked-in registry. Invalid rows do not enter commercial-ready counts, and composite provider IDs remain unknown. +- Consensus source temporal integrity: every validation call supplies a UTC review cutoff, every row declares exactly `current_only` or `point_in_time`, and technical acceptance requires `snapshot_at <= retrieved_at <= review_cutoff`. Candidate context receives no cutoff exemption, and rejected rows never enter commercial review. +- Shared consensus commercial field-scope review: prospective collection and upstream source validation use one immutable exact-source rights/scope decision with ordered required and missing metric fields; blank or duplicate requirements fail locally, composite IDs remain unknown, and consumer technical/write states stay independent. +- Read-only consensus source-review command: set `SOURCE_INPUT=` and run `make earnings-consensus-source-review INPUT=$SOURCE_INPUT PROVIDER= AS_OF=`; it requires explicit provider/cutoff, rejects ambiguous CSV shape, and exposes ordered technical, temporal, rights, and Revenue/EPS scope evidence before collection preview without writing an artifact. Source review and prospective collection are distinct input contracts; set `COLLECTION_INPUT=` only after separate human review and explicit evidence-preserving mapping into the existing collection schema. +- Pull-request engineering gate: `.github/workflows/commercial-research-beta.yml` runs the full tests plus the minimal dashboard, Personal Research render, wording, range hygiene, and range whitespace contract only for pull requests to `main`, with `contents: read` and no provider, readiness, deployment, secret, schedule, or artifact-upload path. It fetches full history, checks out the exact event head, and binds both hygiene commands to the explicit event base/head SHAs. Direct GitHub evidence must pass on every current revision, and automation never substitutes for human review. +- Same-mode Advanced Evidence continuity: Data Health and Proof History stay inside Personal Research mode, preserve the selected ticker, and expose Return to Company Workbench before evidence content. The navigation does not change readiness or evidence state. +- Public mobile workflow audit: fresh desktop and `390x844` phone review passed for Home, Stock Selector, Single-Stock Report, Data Health, and Proof History. A later regression audit found that the Single-Stock Report direct-open loading state again placed three large quick-read cards before the evidence handoff. The loading state now renders the same compact selected-ticker answer as the completed report, preserving Selected ticker -> `Use now` -> `Still withheld` -> `Open Data Health` before provider and report work. Browser measurement verified the 44px handoff fully inside the phone viewport with at least 50px of bottom clearance, no horizontal overflow, and no traceback; desktop remains a four-column layout with the same handoff visible. This is local product-layout evidence only—not hosted, accessibility-conformance, external-reviewer, freshness, demand, or market evidence—and changes no readiness, source, research, or generated-artifact state. +- Public packaging reconciliation: README begins with one external-reviewer entry point, names Research Desk -> Discover -> Company Workbench -> Monitor as the primary product workflow, and keeps the five-page Public path secondary. LinkedIn copy uses `Stock Research Command Center | Evidence-First Company Research`, requires the stable GitHub link only after the feature reaches the default branch, and labels any draft branch/PR link `Draft engineering preview`. `docs/assets/linkedin-public-dashboard.png` is a reviewed real-app `1200x627` AVGO Workbench answer image without volatile readiness figures. Browser QA binds it to the Workbench route and answer markers, while Commercial Research performance uses `Use now` as the Workbench first-useful marker. This is packaging and local runtime evidence only, not proof of external sharing, hosted availability, reviewer understanding, demand, competitive position, or market validation. +- Implemented locally: read-only immutable-package status/preview with ten independent states: manifest, technical, temporal, identity, membership, corporate action, delisting, source rights, reproduction, and leakage. +- Reproduction contract: `membership_count_and_sha256_at_cutoff_v1`. +- The only point-in-time universe operating entries are `make point-in-time-universe-status MANIFEST=` and `make point-in-time-universe-preview MANIFEST= TOP_N=20`; they do not fetch, write, apply, rebuild readiness, or activate analysis. +- Synthetic fixtures remain test-only and local software evidence cannot complete Priority 4. +- This local evidence does not change independent readiness for actuals, consensus, Revenue, EPS, valuation, catalysts, outcomes, backtesting, or calibration. +- It does not provide investment advice; numerical probability remains unavailable without calibration; Q4 evidence and EPS split-basis compatibility remain explicit; synthetic evidence stays test-only; candidate peer evidence remains candidate-context-only. +- Proof-readiness reconciliation: run `make proof-readiness-reconciliation TOP_N=20` before reusing a supporting proof outcome. It maps only explicit fundamentals, DCF, share-count, price, peer-mapping, and peer-valuation proof lanes to their independent current fields. Only `explicit_ticker_change` is ticker-level support when the latest proof explicitly names that ticker in `changed_tickers`; keep scope-only outcomes non-supporting even when the ticker appears in a batch scope. `proof_applicability` and `current_blocker_code` are independent; route each current blocker, including `current_canonical_row_missing`, to its named safe review. Current blocker diagnosis does not establish the historical cause; never infer historical source, rights, scope, or cause from narrative proof. `historical_supported_currently_blocked` means historical support cannot be treated as current support; current saved readiness remains authoritative. Advanced Proof History shows global and selected-ticker conflicts before raw ledger detail, while the four primary research routes remain unchanged. The command is read-only and does not restore canonical data, rewrite proof history, promote readiness, prove source rights, field scope, provenance, payload truth, commercial use, or any external gate. Any reported count is a current-snapshot audit finding, not a durable coverage total. The implemented structured per-ticker/per-field record is prospective-only and does not retroactively upgrade existing narrative proof. +- Stale readiness continuation gate across project status, Session Source Preflight, provider setup, coverage frontier, Auto-Refresh Status, its runbook, Advanced Data Health cards, and the commercial-beta release path: while readiness is stale or incomplete, `make readiness-preview TOP_N=20` is the only continuation-safe command; source availability, provider, scheduled-operation, and ranked coverage details remain planning context only, and `make readiness` is a separate intentional reviewed write. +- Peer evidence-quality contract separating relationship provenance, peer role, economic comparability, result context, trend readiness, and valuation-anchor eligibility. +- In-memory quarterly cash-generation evidence contract with independent operating-margin, free-cash-flow, and FCF-margin states, explicit filed-Q4 enforcement, Advanced-only source lineage, and no supplemental data file, writer, template, report, or generated artifact. +- Quarterly adapter acceptance harness for one in-memory company batch, with deterministic identity, commercial-rights, supported-field, cutoff, revision, component, compatibility, complete-period, and Q4 blockers; `accepted_for_review` always leaves production activation false and readiness promotions empty. +- Read-only SEC quarterly cash-generation source preview: `make sec-quarterly-cash-preview AS_OF=` fetches only exact Companyfacts, submissions, and primary-filing endpoints in memory. NVIDIA Q1 FY2027 accession `0001045810-26-000052` and AMD Q1 FY2026 accession `0000002488-26-000076` passed with exact UTC acceptance time and explicit filed-table capex outflow proof; they created no generated artifact and did not activate Company Workbench or readiness. +- Explicit Company Workbench cash-generation preview: only the opt-in `cash_preview=1` NVIDIA and AMD routes compose the accepted SEC result at the fixed review cutoff. They use complete withholding, label every value preview-only rather than ready, keep technical lineage under Advanced, leave `production_activation=false` and `readiness_promotions=()`, make no canonical persistence or generated artifact, and do not alter the normal Workbench route. + +Truth boundaries that must remain unchanged unless direct evidence proves otherwise: + +- Research-only; no investment advice, broker integration, order routing, auto-trading, direct buy/sell instructions, or post-earnings price prediction. +- Real-company Earnings Nowcast remains blocked until compatible quarterly actuals and exact-period point-in-time consensus independently pass. +- Numerical Beat/Miss probability remains withheld until at least 100 valid leakage-safe events pass Brier-score, calibration-bin, and benchmark-improvement gates. +- EPS split basis remains unverified without explicit primary proof. The Companyfacts unverified sentinel must remain blocked from display comparisons, model eligibility, backtest outcomes, and benchmark math; matching sentinel labels or commercial field scope are not proof. +- Q4 actuals require explicit SEC-filed Q4 result-table evidence; do not derive Q4. +- Synthetic fixtures are test-only. +- Empty valuation, catalyst, outcome, and consensus ledgers remain visibly empty. +- Candidate context cannot modify deterministic forecasts or become trusted evidence. +- A source-backed peer relationship is not automatically a valuation anchor. Only explicitly reviewed `core_peer` or `secondary_peer` rows with source/as-of provenance, relationship rationale, comparability basis, and `valuation_anchor_eligible=yes` may enter peer medians. +- Operating margin, free cash flow, and FCF margin remain withheld in Company Workbench until a separate activation review connects compatible explicit observations. The NVIDIA source preview is `accepted_for_review`, not production input. Their readiness cannot promote Revenue, EPS, DCF, consensus, peer, catalyst, outcome, backtest, or calibration states. +- Quarterly adapter acceptance is a local review-routing decision only. It cannot change source rights, load or write an adapter file, supply Company Workbench production observations, or promote readiness. +- Declared source dates newer than the saved readiness build must keep pilot, reviewed-batch, project-status, and profile-context freshness stale. File mtimes cannot override that evidence; only an intentional reviewed `make readiness` run can rebuild the generated snapshot. +- `make readiness-preview TOP_N=20` is inspection evidence only. It must not create or modify CSV, JSON, report, sample-report, screenshot, timing, directory, or bytecode artifacts; it cannot authorize a rebuild, promote readiness, or make stale counts current. +- Proposed technical readiness movement is independent from promotion evidence review. Missing or duplicate canonical evidence, incomplete provenance, unapproved exact-source rights, missing registered support for required fundamentals fields, and unproven DCF price-source lineage must remain explicit and cannot be converted into rebuild approval. +- DCF price availability is independent from price lineage, exact-source commercial rights, and registered price scope. Missing or duplicate latest rows fail closed; missing or composite provider IDs are never inferred. The price-lineage audit cannot change technical readiness, canonical prices, valuation, source rights, freshness, or rebuild authorization. +- Prospective lineage preservation is not evidence activation. A `lineage_complete` staged row still requires exact-source rights, registered `prices` support, payload review, explicit apply intent, rebuilt readiness, and proof. Do not treat temporary fixtures or schema support as a real provider row. +- Staged rights/scope review is not source approval or apply authorization. An approved registry result only reports current checked-in rights and field-scope metadata for the exact source ID; it cannot prove the payload, reviewer decision, freshness, canonical activation, rebuilt readiness, or market validation. +- Commercial apply-guard passage is not evidence activation or automatic mutation. It is a necessary pre-write condition after an explicit apply request; payload review, reviewer intent, freshness, readiness rebuild, proof recording, and downstream use remain separate. +- Direct-refresh guard passage is not provider approval, payload proof, row-level lineage, freshness, readiness, or operating evidence. The checked-in registry still has no approved direct price provider with registered `prices` scope, and temporary test registries cannot change that state. +- Daily-price temporal passage proves only conservative local ordering against one supplied cutoff. It does not establish exchange close, provider publication time, payload correctness, source rights, reviewer approval, canonical freshness, readiness, or safe concurrent operation. Atomic replacement narrows partial-file risk only; backup plus replace is not a database transaction or crash-recovery system. +- Cohort saved-row, adjusted-price, or quarterly metric scope passage is display eligibility for one exact lane only. It cannot validate the payload or retrieval chronology, approve a source, repair rejected rows, resolve revisions, verify EPS split basis, establish Q4 proof, change readiness, activate consensus or nowcast, promote a peer role, or grant another lane. +- Canonical quarterly dashboard consumption is whole-ledger fail-closed: any rejected CSV row blocks accepted-subset Business Trend and focused-cohort quarterly packets. Row-numbered reasons remain under Advanced; no rejected row is repaired, inferred, or promoted. +- Non-empty historical valuation, supported catalyst, and reviewed outcome packets require approved rights for every exact source plus literal `valuation_history`, `catalyst_evidence`, or `research_outcomes` scope in Commercial Research composition. One failed row blocks that scoped result; research-mode review stays independent and empty ledgers remain empty. +- Consensus commercial-guard passage is not point-in-time evidence activation. Technical append eligibility, commercial rights, Revenue scope, EPS scope, payload review, historical availability, comparability, nowcast readiness, backtesting, and calibration remain independent. The checked-in registry currently has no approved prospective-consensus source or consensus scope, and temporary fixtures cannot change that state. +- Consensus batch preflight is not a database transaction, concurrent-writer lock, crash-recovery mechanism, scheduler, or evidence source. It aligns deterministic preview and record decisions only; it cannot supply or approve a provider, repair lineage, reorder revisions, change rights, create readiness, or satisfy calibration. +- Consensus preview receipts prove only that the exact locally reviewed cutoff, mode, input bytes-as-records, and saved ledger state were re-evaluated consistently. Any change requires a fresh preview; a matching receipt does not prove payload truth, publication availability, rights, reviewer judgment, readiness, backtesting, calibration, or safe concurrent operation. +- Consensus source validation is a review-routing contract, not permission, collection, or activation. `historical_evidence_reviewable` cannot prove payload correctness, source rights, freshness, point-in-time depth, nowcast readiness, backtesting, or calibration, and no caller-provided label can upgrade those states. +- Consensus source cutoff passage proves only local timestamp ordering against the supplied review boundary. It cannot infer provider publication time, establish availability, validate a payload or source reference, grant rights, create freshness, collect evidence, or promote readiness, backtesting, or calibration. +- Shared commercial field-scope review proves only consistent checked-in registry metadata for one exact source and ordered required fields. It cannot prove a payload, timestamp, comparability, reviewer intent, collection, activation, readiness, backtesting, calibration, or any non-consensus evidence domain. +- Consensus source review is a read-only local contract evaluation, not provider entitlement, payload approval, collection, activation, readiness, backtesting, or calibration. Human or JSON output and `auto_apply=false` cannot promote a row or satisfy an external gate. +- A checked-in GitHub Actions workflow proves only the intended automation contract. Independent hosted CI requires a completed GitHub result on the current commit; a green automated check is not independent human review and proves no source, hosted-product, reviewer, calibration, adoption, or market claim. +- README, LinkedIn copy, committed screenshots, browser-QA markers, and local timing are public-packaging evidence only. They cannot prove data freshness, changing readiness totals, hosted availability, external reviewer comprehension, adoption, competitive differentiation, demand, or product-market fit. Do not publish the stable default-branch link until the reviewed feature is actually present there; label branch or PR links as draft previews. +- Subagent review is engineering review only; no GitHub human reviews exist. +- Named change causes explain existing method decisions only. They cannot alter scope, upgrade partial data, establish current readiness totals, create a company-quality judgment, or authorize a rebuild. +- The stale readiness continuation gate must suppress broad refresh, source-proof, apply, and readiness-rebuild routing from status surfaces. It does not change provider availability or ranking calculations; those remain planning context only until readiness is current or the separate intentional reviewed write is authorized. + +Current local status boundaries, not external-dependency classifications: + +- Quarterly cash-generation source adapter: `bounded_two_company_source_preview_accepted_for_review`; NVIDIA Q1 FY2027 and AMD Q1 FY2026 establish bounded two-company portability only. The superseded NVIDIA-only label was Quarterly cash-generation source adapter: `one_company_source_preview_accepted_for_review`. Production activation, broad coverage, historical depth, and Q4 coverage remain unproven; do not repeat the NVIDIA pilot or AMD pilot unless its exact filing changes or a regression is suspected. +- Quarterly adapter composition remains explicit preview only. It is not canonical evidence activation; the completed bounded second-company proof does not authorize broader scope. Do not add a third company, persistence, or readiness promotion without a separate exact-source design and review. +- Readiness promotion evidence: `local_evidence_review_required`. Continue only with local no-write lineage validation; do not authorize generated rebuilds or edit source rights without reviewed evidence. + +Current external dependency classifications to verify once, then avoid looping: + +- Point-in-time consensus and rights: `permitted_point_in_time_consensus_and_rights_required`. Last observed: no permitted point-in-time consensus input or approved exact-source consensus rights evidence is on record. Exact unblock condition: configure a permitted point-in-time provider or supply a reviewed CSV, approve rights for that exact source and every populated consensus field, then run source review and collection preview without inferring a provider or recording readiness. +- Hosted account and controls: `hosted_account_and_controls_required`. Last observed: no verified hosted URL or enforced hosted access boundary is on record. Exact unblock condition: provide an intentional host account, a directly verified URL, enforced claimed access controls, and user/workspace isolation in that environment. +- Independent reviewers: `independent_reviewers_required`. Last observed: no independent human GitHub pull-request review or completed independent beta-review cohort is on record. Exact unblock condition: obtain independent human GitHub review of PR #113 for the engineering-review gate and complete controlled sessions with 10-20 independent task-based reviewers, recording reproducible observations for beta validation. Subagent and automated review satisfy neither condition. +- Trusted peer/source review: `trustworthy_peer_source_and_review_required`. Last observed: no trustworthy reviewed peer relationship evidence is on record for the bounded pilot. Exact unblock condition: supply a trustworthy relationship source and review capacity sufficient to preserve one bounded reviewed peer relationship, including role, rationale, comparability, source/as-of evidence, and explicit valuation-anchor eligibility. +- Calibration cohort: `calibration_cohort_required`. Last observed: calibration evidence remains insufficient and numerical probability is withheld. Exact unblock condition: accumulate at least 100 leakage-safe out-of-sample events and pass the declared Brier-score, calibration-bin, and benchmark-improvement gates. +- Operated owner/incident/rollback capacity: `operated_owner_incident_rollback_capacity_required`. Last observed: no named operated owner or directly rehearsed incident and rollback capacity is on record. Exact unblock condition: provide a named owner and directly rehearsed incident and rollback capacity, then directly verify audit, retention, entitlements, monitoring, and health-check operation. A local runbook or hosted URL is insufficient. +- Point-in-time benchmark/universe data and rights: `point_in_time_benchmark_universe_and_rights_required`. Last observed: no permitted point-in-time benchmark and universe dataset with approved exact-source rights and independent review is on record. Exact unblock condition: Priority 4 remains open until one bounded permitted real dataset is independently reviewed, reproduces the expected membership count and digest, and passes rights, identity, corporate-action, delisting, survivorship, cutoff, partition, reproduction, and leakage gates. Do not retry an unavailable provider or infer data access, source identity, field scope, or rights. +- Accessibility manual-review environment: `accessibility_manual_review_environment_required`. Last observed: `docs/ACCESSIBILITY_EVIDENCE.md` records a partial local desktop/phone workflow audit plus directly retested primary-link contrast, route-preserving skip-link, and route-heading hierarchy corrections, but no stable `main` landmark or complete keyboard, zoom, forced-colors, or assisted-technology review is on record. Exact unblock condition: verify a suitable local or supervised review environment, execute the remaining landmark, keyboard, zoom, reflow, forced-colors, reduced-motion, and screen-reader tasks, and record reproducible findings and retests. +- Paper-position laboratory design approval: `paper_position_lab_design_approval_required`. Last observed: no separately approved hypothetical paper-position laboratory design is on record. Exact unblock condition: complete the research-only design, private-data policy, language review, misuse analysis, and acceptance plan, then obtain separate explicit approval before implementation. + +Approved Next-Stage Maturity Program: + +This is the authoritative priority order after the completed Research Decision Lab. At the beginning of each loop, select the first incomplete priority with a safe executable task. When the next requirement is unavailable, classify its last evidence and exact unblock condition once, leave it incomplete, and move to the next safe executable priority. Re-enter a blocked priority only after relevant external state changes. + +A blocked priority does not become complete, and moving past it does not weaken its exit gate. Local contracts, fixtures, screenshots, subagent reviews, and green tests cannot substitute for direct source, hosted, accessibility, independent-user, or calibration evidence. Continue cycling through the ordered program while safe executable work exists; overall completion requires direct current evidence for every applicable priority. + +Priority 1 — Legacy portfolio, ranking, and action-language quarantine +- Priority 1 is complete locally. The five retained compatibility pages are Operator-only, Public and Personal Research deep links fail closed, and every retained page is labeled `Legacy research utility — not part of Personal Research Mode` with details behind an explicit collapsed control. +- Direct route, wording, and source-contract tests prove retained legacy output cannot feed Company Workbench, Research Decision Lab, readiness, recommendations, sizing, or transaction behavior. Historical calculations and filenames remain compatibility-only. +- Inventory every Personal Research, Public, Advanced, operator, report, test, and documentation surface that exposes legacy portfolio, ranking, position, cost-basis, or transaction-like language. +- Remove those concepts from supported primary flows and public claims, or isolate them behind an unmistakable `Legacy research utility — not part of Personal Research Mode` boundary. +- Quarantine legacy picks, disposition, entry-zone, add-candidate, position-percentage, cost-basis, and ranked-company labels. Prove retained compatibility code cannot feed the Decision Lab, conclusions, readiness, recommendations, sizing, or transaction behavior. +- Exit only when route, wording, report, and no-trading tests directly prove the isolation. + +Priority 2 — Stage B field-proof audit and operator hardening +- Priority 2 is complete locally. `make prospective-field-proof-audit` provides read-only append history, active-head state, current blocker explanations, and controlled invalid-ledger routing; preview provides per-row explanations and reports `preview_receipt_persisted=false` and `receipt_revalidation_required=true`. +- Direct byte-snapshot tests prove audit and preview create no ledger, readiness, canonical-data, report, screenshot, timing, or generated artifact. Audit does not activate readiness, does not update canonical data, and does not activate Company Workbench. +- Complete the approved read-only audit for prospective field-proof records, preview receipts, append-only history, blocker explanations, and operator review ergonomics. +- Preserve no readiness mapping, no canonical-data change, no Company Workbench activation, and no automatic evidence recording. +- Exit only when focused read-only evidence proves clearer deterministic review and errors with no ledger or generated-artifact write. + +Priority 3 — In-app research-record authoring +- Priority 3 — completed locally after direct desktop/phone runtime review and the required automated acceptance matrix. The approved specification is at `docs/superpowers/specs/2026-07-22-in-app-research-record-authoring-design.md` and its test-first implementation plan is at `docs/superpowers/plans/2026-07-22-in-app-research-record-authoring.md`. Priority 4's local validator is frozen; its permitted real-data exit gate remains externally incomplete. Priority 6's provider-neutral authorization contract is complete locally; hosted implementation remains environment-dependent. Priority 7 accessibility remediation is the next safe executable local lane. +- The approved design uses one collapsed Company Workbench composer over the existing thesis-journal, catalyst, and outcome engines. A session-only receipt binds the exact draft, selected profile/ticker, destination ledger, and current ledger fingerprint; edits or concurrent appends require a fresh preview. Tests must use temporary ledgers and must not append production research data. +- Hardening commit `07758114c` closes the confirmation race: all three append engines share one resolved-ledger cooperative lock, receipts bind resolved ledger identity, every new preview resets confirmation, and uncertain post-append teardown requires one-shot read-side reload before success. +- Final integrity commit `e3a090dba` ensures confirmation appends only the receipt-matched recomputed record and enforces one readable active thesis lineage: revisions must supersede the exact active entry and preserve its thesis ID. The Company Workbench locks and explains that relationship, with temporary-ledger create -> revise -> reload coverage. +- Confirmation-integrity commit `5a6c55921` binds every displayed preview field, preview time, and destination label to the exact receipt. If an append raises after it may have written, confirmation returns one-shot `save_pending_reload` with the exact record ID unless the locked ledger is provably unchanged; it never invites a blind duplicate retry. +- Design and implement simple validate -> preview -> confirm authoring for thesis, evidence, catalyst, and outcome records. +- Keep records append-only, profile/ticker scoped, reviewer-authored, timestamped, source-aware where applicable, and independently valid or blocked. Drafts and candidate context remain untrusted; automated generation cannot become reviewer-authored evidence. +- Exit only when desktop/phone workflow evidence and persistence, rejection, identity, provenance, empty-state, and no-fabrication tests prove safe command-line-free authoring without changing forecasts, probabilities, recommendations, or readiness. + +Thesis, evidence, catalyst, and outcome records are all available in the collapsed Company Workbench composer. +A valid record requires an exact preview and explicit confirmation before save. +Drafts are untrusted and preview receipts are session-only. +Production tests never append repository ledgers; persistence tests use temporary ledgers. +A saved record cannot change readiness, forecasts, probabilities, recommendations, or any other ledger. + +Priority 4's local validator is frozen; its permitted real-data exit gate remains externally incomplete. +Priority 6's provider-neutral authorization contract is complete locally; hosted implementation remains environment-dependent. +Priority 7 accessibility remediation is the next safe executable local lane. +That summary is necessary but not sufficient; the exact Priority 4 exit condition below also requires independent review, expected count/digest reproduction, and the partition gate. + +Priority 4 — Point-in-time benchmark and universe foundation +- The provider-neutral, read-only first slice is implemented locally against the approved design at `docs/superpowers/specs/2026-07-23-point-in-time-universe-foundation-design.md`; reverify its ten independent states and no-write behavior from current repository truth. +- Completed review and remediation chronology is preserved in `docs/internal/POINT_IN_TIME_UNIVERSE_REVIEW_HISTORY.md`. Priority 4 remains externally incomplete; this continuation contract retains only the current operating boundary and exact external exit gate. +- Local resource budgets for one supplied package: preview sample 100 rows; manifest 1 MiB; each contract CSV 32 MiB; four contract snapshots combined 64 MiB; source-rights registry 4 MiB; declared rows 250,000 per contract; package traversal 32 entries. +- Source-bearing raw rows retain independent exact-source rights review even when exact-row technical parsing fails; walk-forward history is cutoff-relative; publication must precede or equal retrieval; regular-file snapshot reads retain at most the declared limit plus one byte from one descriptor; deeply nested manifest JSON or source-rights YAML fails with a stable readable input error instead of a traceback. Duplicate JSON/YAML mapping keys and malformed contract headers also fail nonzero, traceback-free, and write-free through the direct validator and CLI/Make boundaries. +- These local bounds do not prove scale, hosted reliability, or market readiness. +- No permitted independently reviewed real dataset, accepted expected count/digest, or source-rights proof is on record. +- Keep the foundation isolated from the current ticker-centric universe merge path. Stable security identity, timestamped membership, corporate-action/listing-status events, evaluation cutoffs, immutable manifests, exact-source rights, revision lineage, and deterministic membership digests remain fail-closed and independent. +- Use only the two documented read-only status/preview entries. Do not fetch, write normalized artifacts, apply rows, rebuild readiness, or activate analysis. +- Explicitly control corporate actions, delistings, survivorship, and leakage; never substitute current constituents, identifiers, prices, or fundamentals for historical state. +- Preserve leakage-safe partition or walk-forward boundaries and reproducible benchmark diagnostics. +- Keep this priority open under the single registered external dependency condition above. Schemas, fixtures, and local green gates are insufficient. +- Keep the synchronized Priority 4 local validator frozen unless a newly reproduced Critical or Important defect requires reopening it. One bounded permitted real dataset remains the exact external exit gate. While that dataset and the independent Priority 5 consensus/peer evidence are unavailable, keep Priority 6's completed local policy contract separate from unavailable hosted implementation and advance Priority 7 accessibility remediation. Do not retry unavailable providers or recommit the already synchronized package. + +Priority 5 — One permitted consensus source and one reviewed peer relationship +- Review one permitted point-in-time consensus source for one ticker and exact fiscal period, then perform separate human evidence-preserving mapping into the prospective schema and run the collection preview. +- Only if the exact input, ledger, cutoff, mode, and receipt remain unchanged may the operator run `CONFIRM_REVIEWED=1 make earnings-consensus-collection-record INPUT=$COLLECTION_INPUT AS_OF= PREVIEW_RECEIPT=`. +- Treat the result as an append-only evidence record; it does not activate readiness or numerical probability, and it cannot bypass actuals, Revenue/EPS comparability, backtesting, or calibration gates. +- Establish one genuinely reviewed peer relationship with role, rationale, comparability basis, source/as-of evidence, and an explicit independent valuation-anchor decision. +- Keep the two gates independent; do not broaden coverage or infer source, rights, peer trust, or valuation eligibility. +- Exit only when one real consensus snapshot and one real peer relationship independently pass technical, temporal, provenance, rights, scope, reviewer, and append-only evidence checks. This does not unlock probability. + +Priority 6 — Controlled hosted operating boundary +- The first local slice implements + `src.hosted_access_control.evaluate_workspace_access`, a pure, + provider-neutral, deny-by-default contract for authenticated-principal, + active-membership, workspace, role/resource/action, append-only + research-record, stable reason, and privacy-safe audit obligation decisions. + It does not prove hosted authentication, deployed isolation, persistence, + audit storage, retention, monitoring, rollback, incident response, operated + capacity, or market validation. +- Reverify final trust-boundary hardening commit + `90b441d25e97655e887803d88f4730497e71e383` or a later descendant and + exact-head CI before relying on the local contract. Do not create or change + hosted accounts, choose a provider, use credentials, deploy, or publish + without explicit approval. +- After this local policy slice passes, the next provider-neutral executable + lane is a separately reviewed retention/deletion or append-only audit-event + interface design. Provider-specific integration remains blocked until the + exact identity, storage, logging, host, and operating environment are + explicitly approved. + +- Before an environment is approved, implement only provider-neutral control contracts, threat boundaries, interfaces, and denial-test harnesses; do not silently choose an identity, storage, logging, or hosting architecture. +- Provider-specific implementation begins only after explicit approval of the exact identity, storage, host, and operating environment. Then implement authentication, private-workspace isolation, least privilege, secret handling, append-only audit logs, retention/deletion, monitoring, health checks, backup, rollback, and incident ownership against it. +- Test cross-user and cross-workspace denial, audit completeness, retention execution, alerts, recovery, and rollback. +- Do not create or change hosted accounts, purchase services, use credentials, deploy, or publish without explicit approval. +- Exit only when the actual approved environment directly proves every claimed control, including a named owner and observed rollback rehearsal. Local code or a URL alone is insufficient. + +Priority 7 — Accessibility evidence beyond screenshots +- Add automated semantic checks and direct keyboard, focus, label/error, resize, zoom/reflow, color/contrast, reduced-motion, and screen-reader task testing across Research Desk -> Discover -> Company Workbench -> Monitor on desktop and phone. +- Preserve the 2026-07-23 partial local audit in `docs/ACCESSIBILITY_EVIDENCE.md`: desktop and phone routes had no document-level horizontal overflow, visible controls retained accessible names, and the Monitor primary-link contrast defect was corrected from approximately `3.3:1` to `12.4:1`. Do not broaden that result into keyboard, screen-reader, zoom, forced-colors, or WCAG evidence. +- Preserve the second test-first slice: every Personal Research route now exposes a route-preserving `Skip to page answer` link and focusable answer target, primary sections use continuous `h1` -> `h2` hierarchy, the nested Monitor `h3` is non-empty, and desktop/phone retest found no horizontal overflow. This proves structural presence and heading order only, not complete skip-link keyboard behavior. +- Preserve the 2026-07-26 live-DOM correction: Streamlit had normalized the skip link to `target="_blank"`. It now uses a fragment-only `#public-page-answer` destination and explicit `target="_self"` so same-tab activation retains every current query parameter without a rerun and focuses the answer target. Direct ticker-bound Workbench pointer retest passed; synthetic Enter could not activate ordinary links in the available browser-control environment, so K01/K02 remain `blocked_environment`, not `passed_direct`. +- Preserve the 2026-07-27 target-size correction and its adversarial follow-up: direct Workbench audit measured the framework help target at `16x16`, dataframe toolbar buttons at `22x22`, and the phone Workbench action at `102x21`. The first correction enlarged only the help wrapper; expanded P02 review caught the actual button at `16x24`. The final selector targets the nested button. Desktop/phone retest measured actual help and dataframe buttons at `24x24`, the phone action at `102x24`, and no phone document overflow. This closes only the inspected P01/P02 defects, not full route coverage, keyboard, zoom, forced-colors, screen-reader, or WCAG evidence. +- Preserve the later 2026-07-27 direct Chrome keyboard run at exact commit `15c5270070238b91343a9f6eaa26f86527bfcaf2`: K02 same-page focus transfer and K03 route-rail navigation passed directly in the recorded desktop environment. K01 failed because Workspace help, Workspace selection, route selection, and the Streamlit main section precede the skip link. K04 opened filtered AVGO by keyboard but failed because every company action exposes the same accessible name `Open review`. K05 stopped at focused native disclosure summaries with no visible outline. This supersedes only the earlier input-environment limitation for the recorded Chrome run; it is not independent human, phone-keyboard, assisted-technology, or WCAG evidence. The proposed narrow remediation is not implemented until its design is reviewed. +- Preserve the same-day follow-up at `6aef0f8a9e89e8ec94d308095ca18ef33ea4177a`: real browser zoom remained unavailable, so Z01-Z03 are `blocked_environment`; `640px`/`320px` viewport stress found no document overflow but is not zoom evidence. At `390x844`, the inspected visible primary route and opened thesis-authoring controls had no sub-24px target or adjacent overlap. This is partial P02 evidence only; mobile-sidebar choices, all record kinds, all disclosure states, and pointer misactivation remain unproved. +- Preserve the exact-HEAD continuation at `1a8727706595f87df459eeb5c7c762dafad83577`: K08 passed directly in the truthful current Monitor state, and forward/reverse traversal found no keyboard trap. K09 nevertheless failed because every focused native `summary` still had computed `outline: none`. Direct accessibility-role inspection returned zero `main` landmarks on Research Desk, Discover, ticker-bound AVGO Company Workbench, and Monitor. The connected Chrome and in-app browser surfaces exposed viewport control but no supported forced-colors or reduced-motion activation; C01-C02 and M01 therefore remain `blocked_environment`, not passed. Screen-reader tasks remain `not_run`. +- Preserve the keyboard authoring continuation at `b1dd457b574045c06496fdf4ec6772dddfc7d751`: K06 reached every empty thesis field and `Validate and preview` with direct keyboard input. Rejected validation exposed `thesis_id is required` through one global `role="alert"` and correctly exposed no confirmation action, but Thesis Id had no `aria-invalid`, `aria-describedby`, or `aria-errormessage`, and focus remained on the validation button. K06 therefore failed pending field-level association and direct retest. K07 passed: keyboard route navigation opened Monitor, the one-line thesis ledger fingerprint remained unchanged, both absent catalyst/outcome ledgers remained absent, and no research record was written. S06-S07 remain `not_run`. +- Preserve the exact `390x844` phone continuation at `c748b192dd4fe27f1c17f2feba07eaccd24e4619`: K02 passed on ticker-bound AVGO Workbench, retaining every route parameter and transferring focus to the answer. K01 failed because Workspace help, Workspace selection, and route selection received focus at negative x coordinates inside the closed off-canvas sidebar before the main section and skip link. K03 changed routes deterministically with Arrow Down but failed visible focus because the selected route remained off-canvas; the sidebar-open control was hidden at `0x0` and absent from the recorded Tab sequence. Phone K04 reached search, filtered AVGO, and opened the correct `86x44` action, but the accessible name remained only `Open review` and the preceding summary still had no outline. P02 failed for mobile route choices: each route label measured approximately `104x50` but began at `x=-279`; the visible main document retained `390px` scroll and client widths. +- Execute `docs/ACCESSIBILITY_TASK_PROTOCOL.md` for direct task evidence. It records exact revision/environment metadata, keyboard-only navigation, write-free validation, zoom/reflow, forced-colors, reduced-motion, screen-reader, target-size, finding, and completion rules; the protocol is not completion evidence. +- The current Streamlit container API has no stable semantic-role parameter. Do not use unsafe JavaScript tied to framework-owned DOM selectors as a `main` landmark workaround without a separately reviewed design and direct runtime proof. +- Continue with the named open findings: a stable route-level `main` landmark, direct skip-link focus/transfer behavior, small framework controls, dynamic announcements, complete keyboard order, zoom/reflow, forced colors, and screen-reader tasks. +- Record environment, assistive technology, defect, severity, remediation, and retest evidence; never infer conformance from screenshots or automation alone. +- Exit only when automated and manual task evidence covers the complete workflow and all material defects are fixed or explicitly bounded. + +Priority 8 — Independent workflow validation +- The local protocol is ready: the invitation, feedback template, anonymous CSV schema, runbook, and closeout checklist use `Research Desk -> Discover -> Company Workbench -> Monitor`, prevent coaching, exclude owner/Codex/subagent/fixture/screenshot evidence, capture the complete scorecard, and require voluntary minimal-data capture with withdrawal and deletion handling. This is protocol evidence only; zero independent sessions are on record. +- Run 10-20 independent workflow sessions with the target researcher persona under a non-coaching protocol. +- Measure task completion, time to first useful answer, readiness comprehension, evidence tracing, authoring friction, trust, misuse risk, perceived performance, repeat-use intent, and the most important missing workflow without collecting investment recommendations or unnecessary personal data. +- Exit only when all sessions have anonymized evidence, reproducible findings, severity decisions, and material-defect retests. Automated, subagent, owner, or screenshot review does not count. + +Priority 9 — Out-of-sample calibration cohort +- Accumulate at least 100 valid leakage-safe out-of-sample events from permitted point-in-time inputs with immutable cutoffs, comparable actuals, revision lineage, exclusions, and reproducible cohort membership. +- Evaluate predeclared Brier-score, calibration-bin, benchmark-improvement, missingness, and stability gates without post-hoc threshold selection. +- Exit only when every declared gate passes on direct current evidence. Numerical Beat/Miss probability remains withheld before that point and remains withheld after a failed gate. + +Priority 10 — Separately approved hypothetical paper-position laboratory +- Produce or refine only a separate research-only design, private-data policy, language review, misuse analysis, and acceptance plan for user-authored hypothetical paper positions. +- Do not implement until that design receives separate explicit approval. It must prohibit recommendations, model-generated sizing, allocation instructions, live holdings, account imports, broker connections, order routing, auto-trading, price-triggered stop/profit instructions, and investment-performance claims. +- Keep hypothetical state isolated from evidence truth, ranking, deterministic forecasts, readiness, and calibration. Live brokerage remains out of scope permanently. +- Exit only after separate approved design and direct acceptance evidence; this continuation prompt does not itself grant that approval. + +Execution order for each continuation: + +1. Verify current branch, worktree, latest commits, upstream alignment, PR #113 draft status, ROADMAP.md, generated-artifact hygiene, current tests, and current product gates. +2. Review unresolved PR feedback and roadmap claims against live code and runtime behavior. +3. Before reusing a supporting proof outcome, run `make proof-readiness-reconciliation TOP_N=20`; keep `historical_supported_currently_blocked` lanes blocked, keep scope-only outcomes non-supporting at ticker level, route each current blocker to its named safe review, and move to fresh evidence or another executable lane. Never infer historical source, rights, scope, or cause from narrative proof; the implemented structured per-ticker/per-field record is prospective-only and does not retroactively upgrade narrative history. +4. Audit the complete user workflow: Research Desk -> Discover -> Company Workbench -> Monitor. + Also verify that README, LinkedIn copy, the curated Workbench image, and share checks describe that primary workflow consistently and keep the Public five-page path secondary. +5. Reverify every Research Decision Lab acceptance criterion at the current HEAD. If current evidence remains green, record the local milestone once and do not repeat its implementation. Select the first incomplete safe executable item in the Approved Next-Stage Maturity Program; begin with Priority 1, then Priority 2. If a regression is directly reproduced, fix one coherent slice test-first before continuing. +6. Implement one coherent slice with failing tests first where behavior changes. After it is verified and synchronized, rescan Priorities 1-10 from the top; resume the earliest incomplete executable item and move to the next safe executable priority whenever its next gate is externally blocked. +7. Preserve independent readiness for actuals, consensus, Revenue, EPS, operating margin, free cash flow, FCF margin, valuation, trusted relationships, peer comparability, peer valuation anchors, catalysts, outcomes, backtesting, and calibration. +8. Keep technical evidence under Advanced unless it is required to explain the primary research answer. +9. Classify unavailable external dependencies once and move to the next executable local roadmap item. +10. Update tests, methodology, provenance, runbooks, ROADMAP.md, and this prompt when the verified stage or continuation contract changes. +11. Stage exact intentional paths only, commit the verified slice, push only the named branch, and update PR #113 while keeping it draft. +12. Continue to the next safe executable item rather than ending merely because one slice is complete. + +Continuation maturity lanes: + +Stage A-G labels are continuation maturity lanes only; they do not replace the numbered Stage 0-6 exit gates below. Stage A is the implemented isolated prospective field-proof primitive, and the Research Decision Lab local milestone is complete when current evidence reconfirms it. Stage B — local field-proof audit and operator hardening is the second approved local priority after legacy surface quarantine: improve read-only audit, error explanation, and operator review ergonomics only, with no readiness mapping. Mapping field proof into proof-readiness reconciliation, Company Workbench, canonical data, or any readiness lane requires a separate design and approval. Later lettered lanes remain planning labels until separately designed; they grant no release-stage exit. + +Numbered release stage gates: + +Stage 0 — Independent engineering legitimacy +- Keep the minimal PR-only workflow read-only, least privilege, and free of providers, readiness generation, schedules, secrets, deployment, and artifact uploads. +- Require a completed GitHub Actions result for the current PR revision; do not infer hosted execution from local workflow tests. +- Require independent human GitHub review of PR #113 and classify it separately from automated checks. +- Hosted automation passed on the recorded implementation lineage. Exit remains revision-specific: require the current revision's direct CI result and an explicitly known review state. A green check does not satisfy human review or any later source, hosted-preview, beta, evidence-depth, calibration, or operating gate. + +Stage 1 — Answer-first workflow hardening +- Exit only when all four Personal Research routes show the primary answer and one next action before technical evidence at desktop and phone widths. +- Current expected state: locally completed, including the verified mobile first-action density slice, Company Workbench anchored-answer correction, and public packaging reconciliation. Treat it as current-revision evidence only after focused/full tests, release and hygiene gates, exact push, and exact-head CI pass; local browser measurements, screenshots, and copy do not satisfy hosted, human-review, adoption, demand, competitive, or market gates. + +Stage 2 — Permitted source activation +- Acquire one permitted append-only prospective point-in-time consensus snapshot for one reviewed ticker. +- Require exact fiscal period, independent Revenue/EPS comparability, durable source reference, publication and retrieval timestamps, provenance, freshness, revision handling, and suitable usage rights. +- Require the exact source-rights record to approve commercial use and list each populated metric scope independently before a Commercial Research append; research-only collection does not satisfy this stage gate. +- Run validate, preview, rejection review, readiness, and provenance checks before any apply decision. +- Treat every supplied consensus CSV as one ordered batch: review batch-level technical and commercial blockers before record, and do not interpret a preflight pass as source, payload, readiness, or calibration proof. +- Treat upstream source validation as technical review plus independent registry evidence: a caller label is never rights proof, and historical-reviewable state is never activation. +- Require an explicit validation cutoff and exact history scope before source review; reject reversed or post-cutoff timestamps for candidate and historical rows alike. +- Require collection preview and upstream validation to retain parity through the shared exact-source field-scope decision; keep their technical acceptance, blocker wording, and write controls independently testable. +- Enforce the source-review-before-preview sequence across distinct input contracts: first set `SOURCE_INPUT=` and run `make earnings-consensus-source-review INPUT=$SOURCE_INPUT PROVIDER= AS_OF=`; separately review the payload and evidence; explicitly map accepted evidence into the existing prospective schema without inference; then set `COLLECTION_INPUT=` and run `make earnings-consensus-collection-preview INPUT=$COLLECTION_INPUT AS_OF=`. No local command performs or writes this mapping. Neither gate records a snapshot or rebuilds readiness. +- After the exact collection preview is separately reviewed, an explicitly authorized operator may run `CONFIRM_REVIEWED=1 make earnings-consensus-collection-record INPUT=$COLLECTION_INPUT AS_OF= PREVIEW_RECEIPT=`. This creates an append-only evidence record only; it does not activate readiness or numerical probability, build a forecast, prove comparability, or satisfy backtesting or calibration. +- Exit only when one repeatable source path demonstrates deterministic provenance, rights, freshness, failure handling, and append-only collection. + +Stage 3 — Controlled hosted preview +- Use the deterministic demo profile; keep secrets and local research outside Git. +- Verify the actual URL, claimed access control, user isolation, health checks, rollback, and desktop/phone workflow. +- Exit only when the hosted runtime directly proves those properties without unsupported claims. + +Stage 4 — Controlled beta validation +- Run 10-20 task-based reviewer sessions through the complete Personal Research workflow. +- Measure time to first useful answer, readiness comprehension, evidence tracing, authoring friction, trust, misuse risk, perceived performance, repeat-use intent, and the most important missing workflow. +- Record reproducible workflow defects only; reviewer feedback is not financial evidence. +- Exit only when reviewers complete the workflow without mistaking readiness, context, scenarios, or evidence gaps for advice or live-market certainty. + +Stage 5 — Evidence-depth expansion +- Run a 25-50 company trusted-peer pilot only after the single reviewed relationship in Priority 5 has direct evidence and trustworthy relationship sourcing and review capacity are demonstrably repeatable. +- Preserve peer role, rationale, comparability, source/as-of evidence, and explicit valuation-anchor decisions. +- Add optional earnings and estimates only from trusted, period-defined, source-backed rows. +- Expand valuation, catalyst, and outcome ledgers only from reviewed evidence. +- Continue chronological backtesting and calibration accumulation without relaxing the probability gate. +- Exit only when evidence depth expands through repeatable reviewed contracts without inferred broad coverage or readiness coupling. + +Stage 6 — Operating maturity and product direction +- Add scheduled collection, rotation, alerts, and monitoring only after one provider path proves deterministic limits, provenance, rejection handling, and proof recording. +- Keep imports behind validate, preview, reviewed apply/skip, rebuilt readiness, and proof recording. +- Directly verify authentication, isolation, audit, retention, entitlements, monitoring, health checks, incident/rollback procedures, and owner capacity before claiming an operated platform. +- Choose explicitly among portfolio-quality prototype, maintained research tool, or operated platform only after hosted-preview, beta, source, peer, and operating evidence exists. + +Verification after every meaningful implementation slice: + +- focused tests for changed modules; +- `python3 -m pytest tests -q`; +- `make dashboard-smoke`; +- `make research-dashboard-render-smoke`; +- `make public-wording-check`; +- `make public-check`; +- `make linkedin-share-check`; +- `make browser-qa-evidence`; +- `make pilot-readiness-check TOP_N=10`; +- `make diff-hygiene-summary`; +- `git diff --check`; +- relevant commercial performance or release gate when workflow/runtime behavior changes; +- `make staged-hygiene-check` and `git diff --cached --check` after exact staging. + +Release-matrix boundary: the controller owns the full release matrix, push, PR +update, and hosted exact-head CI verification. A documentation-slice worker may +run only its focused docs/render checks, stage its exact reviewed files, and +report the local evidence; it must not claim the full release matrix or hosted +CI has passed. + +Git and artifact rules: + +- Never use `git add -A`. +- Stage exact reviewed product, code, documentation, test, and template paths only. +- Never stage broad generated CSV, JSON, readiness-report, stock-report, sample-report, screenshot, or timing churn. One exact curated screenshot may be staged only when it is the explicitly reviewed product asset required by the current slice. +- Commit only coherent verified slices. +- Push only to `codex/personal-research-mode-mvp`. +- Keep PR #113 draft and update it after each verified implementation slice. +- Do not merge into main or deploy publicly without explicit approval. + +Completion audit before any completion claim: + +- Derive every requirement from this goal, ROADMAP.md, PR #113, relevant specs/runbooks, and the current repository. +- Map each requirement and exit gate to authoritative evidence: code, tests, current artifacts, runtime behavior, source rows, provider rights, hosted URL behavior, reviewer records, calibration outputs, or operating controls. +- Classify every item as proven, contradicted, incomplete, indirect, or missing. +- Treat indirect, stale, synthetic-only, fixture-only, screenshot-only, local-only, or repo-contract-only evidence as insufficient for broader claims. +- Keep the goal active whenever any applicable gate remains incomplete or unproven. +- Declare completion only when every applicable stage exit gate has direct current evidence and no required work remains. + +Required handoff after each slice: + +1. Repository and PR status. +2. Current product stage and capabilities. +3. Roadmap item worked. +4. Changes made. +5. Tests, runtime checks, and product gates run. +6. Commit and push status. +7. Generated artifacts excluded. +8. External dependencies classified without repeat loops. +9. Remaining product gaps by stage. +10. Exact next executable step. +11. Whether the branch is safe for draft review. +12. Confirmation that the overall goal remains active unless every completion gate is directly proven. +``` + +The persistence language is deliberately evidence-bound. It prevents a single external dependency from stopping local progress, but it does not authorize fabricated completion, infinite retries, merging, deployment, credential use, destructive actions, or material scope expansion. diff --git a/docs/internal/POINT_IN_TIME_UNIVERSE_REVIEW_HISTORY.md b/docs/internal/POINT_IN_TIME_UNIVERSE_REVIEW_HISTORY.md new file mode 100644 index 00000000..cfd8b50d --- /dev/null +++ b/docs/internal/POINT_IN_TIME_UNIVERSE_REVIEW_HISTORY.md @@ -0,0 +1,105 @@ +# Point-in-Time Universe Review History + +This internal audit record preserves completed Priority 4 remediation evidence +that no longer belongs in the active roadmap. + +It is historical engineering evidence, not current source, hosted, reviewer, +market, or calibration evidence. Current execution state and exit gates remain +in `ROADMAP.md`. + +## Review And Remediation Sequence + +The second through fourth fresh whole-branch reviews drove the raw-row rights, +cutoff-relative history, publication chronology, immutable bounded-read, +aggregate-budget, and structured-input parser closures. + +The fifth fresh whole-branch review confirmed those closures and found three +Important trust-boundary defects: C0/C1 characters in structural identifiers +could render the newline-delimited membership digest ambiguous and forge +public status lines, while manifest creation could predate its cutoff or bound +evidence. + +Commits `b2bbd9961` and `c643d066b` remediate those V5 findings locally with +one shared C0/C1 plus Unicode line/paragraph-separator boundary, safe +structural-token rendering, an explicit creation-at-or-after-cutoff manifest +gate, and exact-row chronology against every contract timestamp. + +The first independent R7 review found the Unicode separator and +`listing_state_after` bypass gaps; `c643d066b` closes them locally. + +The sixth fresh whole-branch review confirmed those closures and found one +remaining Important non-scalar input defect: lone Unicode surrogate code +points could reach public output. + +Commit `f143d48ed` rejects Unicode category `Cs` through the shared boundary +and defensively ASCII-escapes it while valid supplementary-plane scalars +remain deterministic. + +The seventh fresh whole-branch review confirmed the V6 correction and found +four further trust-boundary defects (two Critical, one Important, and one +Minor): duplicate JSON/YAML mapping keys could silently change manifest and +rights meaning; invalid or unresolved successor and listing-state evidence +could authorize stale original-member digests; malformed CSV headers could +discard contract bodies and continue; and non-RFC3339 manifest or policy +timestamps were accepted. + +The local seventh-review remediation rejects duplicate keys at every mapping +depth, requires strict RFC3339 UTC manifest and policy timestamps with at most +six fractional-second digits, stops malformed headers as package-level +input-identity failures, and enforces explicit policy/event/listing-state, +successor-identity, and membership-consistency gates without inferring or +repairing a successor or membership. + +An independent scoped re-review then confirmed the four original findings and +the two compatibility regressions were addressed. + +The eighth fresh whole-branch review then found three Critical, nine Important, +and two Minor defects across sub-microsecond ordering, event-time identity, +listing chronology and rights, walk-forward bootstrap aggregation, +identity/action reconciliation, eligible provenance, package-contained bounded +reads, manifest type handling, standalone rights loading, and literal-safe Make +arguments. + +Remediation 9A through 9G closed every finding test-first. Independent scoped +re-reviews confirmed no remaining Critical or Important finding in each +corrected scope; the two Minor contracts now reject identical issuer/security +IDs and recursively freeze manifest semantics. + +Freeze reconciliation consolidated 21 overlapping remediation test files into +six domain suites and one shared fixture module, removed one exact duplicate +plus cross-remediation private imports, and closed five additional local +correctness gaps: ambiguous parents cannot authorize forks; pre-action cutoffs +do not poison later required coverage; decision-consumed listing-state evidence +is retained in eligible provenance; manifest nesting is explicitly bounded; +and structural source IDs cannot forge status output. + +Full branch verification at freeze reconciliation is 4,084 passing tests, one +environment-limited socket test skipped, and one existing dependency +deprecation warning. + +The final fresh whole-slice review found one Important cutoff-relative event +regression; it was reproduced, fixed, and confirmed closed with no remaining +Critical or Important issue. The consolidated package was synchronized at +`69c49968e77bfd55fa259695089e1f34ac2fddfb`, and exact-head GitHub Actions run +`30185232040` passed the full test, dashboard-startup, Personal Research +render, public-wording, PR-range generated-artifact hygiene, and whitespace +matrix. + +Real-data evidence remains pending; Priority 4 remains externally incomplete. + +## Resource And Input Boundaries + +Local resource budgets for one supplied package: preview sample 100 rows; +manifest 1 MiB; each contract CSV 32 MiB; four contract snapshots combined 64 +MiB; source-rights registry 4 MiB; declared rows 250,000 per contract; package +traversal 32 entries. + +Duplicate JSON/YAML mapping keys and malformed contract headers also fail +nonzero, traceback-free, and write-free through the direct validator and +CLI/Make boundaries. + +These local bounds do not prove scale, hosted reliability, or market +readiness. + +No permitted independently reviewed real dataset, accepted expected +count/digest, or source-rights proof is on record. diff --git a/docs/internal/RESEARCH_DECISION_LAB_CONTINUATION_GOAL_PROMPT.md b/docs/internal/RESEARCH_DECISION_LAB_CONTINUATION_GOAL_PROMPT.md new file mode 100644 index 00000000..0310254e --- /dev/null +++ b/docs/internal/RESEARCH_DECISION_LAB_CONTINUATION_GOAL_PROMPT.md @@ -0,0 +1,114 @@ +# Research Decision Lab Continuation Goal Prompt + +Use this prompt to reverify the completed local Decision Lab and continue the next Commercial Research Beta maturity gate without repeating finished implementation. + +```text +/goal + +Continue the Stock Research Command Center in: + +/Users/yjian070/Documents/New project/.worktrees/personal-research-mode-mvp + +Start from current repository truth, never chat memory. + +Read: + +- docs/superpowers/specs/2026-07-22-research-decision-lab-design.md +- docs/superpowers/plans/2026-07-22-research-decision-lab.md +- ROADMAP.md +- docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md +- docs/METHODOLOGY.md +- docs/PROVENANCE_CONTRACT.md +- docs/PERSONAL_RESEARCH_MODE.md + +Expected state to verify, never assume: + +- Branch: codex/personal-research-mode-mvp +- Decision Lab Monitor anchor: c7ad977b3 or a later verified descendant +- Draft PR: https://github.com/YuzeJ21/Stock-Analysis/pull/113 +- PR remains open and draft +- Branch is pushed and aligned with origin +- Existing generated CSV/JSON/report/sample-report/screenshot/timing churn remains local and unstaged + +Current local milestone: + +Local Decision Lab implementation is complete when current evidence reconfirms all of the following: + +1. The immutable six-lane composition preserves independent Plan, Evidence, Invalidation, Scenario, Review trigger, and Learning states. +2. Company Workbench keeps Selected ticker -> Use now -> Still withheld -> Data Health first, then renders exactly one Decision Lab after What Changed. +3. Research Conclusion and Next Research Task remain authoritative and separate from the next process step. +4. Monitor keeps Weekly Research Summary first, then Research Discipline Review, then the independent Research change monitor. +5. Monitor preserves saved focused-cohort order, isolates an invalid ticker, and never ranks by severity, market value, expected return, or attractiveness. +6. Empty process evidence says only that no process item is due from saved reviewer-authored evidence; it makes no market-event, risk, or external-research claim. +7. Identities and technical evidence remain under collapsed Advanced sections. +8. The feature writes no journal, outcome, source, readiness, proof, report, screenshot, timing, or generated data artifact. + +Do not reimplement or redesign this milestone if current tests, runtime behavior, docs, branch state, and exact-head CI still prove it. Fix only a directly reproduced regression. + +Objective: + +Reverify the Decision Lab release evidence, then advance the highest-value executable Commercial Research Beta gate. Prefer a safe local reliability, evidence-depth, or operating-contract slice already approved by ROADMAP.md. If no such local slice remains, classify the exact external dependency and its unblock condition, then move to another executable lane. Never convert an unavailable provider, dataset, hosted account, reviewer, peer source, calibration cohort, or operating owner into fabricated completion evidence. + +Persistence contract: + +1. Verify branch, HEAD, upstream alignment, working tree, latest commits, PR #113 status, ROADMAP.md, generated-artifact hygiene, tests, runtime gates, and exact-head CI before editing. +2. Continue automatically while any safe, meaningful, approved local task remains. +3. Classify each unavailable external dependency once, record its exact unblock condition, avoid identical retry loops, and immediately continue another executable lane. +4. Work test-first, one coherent independently verified slice at a time. +5. After each slice, run focused and full tests, required renders, wording, performance, release, pilot-boundary, PR-range, diff, whitespace, and staged-hygiene checks. +6. Stage exact intentional code/test/docs/template paths only. Never use git add -A. +7. Commit and push only to codex/personal-research-mode-mvp, update draft PR #113, and require exact-head CI before the next slice. +8. Keep PR #113 open and draft. Do not merge or deploy publicly without explicit approval. +9. Do not run make readiness, broad refresh/import/apply commands, or generate/stage CSV, JSON, readiness reports, stock reports, sample reports, screenshots, or timing artifacts for this continuation. +10. Do not claim the broader goal complete while any applicable source, hosted, reviewer, calibration, demand, or operating gate lacks direct evidence. + +Research-only boundaries: + +- No investment advice, recommendation, company grade, expected-return score, or research-skill score. +- No buy/sell/hold/add/trim/reduce direction, allocation, recommended position size, entry, exit, stop-loss, take-profit, live holding, account import, cost basis, broker integration, order routing, auto-trading, or real transaction record. +- Candidate context cannot populate trusted evidence, change deterministic forecasts, or promote readiness. +- Synthetic fixtures are test-only; empty ledgers remain empty. +- Q4 actuals require explicit SEC-filed Q4-table evidence; EPS split basis remains unverified without explicit proof. +- Real-company Earnings Nowcast remains blocked without permitted point-in-time consensus; numerical Beat/Miss probability remains withheld without leakage-safe calibration evidence. +- Every readiness and process lane remains independent. + +Required verification for a changed slice: + +- focused tests for changed modules and contracts +- python3 -m pytest tests -q +- make dashboard-smoke +- make research-dashboard-render-smoke +- make public-wording-check +- make public-check +- make browser-qa-evidence +- make commercial-beta-performance-gate when Workbench or Monitor changes +- make commercial-beta-release-check +- make pilot-readiness-check TOP_N=10 +- make pr-range-hygiene-check +- make diff-hygiene-summary +- git diff --check +- make staged-hygiene-check and git diff --cached --check after exact staging + +Truthful maturity boundary: + +The local Decision Lab does not prove source coverage, predictive accuracy, investment performance, independent adoption, hosted reliability, commercial demand, competitive superiority, or product-market fit. Passing local tests or exact-head CI cannot satisfy those external gates. + +At each handoff report: + +1. Repository and PR status. +2. Current product stage. +3. Decision Lab regression audit result. +4. New maturity slice completed, if any. +5. Focused, full, runtime, performance, wording, release, and hygiene evidence. +6. Commit, push, and exact-head CI status. +7. Generated artifacts excluded. +8. External dependencies classified once with exact unblock conditions. +9. Remaining applicable gates. +10. Exact next executable step. +11. Whether the branch is safe for draft review. +12. Whether the local Decision Lab goal is complete and whether the broader Commercial Research Beta goal remains active. + +Do not stop after a green Decision Lab recheck while another safe approved local task remains. Do not claim external completion. If every remaining task genuinely requires unavailable external evidence or new authority, leave the broader goal active, publish the exact dependency ledger and resume condition, and do not retry unchanged blockers. +``` + +This continuation is persistent but evidence-bound: it prevents one external dependency from stopping other work without redefining completion or weakening the research-only contract. diff --git a/docs/superpowers/plans/2026-07-16-sec-quarterly-actuals-lineage.md b/docs/superpowers/plans/2026-07-16-sec-quarterly-actuals-lineage.md new file mode 100644 index 00000000..8b4b10c7 --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-sec-quarterly-actuals-lineage.md @@ -0,0 +1,638 @@ +# SEC Quarterly Actuals Lineage 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:** Build a preview-only SEC evidence pipeline that stages correctly identified Q1-Q4 Revenue and diluted GAAP EPS actuals for the five-company semiconductor Earnings Nowcast pilot without cumulative facts, period relabeling, split leakage, quarter gaps, or inferred Q4 values. + +**Architecture:** A focused `earnings_nowcast_sec_actuals` module normalizes raw Companyfacts records, establishes fiscal-quarter lineage from original current-quarter filings, preserves revisions, discovers explicit Q4 earnings-release exhibits, and writes only onboarding-compatible temporary evidence plus audit output. Existing readiness and model modules gain metric-specific continuity and split-basis gates; existing onboarding remains the final validate/preview boundary. + +**Tech Stack:** Python 3.12, standard-library dataclasses/HTMLParser/urllib, existing SEC provider cache APIs, CSV/JSON, pytest, Make. + +## Global Constraints + +- Research-only; no investment advice, broker integration, order routing, auto-trading, or direct buy/sell instructions. +- Never infer Revenue, EPS, Q4 values, fiscal periods, consensus, probabilities, or recommendations. +- Q4 must come from an explicit result table in an SEC-filed primary-source exhibit; annual-minus-nine-month arithmetic is prohibited. +- Historical evidence is append-only and cutoff-aware; later revisions never overwrite earlier evidence. +- Revenue and EPS readiness remain independent. +- Generated SEC staging CSV/JSON/rejected-row artifacts stay outside default staging. +- No automatic apply path is added. +- Historical point-in-time consensus remains an external input and must continue to block packet generation when absent. +- Numerical Beat/Miss probability remains hidden until at least 100 valid events pass calibration gates. + +## File Map + +- Create `src/earnings_nowcast_sec_actuals.py`: normalized fact types, Q1-Q3 lineage, SEC filing-index/exhibit parsing, Q4 extraction, staging CLI, and audit payload. +- Create `tests/test_earnings_nowcast_sec_actuals.py`: fixture-driven parser, lineage, revision, Q4, cutoff, and output-boundary tests. +- Modify `src/earnings_nowcast_readiness.py`: metric-specific fiscal-quarter continuity and split-basis eligibility. +- Modify `src/earnings_nowcast_model.py`: consume only the contiguous canonical metric window. +- Modify `tests/test_earnings_nowcast_readiness.py`: missing-quarter and split-basis regression tests. +- Modify `tests/test_earnings_nowcast_model.py`: prove no Q3-to-next-Q1 sequential calculation. +- Modify `tests/test_earnings_nowcast_backtest.py`: prove continuity and revisions remain cutoff-safe in walk-forward evaluation. +- Modify `Makefile`: add the narrow, read-only `earnings-nowcast-sec-actuals-stage` launcher. +- Modify `tests/test_launchers.py`: lock launcher arguments and no-apply boundary. +- Modify `docs/EARNINGS_NOWCAST_PILOT.md`, `docs/METHODOLOGY.md`, `docs/PROVENANCE_CONTRACT.md`, and `ROADMAP.md`: document actuals lineage, Q4, split, continuity, and remaining consensus dependency. + +--- + +### Task 1: Normalize SEC facts and extract Q1-Q3 fiscal-quarter lineage + +**Files:** +- Create: `src/earnings_nowcast_sec_actuals.py` +- Create: `tests/test_earnings_nowcast_sec_actuals.py` + +**Interfaces:** +- Consumes: raw SEC Companyfacts JSON and an ISO cutoff timestamp. +- Produces: `normalize_sec_duration_facts(payload: Mapping[str, object]) -> tuple[SecDurationFact, ...]`. +- Produces: `extract_q1_q3_lineage(ticker: str, payload: Mapping[str, object], *, cutoff: str, retrieved_at: str) -> ExtractionResult`. +- `ExtractionResult.rows` contains onboarding `QuarterlyActual` values; `ExtractionResult.audit_rows` records accepted and rejected source facts. + +- [ ] **Step 1: Write failing normalization and cumulative-fact tests** + +Create fixtures directly in `tests/test_earnings_nowcast_sec_actuals.py` so one Q3 accession contains: + +```python +def _fact(*, val, start, end, filed="2026-06-25", fy=2026, fp="Q3", frame=None): + return { + "val": val, + "start": start, + "end": end, + "filed": filed, + "form": "10-Q", + "accn": "0000000000-26-000001", + "fy": fy, + "fp": fp, + "frame": frame, + } + + +def test_q3_lineage_keeps_aligned_quarter_and_rejects_ytd_and_comparative_period(): + payload = companyfacts_fixture( + revenue=[ + _fact(val=30, start="2025-08-29", end="2026-05-28"), + _fact(val=12, start="2026-02-27", end="2026-05-28", frame="CY2026Q2"), + _fact(val=8, start="2025-02-28", end="2025-05-29", frame="CY2025Q2"), + ], + eps=[ + _fact(val=3.0, start="2025-08-29", end="2026-05-28"), + _fact(val=1.2, start="2026-02-27", end="2026-05-28", frame="CY2026Q2"), + _fact(val=0.8, start="2025-02-28", end="2025-05-29", frame="CY2025Q2"), + ], + ) + + result = extract_q1_q3_lineage("SYN1", payload, cutoff=CUTOFF, retrieved_at=RETRIEVED_AT) + + assert [(row.fiscal_period, row.revenue_actual, row.eps_actual) for row in result.rows] == [ + ("2026-Q3", 12.0, 1.2) + ] + assert {row.state for row in result.audit_rows} >= { + "accepted_explicit_quarter", + "cumulative_fact_rejected", + "comparative_period_relabelled", + } +``` + +- [ ] **Step 2: Run the focused test and verify RED** + +Run: + +```bash +python3 -m pytest tests/test_earnings_nowcast_sec_actuals.py::test_q3_lineage_keeps_aligned_quarter_and_rejects_ytd_and_comparative_period -q +``` + +Expected: collection/import failure because `src.earnings_nowcast_sec_actuals` does not exist. + +- [ ] **Step 3: Implement normalized fact types and deterministic Q1-Q3 selection** + +Implement these public types and functions: + +```python +@dataclass(frozen=True) +class SecDurationFact: + taxonomy: str + concept: str + unit: str + value: float + start: str + end: str + filed: str + form: str + accession: str + fiscal_year: int + fiscal_period: str + frame: str + + @property + def duration_days(self) -> int: + return (date.fromisoformat(self.end) - date.fromisoformat(self.start)).days + + +@dataclass(frozen=True) +class ExtractionAuditRow: + ticker: str + state: str + metric: str + fiscal_period: str + source_ref: str + detail: str + + +@dataclass(frozen=True) +class ExtractionResult: + rows: tuple[QuarterlyActual, ...] + audit_rows: tuple[ExtractionAuditRow, ...] + + +def normalize_sec_duration_facts(payload: Mapping[str, object]) -> tuple[SecDurationFact, ...]: ... + + +def extract_q1_q3_lineage( + ticker: str, + payload: Mapping[str, object], + *, + cutoff: str, + retrieved_at: str, +) -> ExtractionResult: ... +``` + +Implementation rules: + +- accept only `10-Q` and `10-Q/A` numeric duration facts; +- accept Revenue concepts in the design's priority order and diluted EPS only; +- accept 60-120-day quarter durations; +- choose the latest `end` within each accession/`fy`/`fp` as the current quarter; +- pair metrics only when accession/start/end/filed/`fy`/`fp` match; +- reject conflicting frame or concept values; +- construct the SEC accession URL from CIK and accession; +- set date-only Companyfacts `reported_at` metadata to `23:59:59Z` on the filed date and reject rows after cutoff; +- preserve a metric-only partial row when just Revenue or EPS is proven. + +- [ ] **Step 4: Add missing-frame, concept-conflict, partial-row, and cutoff tests** + +Add tests proving: + +```python +assert missing_frame_result.rows[0].revenue_actual == 12.0 +assert ambiguous_result.rows[0].revenue_actual is None +assert revenue_only_result.rows[0].eps_actual is None +assert post_cutoff_result.rows == () +assert "post_cutoff_rejected" in {row.state for row in post_cutoff_result.audit_rows} +``` + +Add a two-filing lineage test where the original Q2 filing establishes +`2025-Q2` for a period end and a later Q3 filing presents that same period as +comparative data with a different SEC `fy`/`fp`. Assert that the later row keeps +the original `2025-Q2` identity and becomes a revision candidate instead of +being relabelled from the later filing's metadata. Add a fail-closed case where +no original current-quarter filing can establish the comparative period. + +- [ ] **Step 5: Run the Task 1 tests and verify GREEN** + +Run: + +```bash +python3 -m pytest tests/test_earnings_nowcast_sec_actuals.py -q +``` + +Expected: all Task 1 tests pass. + +- [ ] **Step 6: Commit Task 1** + +```bash +git add -- src/earnings_nowcast_sec_actuals.py tests/test_earnings_nowcast_sec_actuals.py +git commit -m "Extract SEC quarterly actual lineage" +``` + +--- + +### Task 2: Preserve append-only revisions and write preview-only staging artifacts + +**Files:** +- Modify: `src/earnings_nowcast_sec_actuals.py` +- Modify: `tests/test_earnings_nowcast_sec_actuals.py` + +**Interfaces:** +- Consumes: per-ticker `ExtractionResult` objects and an explicit output directory. +- Produces: `link_quarter_revisions(rows: Sequence[QuarterlyActual]) -> tuple[QuarterlyActual, ...]`. +- Produces: `write_sec_actuals_stage(output_dir: Path, results: Mapping[str, ExtractionResult]) -> StageResult`. +- Produces: `stage_sec_quarterly_actuals(tickers: Sequence[str], *, output_dir: Path, cutoff: str, user_agent: str | None, ...) -> StageResult`. + +- [ ] **Step 1: Write failing revision-lineage tests** + +```python +def test_later_changed_presentation_is_append_only_revision(): + original = actual("2025-Q2", revenue=100, eps=1.0, source_ref="sec://original", reported_at="2025-08-01T00:00:00Z") + revised = actual("2025-Q2", revenue=100, eps=0.1, source_ref="sec://split-adjusted", reported_at="2025-11-01T00:00:00Z") + + linked = link_quarter_revisions([original, revised]) + + assert len(linked) == 2 + assert linked[1].supersedes_source_ref == original.source_ref + assert linked[0].eps_actual == 1.0 +``` + +Also test unchanged later presentations are de-duplicated and an unrelated conflicting source is not silently marked as a revision. + +- [ ] **Step 2: Run the revision test and verify RED** + +Run: + +```bash +python3 -m pytest tests/test_earnings_nowcast_sec_actuals.py::test_later_changed_presentation_is_append_only_revision -q +``` + +Expected: FAIL because `link_quarter_revisions` is missing. + +- [ ] **Step 3: Implement revision linking and output types** + +```python +@dataclass(frozen=True) +class StageResult: + requested_tickers: tuple[str, ...] + accepted_tickers: tuple[str, ...] + withheld_tickers: tuple[str, ...] + accepted_row_count: int + rejected_row_count: int + quarterly_actuals_path: str + audit_path: str + rejected_path: str + automatic_apply: bool = False +``` + +Only link a later row as a revision when ticker/fiscal period/source family match and the changed row is later by `reported_at`. Keep the original row. Never mutate canonical data. + +- [ ] **Step 4: Write failing output-boundary tests** + +```python +def test_stage_writes_only_explicit_output_directory(tmp_path): + result = write_sec_actuals_stage(tmp_path / "stage", {"SYN1": extraction_result()}) + + assert Path(result.quarterly_actuals_path).parent == tmp_path / "stage" + assert (tmp_path / "stage" / "quarterly_actuals.csv").exists() + assert (tmp_path / "stage" / "consensus_snapshots.csv").read_text().count("\n") == 1 + assert result.automatic_apply is False + assert not (tmp_path / "data").exists() +``` + +Verify audit JSON contains raw concept/start/end/frame/accession metadata and rejected CSV contains explicit reason codes. + +- [ ] **Step 5: Implement CSV/JSON output and stage orchestrator** + +Reuse `SCHEMAS` and `EVIDENCE_SCHEMA_VERSION` from `src.earnings_nowcast_onboarding`. Reuse `load_sec_ticker_map` and `fetch_companyfacts` from `src.providers.sec_companyfacts`. Dependency-inject ticker-map and Companyfacts fetchers for tests. Require `output_dir`; do not default to `data/imports`. + +- [ ] **Step 6: Run Task 2 tests and existing onboarding tests** + +```bash +python3 -m pytest tests/test_earnings_nowcast_sec_actuals.py tests/test_earnings_nowcast_onboarding.py -q +``` + +Expected: all pass and no file appears outside pytest temporary directories. + +- [ ] **Step 7: Commit Task 2** + +```bash +git add -- src/earnings_nowcast_sec_actuals.py tests/test_earnings_nowcast_sec_actuals.py +git commit -m "Stage SEC actuals as append-only evidence" +``` + +--- + +### Task 3: Enforce metric-specific quarter continuity and split safety + +**Files:** +- Modify: `src/earnings_nowcast_readiness.py` +- Modify: `src/earnings_nowcast_model.py` +- Modify: `tests/test_earnings_nowcast_readiness.py` +- Modify: `tests/test_earnings_nowcast_model.py` +- Modify: `tests/test_earnings_nowcast_backtest.py` + +**Interfaces:** +- Produces: `contiguous_metric_window(rows: Sequence[QuarterlyActual], target_period: str, metric: str, minimum_quarters: int) -> tuple[QuarterlyActual, ...]`. +- `assess_nowcast_readiness` adds `quarter_history_gap` and `incompatible_eps_definition` without changing existing public state names. +- `build_baseline_nowcast` uses the contiguous windows returned by the readiness helper. + +- [ ] **Step 1: Write failing continuity tests** + +```python +def test_missing_q4_withholds_both_metrics_instead_of_treating_q3_to_q1_as_sequential(): + rows = [row for row in _actuals() if row.fiscal_period != "2024-Q4"] + + result = assess_nowcast_readiness( + ticker="SYN1", + fiscal_period="2026-Q1", + as_of_timestamp=CUTOFF, + actuals=rows, + consensus=[_consensus()], + ) + + assert result.revenue_ready is False + assert result.eps_ready is False + assert "quarter_history_gap" in result.missing_evidence +``` + +Add a model regression test that monkeypatches or directly checks the selected input periods and proves Q3-to-Q1 is never passed to `_sequential_growth`. + +- [ ] **Step 2: Run the continuity tests and verify RED** + +```bash +python3 -m pytest tests/test_earnings_nowcast_readiness.py::test_missing_q4_withholds_both_metrics_instead_of_treating_q3_to_q1_as_sequential -q +``` + +Expected: FAIL because current readiness counts five rows without checking adjacency. + +- [ ] **Step 3: Implement fiscal-period adjacency and contiguous windows** + +```python +def _next_period(period: str) -> str: + year, quarter = period.split("-Q") + return f"{int(year) + 1}-Q1" if quarter == "4" else f"{year}-Q{int(quarter) + 1}" + + +def contiguous_metric_window(rows, target_period, metric, minimum_quarters): + eligible = [row for row in rows if getattr(row, f"{metric}_actual") is not None] + # Walk backward from the period immediately before target_period. + # Return an empty tuple when any required period is missing. +``` + +Use the contiguous window for history sufficiency, stability checks, model inputs, and source IDs. Keep Revenue and EPS windows separate. + +- [ ] **Step 4: Write failing split-basis tests** + +```python +def test_split_basis_change_withholds_eps_but_keeps_revenue_ready(): + rows = _actuals() + rows[0] = replace(rows[0], split_adjustment_basis="pre_split") + consensus = replace(_consensus(), split_adjustment_basis="post_split_2024_06_10") + + result = assess_nowcast_readiness(...) + + assert result.revenue_ready is True + assert result.eps_ready is False + assert "incompatible_eps_definition" in result.missing_evidence +``` + +Also prove a source-backed append-only split-adjusted revision restores a consistent EPS window before the cutoff, while a post-cutoff revision does not. + +- [ ] **Step 5: Implement split-basis filtering and cutoff-safe revision selection** + +Reuse `_metric_definition` and `canonicalize_actuals`; do not add ticker-specific split constants to model code. The evidence row and consensus row carry the basis. If no five-quarter contiguous compatible EPS window exists, withhold EPS. + +- [ ] **Step 6: Run readiness, model, and backtest suites** + +```bash +python3 -m pytest \ + tests/test_earnings_nowcast_readiness.py \ + tests/test_earnings_nowcast_model.py \ + tests/test_earnings_nowcast_backtest.py -q +``` + +Expected: all pass; existing leakage and probability gates remain unchanged. + +- [ ] **Step 7: Commit Task 3** + +```bash +git add -- src/earnings_nowcast_readiness.py src/earnings_nowcast_model.py \ + tests/test_earnings_nowcast_readiness.py tests/test_earnings_nowcast_model.py \ + tests/test_earnings_nowcast_backtest.py +git commit -m "Require contiguous nowcast quarter history" +``` + +--- + +### Task 4: Ingest explicit Q4 results from SEC-filed earnings-release exhibits + +**Files:** +- Modify: `src/earnings_nowcast_sec_actuals.py` +- Modify: `src/providers/sec_submissions.py` +- Modify: `tests/test_earnings_nowcast_sec_actuals.py` +- Modify: `tests/test_sec_submissions.py` + +**Interfaces:** +- Produces: `sec_filing_index_url(cik: str, accession: str) -> str` in `src.providers.sec_submissions`. +- Produces: `extract_filing_exhibits(index_html: str, *, cik: str, accession: str) -> tuple[FiledExhibit, ...]`. +- Produces: `extract_explicit_q4_actual(ticker: str, exhibit: FiledExhibit, document_text: str, *, fiscal_period: str, filed_at: str, retrieved_at: str) -> ExtractionResult`. +- `stage_sec_quarterly_actuals` combines Q1-Q3 lineage and explicit Q4 rows before revision linking. + +- [ ] **Step 1: Write failing filing-index and Q4 table tests** + +Use a fixture index containing an `EX-99.1` link and an earnings-release fixture containing: + +```html +

Fourth Quarter Fiscal 2025 Summary

+ + + + +
Q4 FY25
Revenue$39,331 million
GAAP diluted earnings per share$0.89
+

All per-share amounts are retrospectively adjusted for the ten-for-one split effective June 7, 2024.

+``` + +Assertions: + +```python +assert exhibits[0].document_type == "EX-99.1" +assert result.rows[0].fiscal_period == "2025-Q4" +assert result.rows[0].revenue_actual == 39_331_000_000 +assert result.rows[0].eps_actual == 0.89 +assert result.rows[0].split_adjustment_basis == "split_adjusted_2024_06_07" +``` + +- [ ] **Step 2: Run Q4 tests and verify RED** + +```bash +python3 -m pytest tests/test_earnings_nowcast_sec_actuals.py -k 'filing_index or explicit_q4' -q +``` + +Expected: FAIL because filing-index and Q4 interfaces are missing. + +- [ ] **Step 3: Implement filing-index URL and standard-library table extraction** + +Use `html.parser.HTMLParser`; do not add a new runtime dependency. The parser must preserve table row/cell text and nearby quarter/split labels. Normalize commas, currency symbols, parentheses, and explicit `million` / `billion` scale only after the metric label is matched. + +- [ ] **Step 4: Add fail-closed Q4 cases** + +Add tests proving these produce no actual row and explicit audit states: + +- annual total without a Q4 column; +- guidance table (`outlook`, `expected`, `approximately`); +- non-GAAP EPS without GAAP diluted EPS; +- Q4 derived only by subtraction; +- ambiguous period header; +- source filed after cutoff; +- missing split note labels EPS `as_reported`; the readiness split-basis gate, + rather than the parser, withholds EPS when the surrounding lineage requires + an incompatible split-adjusted basis. + +- [ ] **Step 5: Implement exhibit discovery and Q4 selection** + +Search `8-K` / `8-K/A` filing index documents for `EX-99`, `EX-99.1`, or `EX-99.2` result exhibits. Require exact source URL, accession, filed date, quarter label, and metric labels. Issuer aliases belong in a small immutable table of labels, never values. If multiple exhibits disagree, reject Q4 as `ambiguous_concept`. + +- [ ] **Step 6: Run Q4 and SEC provider tests** + +```bash +python3 -m pytest tests/test_earnings_nowcast_sec_actuals.py tests/test_sec_submissions.py -q +``` + +Expected: all pass without network access. + +- [ ] **Step 7: Commit Task 4** + +```bash +git add -- src/earnings_nowcast_sec_actuals.py src/providers/sec_submissions.py \ + tests/test_earnings_nowcast_sec_actuals.py tests/test_sec_submissions.py +git commit -m "Stage explicit SEC filed Q4 actuals" +``` + +--- + +### Task 5: Add launcher, documentation, and five-company live verification + +**Files:** +- Modify: `Makefile` +- Modify: `tests/test_launchers.py` +- Modify: `docs/EARNINGS_NOWCAST_PILOT.md` +- Modify: `docs/METHODOLOGY.md` +- Modify: `docs/PROVENANCE_CONTRACT.md` +- Modify: `ROADMAP.md` +- Modify: `src/earnings_nowcast_sec_actuals.py` +- Modify: `tests/test_earnings_nowcast_sec_actuals.py` + +**Interfaces:** +- CLI: `python3 -m src.earnings_nowcast_sec_actuals --tickers ... --output-dir ... --cutoff ...`. +- Make: `make earnings-nowcast-sec-actuals-stage TICKERS=... OUTPUT_DIR=... AS_OF=...`. +- JSON summary exposes per-ticker accepted rows, rejected rows, missing Q4, continuity gaps, source refs, and `automatic_apply=false`. + +- [ ] **Step 1: Write failing launcher tests** + +Add to `tests/test_launchers.py`: + +```python +assert "earnings-nowcast-sec-actuals-stage" in makefile +assert "TICKERS is required" in makefile +assert "OUTPUT_DIR is required" in makefile +assert "--cutoff $(AS_OF)" in makefile +assert "imports-apply" not in target_body(makefile, "earnings-nowcast-sec-actuals-stage") +``` + +Add a CLI test that dependency-injects cached fixtures and confirms JSON output contains `automatic_apply: false`. + +- [ ] **Step 2: Run launcher tests and verify RED** + +```bash +python3 -m pytest tests/test_launchers.py tests/test_earnings_nowcast_sec_actuals.py -q +``` + +Expected: launcher assertion fails. + +- [ ] **Step 3: Implement CLI and Make target** + +Add to `.PHONY` and Makefile: + +```make +earnings-nowcast-sec-actuals-stage: +ifndef TICKERS + $(error TICKERS is required, for example: make earnings-nowcast-sec-actuals-stage TICKERS=NVDA OUTPUT_DIR=/tmp/sec-actuals AS_OF=2026-07-16T03:59:59Z) +endif +ifndef OUTPUT_DIR + $(error OUTPUT_DIR is required; use a generated temporary/review directory) +endif +ifndef AS_OF + $(error AS_OF is required for cutoff-safe evidence staging) +endif + @python3 -m src.earnings_nowcast_sec_actuals --tickers "$(TICKERS)" --output-dir "$(OUTPUT_DIR)" --cutoff "$(AS_OF)" +``` + +The Python CLI accepts `--no-network`, `--sec-refresh`, and `--json`. It never accepts an apply flag. + +- [ ] **Step 4: Update methodology and roadmap truth** + +Document: + +- Q1-Q3 duration and fiscal-lineage rules; +- explicit Q4 source rule and no-derivation boundary; +- revision/cutoff behavior; +- quarter-continuity and EPS split-basis withholding; +- five-company pilot scope; +- real output still `awaiting_point_in_time_consensus`; +- probability still `awaiting_calibration_evidence`. + +Do not claim real Nowcast coverage merely because actuals stage successfully. + +- [ ] **Step 5: Run focused and full deterministic verification** + +```bash +python3 -m pytest \ + tests/test_earnings_nowcast_sec_actuals.py \ + tests/test_earnings_nowcast_onboarding.py \ + tests/test_earnings_nowcast_readiness.py \ + tests/test_earnings_nowcast_model.py \ + tests/test_earnings_nowcast_backtest.py \ + tests/test_sec_submissions.py \ + tests/test_launchers.py -q +python3 -m pytest tests -q +make public-wording-check +make dashboard-smoke +make browser-qa-evidence +make public-check +make pilot-readiness-check TOP_N=10 +make diff-hygiene-summary +git diff --check +``` + +Expected: all tests and gates pass. Generated readiness/report files remain unstaged. + +- [ ] **Step 6: Run the live read-only five-company smoke** + +```bash +rm -rf /tmp/stock-nowcast-five-company/sec-lineage-stage +make earnings-nowcast-sec-actuals-stage \ + TICKERS=NVDA,AMD,AVGO,MU,QCOM \ + OUTPUT_DIR=/tmp/stock-nowcast-five-company/sec-lineage-stage \ + AS_OF=2026-07-16T03:59:59Z +make earnings-nowcast-validate \ + INPUT_DIR=/tmp/stock-nowcast-five-company/sec-lineage-stage \ + AS_OF=2026-07-16T03:59:59Z +make earnings-nowcast-preview \ + INPUT_DIR=/tmp/stock-nowcast-five-company/sec-lineage-stage \ + AS_OF=2026-07-16T03:59:59Z +``` + +Inspect per ticker: + +- at least five consecutive Revenue quarters or an explicit truthful gap; +- EPS ready only within one source-backed split basis; +- Q4 rows have exact SEC-filed exhibit URLs; +- zero post-cutoff rows; +- zero cumulative facts; +- preview remains `ready_for_packet=false` with `point_in_time_consensus_missing` when consensus is absent. + +Do not stage `/tmp` output. + +- [ ] **Step 7: Commit Task 5** + +```bash +git add -- Makefile ROADMAP.md \ + docs/EARNINGS_NOWCAST_PILOT.md docs/METHODOLOGY.md docs/PROVENANCE_CONTRACT.md \ + src/earnings_nowcast_sec_actuals.py tests/test_earnings_nowcast_sec_actuals.py \ + tests/test_launchers.py +make staged-hygiene-check +git diff --cached --check +git commit -m "Add SEC quarterly actuals staging workflow" +``` + +## Final Review Gate + +After all task commits: + +```bash +git status --short --branch +git log -8 --oneline +git diff origin/main...HEAD --stat +python3 -m pytest tests -q +make public-check +make pilot-readiness-check TOP_N=10 +make diff-hygiene-summary +git diff --check +``` + +The feature is complete only when fixture validation, full regression gates, and live read-only evidence agree. Real Earnings Nowcast remains blocked until historical point-in-time consensus snapshots are separately source-backed and validated. diff --git a/docs/superpowers/plans/2026-07-17-commercial-research-beta-foundation.md b/docs/superpowers/plans/2026-07-17-commercial-research-beta-foundation.md new file mode 100644 index 00000000..49433e25 --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-commercial-research-beta-foundation.md @@ -0,0 +1,189 @@ +# Commercial Research Beta Foundation 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:** Build the local, deterministic Commercial Research Beta foundation without activating unapproved sources or fabricating analysis evidence. + +**Architecture:** Add focused contracts around the existing Personal Research Mode, quarterly actuals, Scenario Lab, thesis journal, source providers, and refresh orchestrator. Keep UI composition thin and fail closed whenever source, rights, freshness, or calibration evidence is absent. + +**Tech Stack:** Python 3.12, dataclasses, pandas, CSV/JSON contracts, Streamlit, pytest, Make. + +**Implementation status (2026-07-17):** Tasks 1-10 are implemented and locally verified; the desktop/mobile findings and corrected Workbench defect are recorded in [Dashboard QA Notes](../../DASHBOARD_QA.md). The unchecked boxes below preserve the original execution sequence; this status line is the current plan truth. The local package is not hosted, externally validated, merged, or commercially launched. + +## Global Constraints + +- Research-only; no investment advice, broker integration, trading, order routing, auto-trading, or direct buy/sell instructions. +- Do not fabricate data, forecasts, probabilities, sources, reviewers, or results. +- Candidate peers and qualitative signals never become trusted numerical inputs automatically. +- Commercial mode refuses sources with unverified commercial rights. +- Keep generated data, reports, screenshots, timing output, packets, caches, and rejected rows excluded. +- Never use `git add -A`; do not push or merge unless explicitly asked. + +--- + +### Task 1: Commercial Source-Rights Registry + +**Files:** +- Create: `config/source_rights.yml` +- Create: `src/commercial_source_rights.py` +- Create: `tests/test_commercial_source_rights.py` +- Modify: `Makefile` +- Modify: `docs/DATA_STRATEGY.md` + +**Interfaces:** +- Produces immutable source-rights records, registry validation, commercial eligibility decisions, and a read-only CLI/status command. + +- [ ] Write failing tests for complete records, unknown sources, unverified rights, commercial refusal, and approved-source acceptance. +- [ ] Run focused tests and confirm expected failures. +- [ ] Implement the minimal registry loader and commercial-mode gate. +- [ ] Add a read-only `make commercial-source-rights` command and documentation. +- [ ] Run focused tests and commit the coherent slice. + +### Task 2: Focused Cohort Coverage Matrix + +**Files:** +- Create: `src/focused_cohort_coverage.py` +- Create: `tests/test_focused_cohort_coverage.py` +- Modify: `src/research_workspace.py` +- Modify: `src/dashboard.py` +- Modify: `docs/PERSONAL_RESEARCH_MODE.md` + +**Interfaces:** +- Consumes the deterministic cohort plus saved readiness/source metadata. +- Produces per-company, per-lane states without calculating new fundamentals or changing readiness. + +- [ ] Write failing tests for usable, partial, candidate-only, blocked, excluded, missing, and maximum-50 behavior. +- [ ] Implement immutable coverage rows and matrix summaries. +- [ ] Show the concise answer in Discover/Workbench; keep the full matrix under Advanced. +- [ ] Run focused tests and commit the coherent slice. + +### Task 3: Quarterly Actuals Review Contract + +**Files:** +- Modify: `src/earnings_nowcast_onboarding.py` +- Modify: `src/quarterly_business_trend.py` +- Modify: `Makefile` +- Modify: `tests/test_earnings_nowcast_onboarding.py` +- Modify: `tests/test_quarterly_business_trend.py` +- Modify: `docs/EARNINGS_NOWCAST_PILOT.md` + +**Interfaces:** +- Preserves existing append-only templates, validation, preview, duplicate/revision handling, rejected-row reporting, and readiness. + +- [ ] Add failing tests for ambiguous periods, incompatible units, post-cutoff rows, unresolved revisions, inferred Q4, and preview-only behavior. +- [ ] Close only verified contract gaps; do not add automatic apply. +- [ ] Add or clarify canonical read-only commands and readiness output. +- [ ] Run focused tests and commit the coherent slice. + +### Task 4: Deterministic Forward View V1 + +**Files:** +- Create: `src/forward_view.py` +- Create: `tests/test_forward_view.py` +- Modify: `src/research_workspace.py` +- Modify: `src/dashboard.py` +- Modify: `docs/METHODOLOGY.md` + +**Interfaces:** +- Consumes quarterly trend, source-backed valuation readiness/scenarios, trusted-peer state, reviewer-authored thesis evidence, and Earnings Outlook readiness. +- Produces one immutable packet with trend, scenario, context, withholding, and next-task sections. + +- [ ] Write failing tests for complete, partial, stale, candidate-peer-only, invalid-assumption, and calibration-withheld cases. +- [ ] Implement the packet without introducing a second valuation or nowcast calculation. +- [ ] Integrate it into Company Workbench in the required answer order. +- [ ] Keep raw provenance under Advanced and run focused tests. +- [ ] Commit the coherent slice. + +### Task 5: Point-In-Time Validation Diagnostics + +**Files:** +- Modify: `src/earnings_nowcast_backtest.py` +- Modify: `tests/test_earnings_nowcast_backtest.py` +- Modify: `docs/METHODOLOGY.md` + +**Interfaces:** +- Preserves event snapshots, cutoff enforcement, benchmark comparisons, interval diagnostics, and probability calibration gates. + +- [ ] Add failing tests for empty evidence, revised consensus, stale snapshots, mixed valid/invalid events, and benchmark non-improvement. +- [ ] Add only missing diagnostics and fail-closed reasons. +- [ ] Confirm synthetic fixtures never produce predictive-accuracy claims. +- [ ] Run focused tests and commit the coherent slice. + +### Task 6: Controlled Refresh Operations Contract + +**Files:** +- Create: `src/refresh_operations.py` +- Create: `tests/test_refresh_operations.py` +- Modify: `src/auto_refresh_orchestrator.py` +- Modify: `Makefile` +- Modify: `docs/SCHEDULER_ACTIVATION_CHECKLIST.md` + +**Interfaces:** +- Produces read-only job plans/states for fetch, normalize, validate, quarantine, preview, snapshot publish, readiness rebuild, and change detection. + +- [ ] Write failing tests for provider unavailability, retry caps, identical attempts, schema changes, provenance loss, duplicates, stale rows, partial batches, and quarantine. +- [ ] Implement deterministic plans and failure classifications with automatic apply disabled. +- [ ] Add read-only status/runbook commands. +- [ ] Run focused tests and commit the coherent slice. + +### Task 7: Private-Beta Readiness Contract + +**Files:** +- Create: `src/private_beta_readiness.py` +- Create: `tests/test_private_beta_readiness.py` +- Create: `docs/PRIVATE_BETA_ARCHITECTURE.md` +- Modify: `Makefile` +- Modify: `docs/HOSTED_DEMO_DEPLOYMENT.md` + +**Interfaces:** +- Produces a read-only classification for authentication, workspaces, user data separation, secrets, audit, retention, entitlements, monitoring, and health checks. + +- [ ] Write failing tests for local-ready, external-account-required, and unsafe-secret states. +- [ ] Implement the readiness checklist without claiming runtime authentication or hosting. +- [ ] Document data boundaries and exact external setup steps. +- [ ] Run focused tests and commit the coherent slice. + +### Task 8: Commercial Beta Product Contract And Pilot Package + +**Files:** +- Modify: `ROADMAP.md` +- Modify: `README.md` +- Modify: `docs/PILOT_RUNBOOK.md` +- Modify: `docs/PILOT_REVIEW_FEEDBACK_TEMPLATE.md` +- Modify: `docs/PRODUCT_DIRECTION_DECISION.md` +- Modify: `tests/test_launchers.py` +- Modify: `tests/test_pilot_review_feedback_template.py` + +**Interfaces:** +- Makes `ROADMAP.md` the sole active plan and defines beta jobs, outcomes, safe claims, external classifications, and pilot measures. + +- [ ] Write failing documentation-contract tests. +- [ ] Reconcile current-status wording without hard-coded changing coverage claims. +- [ ] Add the 10-20 user task-based runbook and measurement definitions. +- [ ] Keep absent users as `awaiting_external_review`. +- [ ] Run focused tests and commit the coherent slice. + +### Task 9: Desktop And Mobile UX Hardening + +**Files:** +- Modify only reproducibly affected product/test files after live review. + +**Interfaces:** +- Preserves Research Desk -> Discover -> Company Workbench -> Monitor and Advanced Evidence boundaries. + +- [ ] Review 1280x720 and 390x844 for all four primary pages plus supporting evidence links. +- [ ] Add a failing regression test before each reproducible fix. +- [ ] Fix first-answer order, overflow, premature raw evidence, blocked-output ambiguity, or missing next action only when observed. +- [ ] Run focused browser/UI tests and commit one coherent slice if changes exist. + +### Task 10: Final Verification And Review + +**Files:** +- No new scope; fix only findings from this plan. + +- [ ] Run all focused tests. +- [ ] Run `python3 -m pytest tests -q`. +- [ ] Run dashboard, browser, wording, public, pilot, hygiene, and whitespace gates. +- [ ] Run a fresh broad branch review and fix Critical/Important findings. +- [ ] Confirm generated artifacts remain excluded and external dependencies are classified. +- [ ] Report safe push, host, pilot, and commercialization boundaries without pushing. diff --git a/docs/superpowers/plans/2026-07-17-focused-cohort-research-workflow.md b/docs/superpowers/plans/2026-07-17-focused-cohort-research-workflow.md new file mode 100644 index 00000000..f6151a32 --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-focused-cohort-research-workflow.md @@ -0,0 +1,113 @@ +# Focused Cohort Research Workflow 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:** Build a deterministic 25-50 company personal-research cohort with source-backed quarterly trend packets, deduplicated monitoring, and a traceable weekly summary. + +**Architecture:** Add three focused read-only domain modules and compose them through the existing research workspace. Reuse ticker readiness, universe classifications, quarterly actuals, change-event identities, and thesis records; never write canonical data or weaken readiness gates. + +**Tech Stack:** Python 3.12, pandas, dataclasses, pytest, Streamlit AppTest, existing CSV-first profile and readiness contracts. + +## Global Constraints + +- Research-only; no recommendations, trading, broker, or order-routing behavior. +- No fabricated or inferred quarterly data, peers, consensus, or valuation inputs. +- Public and Operator routes remain regression-protected. +- Generated data and reports remain unstaged. +- Every production behavior begins with a failing focused test. + +--- + +### Task 1: Deterministic Focused Cohort + +**Files:** +- Create: `src/focused_research_cohort.py` +- Create: `tests/test_focused_research_cohort.py` + +**Interfaces:** +- Consumes: ticker-readiness and universe-master `pandas.DataFrame` inputs. +- Produces: `FocusedCohortMember`, `FocusedCohort`, `build_focused_cohort(...)`, and `focused_cohort_frame(...)`. + +- [x] Write tests for deterministic order, operating-company eligibility, exclusions, duplicate tickers, missing inputs, truthful sub-25 state, and recommendation-free fields. +- [x] Run `python3 -m pytest tests/test_focused_research_cohort.py -q` and confirm the missing-module failure. +- [x] Implement immutable cohort contracts and deterministic selection. +- [x] Rerun the focused tests and commit the coherent cohort slice. + +### Task 2: Quarterly Business Trend + +**Files:** +- Create: `src/quarterly_business_trend.py` +- Create: `tests/test_quarterly_business_trend.py` + +**Interfaces:** +- Consumes: iterable `QuarterlyActual` rows and ticker. +- Produces: `QuarterlyMetricTrend`, `QuarterlyTrendPacket`, `build_quarterly_trend_packet(...)`, and display-row helpers. + +- [x] Write tests for sequential and year-over-year comparisons, revisions, missing periods, incompatible metric definitions, ambiguous rows, and explicit Q4-only behavior. +- [x] Run the focused test and confirm RED. +- [x] Implement revision resolution and metric-by-metric fail-closed comparisons. +- [x] Rerun tests and commit the coherent trend slice. + +### Task 3: Monitor Detail and Deduplication + +**Files:** +- Modify: `src/research_workspace.py` +- Modify: `tests/test_research_workspace.py` + +**Interfaces:** +- Consumes: existing `ResearchReviewItem` rows. +- Produces: one deterministic monitor row per event identity with previous/current state, source/effective dates, affected section, next task, and wait condition. + +- [x] Add failing tests for duplicate event identities and detailed monitor columns. +- [x] Implement minimal deduplication and field mapping. +- [x] Run focused monitor tests and commit with the Workbench composition slice. + +### Task 4: Weekly Research Summary + +**Files:** +- Create: `src/weekly_research_summary.py` +- Create: `tests/test_weekly_research_summary.py` + +**Interfaces:** +- Consumes: `FocusedCohort`, unresolved research review items, optional reviewer-authored journal summaries, and an as-of timestamp. +- Produces: `WeeklyResearchSummary`, traceable section items, and display rows. + +- [x] Add failing tests for empty weeks, duplicate events, excluded tickers, blocked lanes, stale reviews, and prohibited recommendation wording. +- [x] Implement deterministic grouping and traceable source references. +- [x] Run focused tests and commit with workspace integration. + +### Task 5: Personal Research Workspace Integration + +**Files:** +- Modify: `src/dashboard.py` +- Modify: `src/research_workspace.py` +- Modify: `src/dashboard_render_smoke.py` +- Modify: `tests/test_dashboard_helpers.py` +- Modify: `tests/test_dashboard_render_smoke.py` +- Modify: `tests/test_research_mode_dashboard_contract.py` + +**Interfaces:** +- Consumes: cohort, quarterly trend, monitor, and weekly summary contracts. +- Produces: Research Desk, Discover, Company Workbench, and Monitor first-answer UI with Advanced Evidence collapsed. + +- [x] Add failing page-contract tests for cohort scope, Workbench answer order, unavailable quarterly evidence, weekly summary, and preserved Public/Operator routes. +- [x] Implement small composition helpers before changing dashboard rendering. +- [x] Run focused dashboard tests and render smoke. +- [x] Commit the coherent workspace slice. + +### Task 6: Documentation and Release Verification + +**Files:** +- Modify: `README.md` +- Modify: `ROADMAP.md` +- Modify: `docs/PERSONAL_RESEARCH_MODE.md` +- Modify only if contracts change: `docs/METHODOLOGY.md`, `docs/PROVENANCE_CONTRACT.md` + +**Interfaces:** +- Documents verified cohort size, supported quarterly metrics, withheld states, daily/weekly workflow, automation boundary, and owner-authored review work. + +- [x] Update documentation tests before public wording. +- [x] Run focused and full pytest. +- [x] Run dashboard smoke, browser QA, public wording, public check, pilot readiness, diff hygiene, and whitespace checks. +- [x] Review desktop and mobile Research routes plus the Public Single-Stock regression route. +- [x] Stage exact product/code/docs/test files, run staged hygiene, and create a local commit without pushing. diff --git a/docs/superpowers/plans/2026-07-18-company-workbench-answer-first.md b/docs/superpowers/plans/2026-07-18-company-workbench-answer-first.md new file mode 100644 index 00000000..e322d1a8 --- /dev/null +++ b/docs/superpowers/plans/2026-07-18-company-workbench-answer-first.md @@ -0,0 +1,126 @@ +# Company Workbench Answer-First 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:** Keep selected-company lane coverage available under Advanced while making the existing Company Workbench report answer the first expanded research content. + +**Architecture:** Wrap the existing selected-ticker coverage cards in a collapsed route-level Streamlit expander before the unchanged single-stock report renderer. Extend source-order and real-browser marker contracts without changing report data, readiness, routes, or helper signatures. + +**Tech Stack:** Python 3.12, Streamlit, pytest, source-level dashboard contract tests, Markdown documentation. + +## Global Constraints + +- Research-only; no recommendation, expected-return ranking, broker integration, order routing, auto-trading, or direct buy/sell instruction. +- Preserve independent actuals, consensus, Revenue, EPS, valuation, catalyst, outcome, backtesting, and calibration readiness states. +- Do not refresh, import, apply, or fabricate source data. +- Keep candidate context unable to modify deterministic scenarios or become trusted evidence. +- Keep generated CSV, JSON, report, sample-report, screenshot, and timing churn unstaged. +- Keep PR #113 draft; do not merge or deploy. + +--- + +### Task 1: Make Company Workbench answer-first + +**Files:** +- Modify: `tests/test_research_mode_dashboard_contract.py` +- Modify: `tests/test_public_performance_gate.py` +- Modify: `src/dashboard.py` +- Modify: `src/public_performance_gate.py` +- Modify: `docs/PERSONAL_RESEARCH_MODE.md` +- Modify: `docs/DASHBOARD_QA.md` +- Modify: `ROADMAP.md` + +**Interfaces:** +- Consumes: existing `render_company_workbench`, `focused_ticker_coverage_cards`, `render_single_stock_report`, and `RESEARCH_ROUTE_SPECS` contracts. +- Produces: Company Workbench order `header -> selected-company heading -> collapsed lane coverage -> expanded report answer`, with unchanged report and readiness semantics. + +- [ ] **Step 1: Write the failing route-order contract test** + +```python +def test_company_workbench_keeps_lane_coverage_collapsed_before_report_answer(): + source = dashboard.Path(dashboard.__file__).read_text(encoding="utf-8") + workbench_start = source.index("def render_company_workbench(") + workbench_end = source.index("\ndef main()", workbench_start) + workbench = source[workbench_start:workbench_end] + + selected = workbench.index('st.markdown("### Selected Company")') + advanced = workbench.index( + 'with st.expander("Advanced: selected-company lane coverage", expanded=False):' + ) + coverage = workbench.index("focused_ticker_coverage_cards(coverage, ticker)", advanced) + report = workbench.index("render_single_stock_report(", coverage) + + assert selected < advanced < coverage < report +``` + +- [ ] **Step 2: Run the route test and verify the expected failure** + +Run: `python3 -m pytest tests/test_research_mode_dashboard_contract.py::test_company_workbench_keeps_lane_coverage_collapsed_before_report_answer -q` + +Expected: FAIL because `Advanced: selected-company lane coverage` does not exist and the cards currently render expanded. + +- [ ] **Step 3: Write the failing performance-marker contract** + +Add `Advanced: selected-company lane coverage` to the expected Company Workbench full markers in `test_research_performance_contract_covers_the_commercial_beta_workflow`. + +Run: `python3 -m pytest tests/test_public_performance_gate.py::test_research_performance_contract_covers_the_commercial_beta_workflow -q` + +Expected: FAIL because the Company Workbench route spec does not yet include the new visible label. + +- [ ] **Step 4: Implement the minimal route hierarchy** + +```python + st.markdown("### Selected Company") + with st.expander("Advanced: selected-company lane coverage", expanded=False): + render_signal_cards( + focused_ticker_coverage_cards(coverage, ticker), + show_commands=False, + variant="queue", + ) + st.caption( + "Lane coverage is technical evidence only; blocked and candidate-only states remain separate." + ) + render_single_stock_report( +``` + +Add the same Advanced label to the Company Workbench `full_markers` tuple in `src/public_performance_gate.py`. + +- [ ] **Step 5: Run focused tests and verify green** + +Run: `python3 -m pytest tests/test_research_mode_dashboard_contract.py tests/test_research_workspace.py tests/test_dashboard_render_smoke.py tests/test_public_performance_gate.py -q` + +Expected: all focused tests pass with no new warnings or failures. + +- [ ] **Step 6: Update workflow, QA, and roadmap documentation** + +Document the collapsed selected-company lane context, unchanged report/readiness contracts, desktop and phone first-view acceptance, and this locally implemented Stage 1 slice. + +- [ ] **Step 7: Run the full release verification bundle** + +Run: + +```text +python3 -m pytest tests -q +make dashboard-smoke +make research-dashboard-render-smoke +make commercial-beta-performance-gate +make public-wording-check +make public-check +make pilot-readiness-check TOP_N=10 +make diff-hygiene-summary +git diff --check +``` + +Expected: every command exits zero; generated churn remains excluded. The pilot checklist may retain truthful manual source, hosting, or review gates while the command succeeds. + +- [ ] **Step 8: Stage exact files, verify staged hygiene, commit, and push** + +```text +git add -- src/dashboard.py src/public_performance_gate.py tests/test_research_mode_dashboard_contract.py tests/test_public_performance_gate.py ROADMAP.md docs/PERSONAL_RESEARCH_MODE.md docs/DASHBOARD_QA.md docs/superpowers/specs/2026-07-18-company-workbench-answer-first-design.md docs/superpowers/plans/2026-07-18-company-workbench-answer-first.md +make staged-hygiene-check +git diff --cached --check +git commit -m "Prioritize Company Workbench answers" +git push origin codex/personal-research-mode-mvp +``` + +Expected: exact reviewed files only are committed and pushed; PR #113 remains open and draft. diff --git a/docs/superpowers/plans/2026-07-18-discover-answer-first.md b/docs/superpowers/plans/2026-07-18-discover-answer-first.md new file mode 100644 index 00000000..b2c10b53 --- /dev/null +++ b/docs/superpowers/plans/2026-07-18-discover-answer-first.md @@ -0,0 +1,126 @@ +# Discover Answer-First 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:** Put Discover's readiness-backed company selector before cohort evidence while preserving all cohort and readiness context under Advanced. + +**Architecture:** Reorder existing Streamlit composition calls in the Discover branch of `src/dashboard.py`. Keep the existing selector, focused-cohort cards, coverage cards, routes, and data contracts unchanged; only move the cards into a collapsed route-specific expander after the selector. + +**Tech Stack:** Python 3.12, Streamlit, pytest, source-level dashboard contract tests, Markdown documentation. + +## Global Constraints + +- Research-only; no recommendation, expected-return ranking, broker integration, order routing, auto-trading, or direct buy/sell instruction. +- Preserve independent readiness states and deterministic focused-cohort membership. +- Do not refresh, import, apply, or fabricate source data. +- Keep generated CSV, JSON, report, sample-report, screenshot, and timing churn unstaged. +- Keep PR #113 draft; do not merge or deploy. + +--- + +### Task 1: Make Discover selection-first + +**Files:** +- Modify: `tests/test_research_mode_dashboard_contract.py` +- Modify: `src/dashboard.py` +- Modify: `tests/test_public_performance_gate.py` +- Modify: `src/public_performance_gate.py` +- Modify: `docs/PERSONAL_RESEARCH_MODE.md` +- Modify: `docs/DASHBOARD_QA.md` +- Modify: `ROADMAP.md` + +**Interfaces:** +- Consumes: existing `render_stock_selector`, `focused_cohort_cards`, `focused_cohort_coverage_cards`, `focused_cohort`, and `focused_cohort_coverage` contracts. +- Produces: Discover route order `header -> selector -> collapsed cohort readiness context`, with unchanged Company Workbench links and readiness semantics. + +- [ ] **Step 1: Write the failing route-order contract test** + +```python +def test_research_discover_renders_selector_before_advanced_cohort_context(): + source = dashboard.Path(dashboard.__file__).read_text(encoding="utf-8") + discover_start = source.index('elif research_mode and selected_page == "Discover":') + discover_end = source.index('elif research_mode and selected_page == "Company Workbench":', discover_start) + discover = source[discover_start:discover_end] + + heading = discover.index('st.markdown("### Which stock can I review?")') + selector = discover.index("render_stock_selector(", heading) + advanced = discover.index('with st.expander("Advanced: cohort readiness context", expanded=False):') + cohort = discover.index("focused_cohort_cards(focused_cohort)", advanced) + coverage = discover.index("focused_cohort_coverage_cards(focused_cohort_coverage)", advanced) + + assert heading < selector < advanced < cohort < coverage +``` + +- [ ] **Step 2: Run the test and verify the expected failure** + +Run: `python3 -m pytest tests/test_research_mode_dashboard_contract.py::test_research_discover_renders_selector_before_advanced_cohort_context -q` + +Expected: FAIL because `Advanced: cohort readiness context` does not exist and the cohort cards currently render before the selector. + +- [ ] **Step 3: Implement the minimal route reordering** + +```python + st.markdown("### Which stock can I review?") + render_stock_selector( + output_frames, + public_mode=True, + target_mode=RESEARCH_MODE, + target_page="company-workbench", + allowed_tickers=tuple(member.ticker for member in focused_cohort.members), + ) + with st.expander("Advanced: cohort readiness context", expanded=False): + render_signal_cards(focused_cohort_cards(focused_cohort), show_commands=False, variant="queue") + render_signal_cards( + focused_cohort_coverage_cards(focused_cohort_coverage), + show_commands=False, + variant="queue", + ) + st.caption( + "Cohort membership and lane coverage remain evidence context only; " + "they do not rank expected return or create a recommendation." + ) +``` + +- [ ] **Step 4: Run focused tests and verify green** + +Run: `python3 -m pytest tests/test_research_mode_dashboard_contract.py tests/test_research_workspace.py tests/test_dashboard_render_smoke.py tests/test_public_performance_gate.py -q` + +Expected: all focused tests pass with no new warnings or failures. + +- [ ] **Step 4a: Align the real-browser visible-marker contract test-first** + +Require Discover's performance contract to use `Which stock can I review?` as the first useful marker and `Search this review queue`, `Advanced: cohort readiness context`, and `Research-only` as visible full-settle evidence. Run the focused performance-contract test red against the stale `Focused cohort` marker, update `RESEARCH_ROUTE_SPECS`, then rerun the focused performance and route suite green. + +- [ ] **Step 5: Update the workflow, QA, and roadmap documentation** + +Document the selector-first Discover order, collapsed cohort context, unchanged readiness boundaries, and first-viewport acceptance rule. Record the slice as implemented only after focused verification passes. + +- [ ] **Step 6: Run the full release verification bundle** + +Run: + +```text +python3 -m pytest tests -q +make dashboard-smoke +make research-dashboard-render-smoke +make commercial-beta-performance-gate +make public-wording-check +make public-check +make pilot-readiness-check TOP_N=10 +make diff-hygiene-summary +git diff --check +``` + +Expected: every command exits zero; generated churn remains excluded. + +- [ ] **Step 7: Stage exact files, verify staged hygiene, commit, and push** + +```text +git add -- src/dashboard.py src/public_performance_gate.py tests/test_research_mode_dashboard_contract.py tests/test_public_performance_gate.py ROADMAP.md docs/PERSONAL_RESEARCH_MODE.md docs/DASHBOARD_QA.md docs/superpowers/specs/2026-07-18-discover-answer-first-design.md docs/superpowers/plans/2026-07-18-discover-answer-first.md +make staged-hygiene-check +git diff --cached --check +git commit -m "Prioritize Discover company selection" +git push origin codex/personal-research-mode-mvp +``` + +Expected: exact reviewed files only are committed and pushed; PR #113 remains draft. diff --git a/docs/superpowers/plans/2026-07-18-evidence-activation-layer.md b/docs/superpowers/plans/2026-07-18-evidence-activation-layer.md new file mode 100644 index 00000000..87370107 --- /dev/null +++ b/docs/superpowers/plans/2026-07-18-evidence-activation-layer.md @@ -0,0 +1,122 @@ +# Evidence Activation Layer Implementation Plan + +> **For Codex:** Execute each task with test-driven development. Keep generated evidence under `/tmp` or `outputs`, never stage it, and preserve fail-closed research-only states. + +**Goal:** Add the next evidence-bound personal research capabilities without creating new primary pages or turning candidate context into forecasts, valuation claims, or recommendations. + +**Architecture:** Extend the existing Earnings Nowcast, Valuation, Research Thesis Journal, Change Monitor, Company Workbench, and Monitor modules. New modules expose immutable/read-only packets and append-only reviewed records. Dashboard changes consume compact helpers and keep raw evidence under Advanced. + +**Tech stack:** Python 3.12, dataclasses, CSV/JSON contracts, argparse, Streamlit, pytest, Makefile launchers. + +--- + +### Task 1: Reproduce the five-company actuals baseline + +**Files:** +- Generated only: `/tmp/earnings-nowcast-sec-actuals-review-/` +- Verify: `src/earnings_nowcast_sec_actuals.py` +- Verify: `src/earnings_nowcast_onboarding.py` + +1. Stage NVDA, AMD, AVGO, MU, and QCOM SEC actuals into a new timestamped `/tmp` directory with a current UTC cutoff. +2. Validate and preview the generated onboarding rows. +3. Run per-ticker readiness and record the actual, Q4, split-basis, consensus, and calibration blockers. +4. Confirm no repository files changed. + +### Task 2: Five-company Earnings Nowcast readiness board + +**Files:** +- Create: `src/earnings_nowcast_cohort.py` +- Create: `tests/test_earnings_nowcast_cohort.py` +- Modify: `Makefile` +- Modify: `src/dashboard.py` +- Modify: `tests/test_research_mode_dashboard_contract.py` + +1. Write failing tests for one immutable summary row per ticker and independent Revenue/EPS/Q4/split/consensus/calibration states. +2. Implement CSV-loading and cohort summarization from existing onboarding contracts. +3. Add a read-only `earnings-nowcast-cohort-readiness` command. +4. Add one compact Company Workbench/Monitor summary; keep source IDs and raw rows under Advanced. + +### Task 3: Consensus source probe and prospective collector + +**Files:** +- Create: `src/earnings_consensus_sources.py` +- Create: `src/earnings_consensus_collector.py` +- Create: `tests/test_earnings_consensus_sources.py` +- Create: `tests/test_earnings_consensus_collector.py` +- Modify: `Makefile` +- Modify: `config/provider_keys.env.example` + +1. Write failing tests for deterministic provider order, missing-key classification, rights/comparability gates, and current-only estimates staying `candidate_context_only`. +2. Implement read-only probes for Alpha Vantage, FMP, Finnhub, and a generic reviewed CSV contract. Do not fetch when a key or approved source contract is absent. +3. Write failing tests for deterministic snapshot identity, deduplication, revision preservation, cutoff checks, cooldown, and no overwrite. +4. Implement plan/status/preview collection. Recording must require an explicit reviewed confirmation and append only. +5. Add source-status, collection-plan, collection-preview, and collection-status commands. No automatic promotion or apply path. + +### Task 4: Historical valuation regime + +**Files:** +- Create: `src/historical_valuation_regime.py` +- Create: `tests/test_historical_valuation_regime.py` +- Modify: `src/dashboard.py` +- Modify: `tests/test_dashboard_helpers.py` + +1. Write failing tests for aligned point-in-time price and denominator observations, definition-change segmentation, insufficient history, stale observations, and rejection of current-denominator backfills. +2. Implement descriptive multiple observations and percentile/range context only when source timestamps and period definitions align. +3. Add a compact Valuation section answer. Keep rows and provenance under Advanced. +4. Use neutral wording; never label a stock cheap, expensive, attractive, or actionable. + +### Task 5: Research outcome review + +**Files:** +- Create: `src/research_outcome_review.py` +- Create: `tests/test_research_outcome_review.py` +- Modify: `Makefile` +- Modify: `src/dashboard.py` +- Modify: `tests/test_research_mode_dashboard_contract.py` + +1. Write failing tests for append-only reviewed records, immutable thesis references, observation-window validation, source timestamps, duplicate rejection, and no return/skill scoring. +2. Implement read, validate, preview, explicitly confirmed append, and derived status helpers. +3. Add preview/record/status commands. +4. Integrate a compact learning-loop answer into Thesis Journal/Monitor; keep raw records under Advanced. + +### Task 6: Catalyst evidence timeline + +**Files:** +- Create: `src/catalyst_evidence_timeline.py` +- Create: `tests/test_catalyst_evidence_timeline.py` +- Modify: `Makefile` +- Modify: `src/dashboard.py` +- Modify: `tests/test_research_mode_dashboard_contract.py` + +1. Write failing tests for allowed event types, provenance, publication/retrieval/effective timestamps, cutoff safety, duplicate identity, and candidate-only boundaries. +2. Implement read, validate, preview, explicitly confirmed append, and timeline derivation. +3. Add preview/record/status commands. +4. Integrate a concise upcoming/recent evidence answer into Company Workbench/Monitor. No scraping, sentiment scoring, forecast mutation, or recommendation. + +### Task 7: Product integration and documentation + +**Files:** +- Modify: `src/earnings_nowcast_ui.py` +- Modify: `src/forward_view.py` +- Modify: `src/dashboard.py` +- Modify: `README.md` +- Modify: `ROADMAP.md` +- Modify: `docs/EARNINGS_NOWCAST_PILOT.md` +- Modify: `docs/METHODOLOGY.md` +- Modify: `docs/PROVENANCE_CONTRACT.md` +- Modify: `docs/PERSONAL_RESEARCH_MODE.md` +- Modify: `tests/test_earnings_nowcast_ui.py` +- Modify: `tests/test_forward_view.py` + +1. Preserve the four-page Personal Research flow: Research Desk -> Discover -> Company Workbench -> Monitor. +2. Present concise answers in this order: readiness, usable evidence, withheld evidence, next research action. +3. Keep hashes, source IDs, raw events, records, and diagnostics under Advanced. +4. Document implemented software separately from real data availability, historical validation, and external dependencies. + +### Task 8: Verification, commits, and draft PR + +1. Run focused tests after each task and the full suite after integration. +2. Run dashboard smoke, browser QA, public wording, public check, pilot readiness, diff hygiene, and whitespace checks. +3. Stage exact product/code/docs/test files only; never use `git add -A`. +4. Commit coherent slices only after verification. +5. Push `codex/personal-research-mode-mvp` and update draft PR #113 without merging or deploying. diff --git a/docs/superpowers/plans/2026-07-18-mobile-research-first-action.md b/docs/superpowers/plans/2026-07-18-mobile-research-first-action.md new file mode 100644 index 00000000..ff8c0f33 --- /dev/null +++ b/docs/superpowers/plans/2026-07-18-mobile-research-first-action.md @@ -0,0 +1,309 @@ +# Mobile Research First-Action Density 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:** Bring each Personal Research route's first task into the 390x844 useful viewport while preserving profile truth, freshness, next action, and research-only boundaries. + +**Architecture:** Keep the existing compact profile strip and research workspace header. Add semantic classes to the two route metadata rows, apply phone-only compaction through the existing research style block, and replace Company Workbench's long always-visible path caption with a collapsed native disclosure. + +**Tech Stack:** Python 3.12, Streamlit, HTML/CSS strings, pytest, Streamlit AppTest, Git, Make. + +## Global Constraints + +- Desktop profile and route metadata remain unchanged; Company Workbench keeps the complete path available through the same collapsed disclosure. +- Data profile, Sources through, Freshness, Price-ready, DCF-ready, next action, and the research-only boundary remain visible or equivalently available. +- No readiness state, source date, source right, research input, forecast, valuation, peer, catalyst, outcome, backtest, or calibration state changes. +- No CSV, JSON, report, sample-report, committed screenshot, or timing artifact is created or staged. +- Candidate context remains untrusted and synthetic fixtures remain test-only. +- No investment advice, ranking, recommendation, price prediction, broker action, order routing, or trade instruction is added. +- Use exact staging only; never use `git add -A`. +- Keep PR #113 open and draft; do not merge or deploy. + +--- + +### Task 1: Compact the shared research context at phone width + +**Files:** +- Modify: `tests/test_research_workspace.py:353-368` +- Modify: `tests/test_research_mode_dashboard_contract.py:202-220` +- Modify: `src/research_workspace.py:376-398` +- Modify: `src/dashboard.py:33966-34057` + +**Interfaces:** +- Consumes: `research_workspace_header_html(page_title, *, ticker, profile_label, freshness, primary_action) -> str` and the existing `.profile-trust-strip.compact` markup. +- Produces: `.research-workspace-freshness` and `.research-workspace-action` semantic hooks plus phone-only CSS that leaves desktop output unchanged. + +- [ ] **Step 1: Write failing HTML and style contract tests** + +Add these assertions to `test_research_workspace_header_keeps_scope_freshness_action_and_boundary_visible`: + +```python +assert "class='research-workspace-meta-item research-workspace-freshness'" in rendered +assert "class='research-workspace-meta-item research-workspace-action'" in rendered +``` + +Add this test to `tests/test_research_mode_dashboard_contract.py`: + +```python +def test_research_workspace_phone_styles_compact_profile_and_hide_only_duplicate_freshness(): + source = dashboard.Path(dashboard.__file__).read_text(encoding="utf-8") + start = source.index("def render_research_workspace_styles()") + end = source.index("\ndef render_research_workspace_header(", start) + styles = source[start:end] + + assert ".profile-trust-strip.compact" in styles + assert "grid-template-columns: repeat(3, minmax(0, 1fr));" in styles + assert ".research-workspace-freshness { display: none; }" in styles + assert ".research-workspace-action" in styles + assert "@media (max-width: 640px)" in styles +``` + +- [ ] **Step 2: Run focused tests and verify the red state** + +Run: + +```bash +python3 -m pytest tests/test_research_workspace.py::test_research_workspace_header_keeps_scope_freshness_action_and_boundary_visible tests/test_research_mode_dashboard_contract.py::test_research_workspace_phone_styles_compact_profile_and_hide_only_duplicate_freshness -q +``` + +Expected: both tests fail because the semantic classes and three-column phone CSS do not exist. + +- [ ] **Step 3: Add semantic route metadata classes** + +Change the metadata markup in `research_workspace_header_html` to: + +```python +"
" +f"
Freshness
{html.escape(str(freshness or 'Check saved readiness'))}
" +f"
Next action
{html.escape(str(primary_action or 'Review source-backed evidence'))}
" +"
" +``` + +- [ ] **Step 4: Add phone-only compaction to the existing research styles** + +Replace the current phone block in `render_research_workspace_styles` with: + +```css +@media (max-width: 640px) { + .research-desk-grid { grid-template-columns: 1fr; } + .research-workspace-header { + padding: .72rem .78rem; + margin-bottom: .65rem; + } + .research-workspace-heading h1 { font-size: 1.4rem; } + .research-workspace-meta { + grid-template-columns: 1fr; + gap: 0; + margin: .5rem 0 .4rem; + } + .research-workspace-freshness { display: none; } + .research-workspace-action { + border-top: 1px solid #e5e9e7; + padding-top: .45rem; + } + .profile-trust-strip.compact { + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: .3rem; + margin: .12rem 0 .6rem; + padding: .45rem 0; + } + .profile-trust-strip.compact > span, + .profile-trust-strip.compact .profile-trust-primary { + padding: 0 .35rem; + } + .profile-trust-strip.compact > :nth-child(3n + 1) { border-left: 0; } +} +``` + +- [ ] **Step 5: Run focused tests and verify the green state** + +Run: + +```bash +python3 -m pytest tests/test_research_workspace.py tests/test_research_mode_dashboard_contract.py -q +``` + +Expected: all focused tests pass. + +- [ ] **Step 6: Commit the semantic and responsive shell slice** + +```bash +git add -- src/research_workspace.py src/dashboard.py tests/test_research_workspace.py tests/test_research_mode_dashboard_contract.py +make staged-hygiene-check +git diff --cached --check +git commit -m "Compact mobile research context" +git push origin codex/personal-research-mode-mvp +``` + +--- + +### Task 2: Collapse the Company Workbench review path + +**Files:** +- Modify: `tests/test_research_mode_dashboard_contract.py` +- Modify: `src/dashboard.py:34153-34185` + +**Interfaces:** +- Consumes: `company_workbench_section_contract() -> list[dict[str, object]]` and Streamlit's existing `st.expander` API. +- Produces: a collapsed `Review path` disclosure immediately after the `Selected Company` heading and before technical coverage or detailed report content. + +- [ ] **Step 1: Write the failing Workbench ordering test** + +Add: + +```python +def test_company_workbench_keeps_selected_company_before_collapsed_review_path_and_details(): + source = dashboard.Path(dashboard.__file__).read_text(encoding="utf-8") + start = source.index("def render_company_workbench(") + end = source.index("\ndef main()", start) + workbench = source[start:end] + + selected = workbench.index('st.markdown("### Selected Company")') + review = workbench.index('with st.expander("Review path", expanded=False):', selected) + path = workbench.index('st.caption(" -> ".join(section_names[:-1]))', review) + coverage = workbench.index('with st.expander("Advanced: selected-company lane coverage", expanded=False):', path) + report = workbench.index("render_single_stock_report(", coverage) + + assert selected < review < path < coverage < report + assert 'st.caption("Review path: "' not in workbench +``` + +- [ ] **Step 2: Run the test and verify the red state** + +Run: + +```bash +python3 -m pytest tests/test_research_mode_dashboard_contract.py::test_company_workbench_keeps_selected_company_before_collapsed_review_path_and_details -q +``` + +Expected: FAIL because the path is an always-visible caption before the `Selected Company` heading. + +- [ ] **Step 3: Implement the collapsed disclosure** + +Replace the Workbench path block with: + +```python +st.markdown("### Selected Company") +section_names = [section["title"] for section in company_workbench_section_contract()] +with st.expander("Review path", expanded=False): + st.caption(" -> ".join(section_names[:-1])) +``` + +Keep `Advanced: selected-company lane coverage` and `render_single_stock_report(...)` directly after this block. + +- [ ] **Step 4: Run focused tests and verify the green state** + +Run: + +```bash +python3 -m pytest tests/test_research_mode_dashboard_contract.py tests/test_dashboard_render_smoke.py -q +``` + +Expected: all focused tests pass and all four Personal Research routes still render. + +- [ ] **Step 5: Commit the Workbench disclosure slice** + +```bash +git add -- src/dashboard.py tests/test_research_mode_dashboard_contract.py +make staged-hygiene-check +git diff --cached --check +git commit -m "Collapse the mobile Workbench review path" +git push origin codex/personal-research-mode-mvp +``` + +--- + +### Task 3: Verify the visual result and document the maturity boundary + +**Files:** +- Modify: `ROADMAP.md` +- Modify: `docs/PERSONAL_RESEARCH_MODE.md` +- Modify: `docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md` +- Test: `tests/test_public_v1_release_docs.py` + +**Interfaces:** +- Consumes: verified implementation behavior and fresh 1280x720 plus 390x844 audit screenshots stored outside the repository. +- Produces: truthful roadmap and continuation claims that describe local usability maturity without claiming source, hosted, reviewer, calibration, or market validation. + +- [ ] **Step 1: Re-capture the four routes outside the repository** + +Capture Research Desk, Discover, Company Workbench, and Monitor at 1280x720 and 390x844 to `/tmp/stock-research-workflow-audit-after/`. + +Expected visible results: + +- desktop retains all existing profile and route metadata; +- the phone profile strip uses two rows; +- the phone route card omits only its duplicate freshness row; +- Discover search is fully visible in the first phone viewport; +- Company Workbench shows `Selected Company` and collapsed `Review path` in the first phone viewport; +- Monitor shows the weekly summary in the first phone viewport; +- no route shows a traceback, horizontal overflow, fabricated content, or missing research-only boundary. + +- [ ] **Step 2: Add a failing documentation contract assertion** + +Extend the Personal Research documentation test in `tests/test_public_v1_release_docs.py` to require these phrases: + +```python +assert "mobile first-action density" in personal_mode.lower() +assert "does not change readiness" in personal_mode.lower() +``` + +- [ ] **Step 3: Run the documentation test and verify the red state** + +Run: + +```bash +python3 -m pytest tests/test_public_v1_release_docs.py -q +``` + +Expected: FAIL because the documentation does not yet describe this slice. + +- [ ] **Step 4: Update roadmap, Personal Research documentation, and continuation truth** + +Document: + +- the four-route phone compaction is implemented locally; +- desktop profile and route metadata plus all evidence boundaries are unchanged; the Workbench path remains available in its collapsed disclosure; +- this improves usability and reviewer comprehension only; +- it does not change readiness or prove source activation, hosted operation, reviewer demand, calibration, commercial demand, or product-market fit; +- the next stage remains one permitted source path followed by controlled hosting and external reviewer validation. + +- [ ] **Step 5: Run focused and full verification** + +Run: + +```bash +python3 -m pytest tests/test_research_workspace.py tests/test_research_mode_dashboard_contract.py tests/test_dashboard_render_smoke.py tests/test_public_v1_release_docs.py -q +python3 -m pytest tests -q +make dashboard-smoke +make research-dashboard-render-smoke +make public-wording-check +make public-check +make commercial-beta-check +make commercial-beta-release-check +make pilot-readiness-check TOP_N=10 +make diff-hygiene-summary +git diff --check +``` + +Expected: every command exits zero; pilot readiness remains `pilot-ready with manual gates`; generated artifact churn remains zero. + +- [ ] **Step 6: Stage exact files and verify the package** + +```bash +git add -- ROADMAP.md docs/PERSONAL_RESEARCH_MODE.md docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md tests/test_public_v1_release_docs.py +make staged-hygiene-check +git diff --cached --check +``` + +Expected: only the four intentional documentation/test files are staged and generated CSV/JSON/report churn is zero. + +- [ ] **Step 7: Commit, push, and update draft PR #113** + +```bash +git commit -m "Document mobile research first-action maturity" +git push origin codex/personal-research-mode-mvp +gh pr comment 113 --body "Verified mobile Personal Research first-action density update: desktop profile and route metadata plus all readiness boundaries remain unchanged; the Workbench path stays available in a collapsed disclosure; phone routes expose their first task sooner; full local release gates passed; no generated CSV, JSON, report, sample-report, screenshot, or timing churn was staged. PR remains draft." +``` + +Expected: branch aligns with its remote, PR #113 remains open and draft, and the new comment records the verified boundary. diff --git a/docs/superpowers/plans/2026-07-18-monitor-answer-first.md b/docs/superpowers/plans/2026-07-18-monitor-answer-first.md new file mode 100644 index 00000000..8d39639a --- /dev/null +++ b/docs/superpowers/plans/2026-07-18-monitor-answer-first.md @@ -0,0 +1,196 @@ +# Monitor Answer-First 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:** Put Monitor's research-change answer and truthful empty-state action before technical Earnings Nowcast readiness evidence while preserving every queue and readiness contract. + +**Architecture:** Reorder existing calls inside `render_research_monitor()` so the weekly summary is followed by the deduplicated queue answer. Add the existing Discover link only for an empty queue, use the neutral context-note treatment, and move unchanged five-company readiness cards into their existing collapsed Advanced drawer. + +**Tech Stack:** Python 3.12, Streamlit, pytest, source-level dashboard contract tests, Markdown documentation. + +## Global Constraints + +- Research-only; no recommendation, expected-return ranking, broker integration, order routing, auto-trading, or direct buy/sell instruction. +- Preserve independent actuals, consensus, Revenue, EPS, valuation, catalyst, outcome, backtesting, and calibration readiness states. +- An empty queue means no saved comparable source-backed change is queued; it never proves that nothing changed in the real world. +- Do not refresh, import, apply, or fabricate source data. +- Keep candidate context unable to modify deterministic scenarios or become trusted evidence. +- Keep generated CSV, JSON, report, sample-report, screenshot, and timing churn unstaged. +- Keep PR #113 draft; do not merge or deploy. + +--- + +### Task 1: Make Monitor answer-first + +**Files:** +- Modify: `tests/test_research_mode_dashboard_contract.py` +- Modify: `tests/test_public_performance_gate.py` +- Modify: `src/dashboard.py` +- Modify: `src/public_performance_gate.py` +- Modify: `docs/PERSONAL_RESEARCH_MODE.md` +- Modify: `docs/DASHBOARD_QA.md` +- Modify: `ROADMAP.md` + +**Interfaces:** +- Consumes: existing `weekly_summary_cards`, `research_monitor_frame`, `render_context_note`, `load_dashboard_nowcast_cohort`, `cohort_readiness_cards`, and `RESEARCH_ROUTE_SPECS` contracts. +- Produces: Monitor order `header -> weekly summary -> change answer -> empty Discover action when applicable -> collapsed readiness evidence`, with unchanged queue rows and readiness payloads. + +- [ ] **Step 1: Write the failing Monitor route-order contract** + +Add a complete order contract alongside the existing Monitor integration assertions: + +```python +def test_monitor_renders_change_answer_before_advanced_readiness(): + source = dashboard.Path(dashboard.__file__).read_text(encoding="utf-8") + monitor_start = source.index("def render_research_monitor(") + monitor_end = source.index("def render_company_workbench(", monitor_start) + monitor = source[monitor_start:monitor_end] + + weekly = monitor.index("weekly_summary_cards(weekly_summary)") + answer = monitor.index('st.markdown("### Research change monitor")', weekly) + frame = monitor.index("research_monitor_frame(state.get", answer) + empty = monitor.index("if frame.empty:", frame) + note = monitor.index("render_context_note(", empty) + discover = monitor.index('st.link_button("Open Discover"', note) + cohort = monitor.index("nowcast_cohort = load_dashboard_nowcast_cohort()", discover) + advanced = monitor.index( + 'with st.expander("Advanced: five-company Earnings Nowcast readiness", expanded=False):', + cohort, + ) + readiness_heading = monitor.index('st.markdown("### Earnings evidence readiness")', advanced) + readiness_cards = monitor.index("cohort_readiness_cards(nowcast_cohort)", readiness_heading) + readiness_frame = monitor.index("pd.DataFrame([asdict(row) for row in nowcast_cohort])", readiness_cards) + + assert weekly < answer < frame < empty < note < discover < cohort < advanced + assert advanced < readiness_heading < readiness_cards < readiness_frame + assert 'tone="success"' not in monitor[empty:discover] +``` + +Add a theme contract for the new primary action: + +```python +def test_dashboard_theme_keeps_primary_link_button_text_white(): + source = dashboard.Path(dashboard.__file__).read_text(encoding="utf-8") + + assert '[data-testid="stLinkButton"] a[kind="primary"],' in source + assert '[data-testid="stLinkButton"] a[kind="primary"] * {' in source + assert "color: #ffffff !important;" in source[source.index('[data-testid="stLinkButton"] a[kind="primary"],'):] +``` + +- [ ] **Step 2: Run the route test and verify the expected failure** + +Run: `python3 -m pytest tests/test_research_mode_dashboard_contract.py::test_monitor_renders_change_answer_before_advanced_readiness -q` + +Expected: FAIL because `Research change monitor` currently occurs after the Nowcast readiness card and there is no empty-state Discover link. + +- [ ] **Step 3: Write the failing performance-marker contract** + +Extend `test_research_performance_contract_covers_the_commercial_beta_workflow` with: + +```python + assert RESEARCH_ROUTE_SPECS[3].first_useful_marker == "WEEKLY RESEARCH SUMMARY" + assert RESEARCH_ROUTE_SPECS[3].full_markers == ( + "WEEKLY RESEARCH SUMMARY", + "Research change monitor", + "No unresolved evidence change is queued.", + "Open Discover", + "Advanced: five-company Earnings Nowcast readiness", + "Research-only", + ) +``` + +Run: `python3 -m pytest tests/test_public_performance_gate.py::test_research_performance_contract_covers_the_commercial_beta_workflow -q` + +Expected: FAIL because Monitor currently treats the route title as first useful and does not require the primary answer or action. + +- [ ] **Step 4: Implement the minimal Monitor hierarchy** + +Change `render_research_monitor()` to this composition: + +```python + render_signal_cards(weekly_summary_cards(weekly_summary), show_commands=False, variant="queue") + st.markdown("### Research change monitor") + frame = research_monitor_frame(state.get("queue") or ()) + if frame.empty: + render_context_note( + "No unresolved evidence change is queued.", + "This is a monitoring state, not a stock ranking. Continue with Discover or wait for a comparable source-backed change.", + ) + st.link_button("Open Discover", "?mode=research&page=discover", type="primary") + else: + st.dataframe(frame, width="stretch", hide_index=True) + nowcast_cohort = load_dashboard_nowcast_cohort() + with st.expander("Advanced: five-company Earnings Nowcast readiness", expanded=False): + st.markdown("### Earnings evidence readiness") + render_signal_cards(cohort_readiness_cards(nowcast_cohort), show_commands=False, variant="queue") + st.dataframe(pd.DataFrame([asdict(row) for row in nowcast_cohort]), width="stretch", hide_index=True) + st.caption("This board creates no forecast. Missing consensus, Q4, split, backtest, and calibration evidence remain separate blockers.") +``` + +Update Monitor's `PublicRouteSpec` to: + +```python + PublicRouteSpec( + "Monitor", + "/?mode=research&page=monitor", + "WEEKLY RESEARCH SUMMARY", + ( + "WEEKLY RESEARCH SUMMARY", + "Research change monitor", + "No unresolved evidence change is queued.", + "Open Discover", + "Advanced: five-company Earnings Nowcast readiness", + "Research-only", + ), + True, + ), +``` + +Keep nested Streamlit Markdown text readable on primary link buttons: + +```css +[data-testid="stLinkButton"] a[kind="primary"], +[data-testid="stLinkButton"] a[kind="primary"] * { + color: #ffffff !important; +} +``` + +- [ ] **Step 5: Run focused tests and verify green** + +Run: `python3 -m pytest tests/test_research_mode_dashboard_contract.py tests/test_research_workspace.py tests/test_dashboard_render_smoke.py tests/test_public_performance_gate.py -q` + +Expected: all focused tests pass with no new warnings or failures. + +- [ ] **Step 6: Update workflow, QA, and roadmap documentation** + +Document the Monitor answer-first order, neutral wait state, conditional Discover action, collapsed five-company readiness evidence, unchanged readiness boundaries, desktop and phone acceptance, and completion of local Stage 1 workflow hardening. Move the next executable roadmap step to one permitted prospective point-in-time consensus source path without claiming that an external dependency is available. + +- [ ] **Step 7: Run the full release verification bundle** + +Run: + +```text +python3 -m pytest tests -q +make dashboard-smoke +make research-dashboard-render-smoke +make commercial-beta-performance-gate +make public-wording-check +make public-check +make pilot-readiness-check TOP_N=10 +make diff-hygiene-summary +git diff --check +``` + +Expected: every command exits zero; generated churn remains excluded. The pilot checklist may retain truthful manual source, hosting, or review gates while the command succeeds. + +- [ ] **Step 8: Stage exact files, verify staged hygiene, commit, and push** + +```text +git add -- ROADMAP.md docs/DASHBOARD_QA.md docs/PERSONAL_RESEARCH_MODE.md src/dashboard.py src/public_performance_gate.py tests/test_public_performance_gate.py tests/test_research_mode_dashboard_contract.py docs/superpowers/plans/2026-07-18-monitor-answer-first.md docs/superpowers/specs/2026-07-18-monitor-answer-first-design.md +make staged-hygiene-check +git diff --cached --check +git commit -m "Prioritize Monitor answers" +git push origin codex/personal-research-mode-mvp +``` + +Expected: exact reviewed files only are committed and pushed; PR #113 remains open and draft. diff --git a/docs/superpowers/plans/2026-07-18-peer-evidence-quality.md b/docs/superpowers/plans/2026-07-18-peer-evidence-quality.md new file mode 100644 index 00000000..34c58d4a --- /dev/null +++ b/docs/superpowers/plans/2026-07-18-peer-evidence-quality.md @@ -0,0 +1,108 @@ +# Peer Evidence Quality 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:** Preserve reviewed peer role and economic-comparability evidence end to end, and withhold peer-median valuation unless a relationship is explicitly eligible to anchor it. + +**Architecture:** A small pure module owns deterministic peer evidence-quality classification. Source review, local provider, readiness generation, and Company Workbench consume that shared result so relationship, trend, read-through, and valuation readiness cannot collapse into one state. + +**Tech Stack:** Python 3.12, pandas, Streamlit, pytest, Markdown, CSV contracts. + +## Global Constraints + +- Research-only; no recommendation, transaction instruction, broker integration, order routing, or auto-trading. +- Candidate context cannot become trusted relationship or valuation evidence. +- Existing rows without explicit role and comparability evidence fail closed for valuation anchoring. +- Do not infer, backfill, or fabricate peer roles, comparability, sources, or timestamps. +- Preserve trend, result read-through, and valuation readiness as independent states. +- Keep technical evidence under Advanced unless needed to explain the primary answer. + +--- + +### Task 1: Deterministic Peer Evidence-Quality Contract + +**Files:** +- Create: `src/peer_evidence_quality.py` +- Create: `tests/test_peer_evidence_quality.py` + +**Interfaces:** +- Consumes: a mapping-shaped peer relationship row. +- Produces: `assess_peer_evidence(row: Mapping[str, object]) -> PeerEvidenceQuality` and `is_valuation_anchor_eligible(row: Mapping[str, object]) -> bool`. + +- [ ] Write failing tests for a fully eligible `core_peer`, a legacy row, a context-only role, an invalid role, missing comparability, and missing provenance. +- [ ] Run `python3 -m pytest tests/test_peer_evidence_quality.py -q` and confirm failures are caused by the missing module. +- [ ] Implement the immutable result type, allowed roles, normalization, blocker ordering, and fail-closed anchor decision. +- [ ] Rerun the focused test and confirm it passes. + +### Task 2: Preserve Review Evidence Through Import + +**Files:** +- Modify: `src/providers/local_schemas.py` +- Modify: `src/peer_mapping_source_review.py` +- Modify: `docs/TRUSTED_PEER_PILOT_SOURCE_TEMPLATE.csv` +- Modify: `tests/test_peer_mapping_source_review.py` +- Modify: `tests/test_trusted_peer_pilot_source_template.py` + +**Interfaces:** +- Consumes: reviewed `peer_role`, `relationship_rationale`, `comparability_basis`, and `valuation_anchor_eligible` fields. +- Produces: exact import header and guarded CSV row that preserve those fields. + +- [ ] Add failing tests proving incomplete evidence remains blocked and a complete row survives into the import scaffold. +- [ ] Run the two focused test modules and confirm the new assertions fail for the missing contract. +- [ ] Extend schema, review row, required fields, scaffold, CLI arguments, rendering, and template without adding an automatic apply path. +- [ ] Rerun the focused tests and confirm they pass. + +### Task 3: Gate Provider Valuation Inputs And Readiness Independently + +**Files:** +- Modify: `src/providers/local_market_data.py` +- Modify: `src/readiness_engine.py` +- Modify: `tests/test_local_market_data_provider.py` +- Modify: `tests/test_readiness_engine.py` + +**Interfaces:** +- Consumes: `assess_peer_evidence` and canonical peer rows. +- Produces: relationship-quality fields in `trusted_relationships`, valuation inputs containing only eligible anchors, and readiness columns for anchor counts/blockers. + +- [ ] Add failing provider tests proving legacy and context-only rows remain visible but are excluded from valuation inputs. +- [ ] Add failing readiness tests proving trend readiness can be true while valuation-anchor readiness is false, and eligible peers can unlock valuation readiness only with financial evidence. +- [ ] Run the focused tests and confirm the expected failures. +- [ ] Implement provider filtering and independent readiness classification using the shared contract. +- [ ] Rerun focused tests and confirm they pass. + +### Task 4: Surface The Answer In Company Workbench + +**Files:** +- Modify: `src/peer_read_through_map.py` +- Modify: `src/dashboard.py` +- Modify: `tests/test_peer_read_through_map.py` +- Modify: `tests/test_dashboard_helpers.py` + +**Interfaces:** +- Consumes: provider relationship-quality fields or the shared classifier. +- Produces: compact peer rows with `Peer Role`, `Comparability`, and `Valuation Anchor`, plus answer-first summary copy. + +- [ ] Add failing tests for eligible, context-only, and legacy-withheld table states and summary copy. +- [ ] Run focused tests and confirm the table assertions fail. +- [ ] Extend map edges, deterministic identity, compact rows, frame columns, and summary cards; keep raw evidence in Advanced. +- [ ] Rerun focused tests and confirm they pass. + +### Task 5: Documentation And Release Verification + +**Files:** +- Modify: `ROADMAP.md` +- Modify: `docs/METHODOLOGY.md` +- Modify: `docs/PILOT_RUNBOOK.md` +- Modify: `docs/PROVENANCE_CONTRACT.md` +- Modify: `docs/PERSONAL_RESEARCH_MODE.md` + +**Interfaces:** +- Consumes: verified behavior from Tasks 1-4. +- Produces: truthful methodology, migration boundary, pilot procedure, and next external dependency. + +- [ ] Document the new independent states and the explicit legacy-row migration boundary. +- [ ] Run focused peer/schema/dashboard tests. +- [ ] Run `python3 -m pytest tests -q`. +- [ ] Run `make dashboard-smoke`, `make public-wording-check`, `make public-check`, `make pilot-readiness-check TOP_N=10`, `make diff-hygiene-summary`, and `git diff --check`. +- [ ] Stage only the exact product, test, spec, plan, template, and documentation paths; run `make staged-hygiene-check`. +- [ ] Commit one coherent slice, push only `codex/personal-research-mode-mvp`, and update draft PR #113 without changing its draft state. diff --git a/docs/superpowers/plans/2026-07-18-personal-research-evidence-detour-continuity.md b/docs/superpowers/plans/2026-07-18-personal-research-evidence-detour-continuity.md new file mode 100644 index 00000000..368c1d30 --- /dev/null +++ b/docs/superpowers/plans/2026-07-18-personal-research-evidence-detour-continuity.md @@ -0,0 +1,273 @@ +# Personal Research Evidence Detour Continuity 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:** Keep Company Workbench evidence detours inside Personal Research mode and provide one direct return action without adding routes or changing evidence state. + +**Architecture:** Pure helpers in `src/research_workspace.py` own same-mode evidence URLs and the deterministic return destination. `src/dashboard.py` renders the return action only for research-mode Data Health and Proof History, before existing evidence content. AppTest render smoke expands from four primary routes to those four plus the two secondary evidence detours. + +**Tech Stack:** Python 3, Streamlit, pandas, pytest, AppTest, Markdown. + +## Global Constraints + +- Do not run `make readiness`. +- Do not create or stage CSV, JSON, report, sample-report, screenshot, timing, or bytecode churn. +- Do not add a route, alias, session state, persistence layer, or data mutation. +- Data Health and Proof History remain secondary Advanced evidence routes. +- Public and Operator mode behavior remains unchanged. +- Missing ticker returns to Research Desk; never infer a company. +- Navigation cannot mutate readiness, source, valuation, consensus, forecast, catalyst, outcome, backtest, or calibration state. +- Preserve research-only, candidate-context, source-rights, explicit-Q4, EPS split-basis, synthetic-fixture, and no-investment-advice boundaries. + +--- + +### Task 1: Same-mode evidence and return-link helpers + +**Files:** +- Modify: `src/research_workspace.py:359-376` +- Test: `tests/test_research_workspace.py:336-351` + +**Interfaces:** +- Consumes: existing URL quoting and ticker normalization in `src.research_workspace`. +- Produces: `advanced_evidence_links(ticker: str) -> list[dict[str, str]]` with research-mode URLs and `research_evidence_return_link(ticker: str) -> dict[str, str]`. + +- [ ] **Step 1: Write failing route-helper tests** + +Replace the cross-mode expectation and add return-destination coverage: + +```python +def test_advanced_evidence_links_preserve_personal_research_mode_and_ticker(): + links = advanced_evidence_links("NVDA") + + assert links[0]["href"] == "?mode=research&page=data-health&ticker=NVDA" + assert links[1]["href"] == "?mode=research&page=proof-history&ticker=NVDA" + + +def test_research_evidence_return_link_preserves_ticker_or_falls_back_to_desk(): + assert research_evidence_return_link("BRK/B") == { + "label": "Return to Company Workbench", + "href": "?mode=research&page=company-workbench&ticker=BRK%2FB&open=1", + "purpose": "Continue the selected-company review without changing evidence state.", + } + assert research_evidence_return_link("")["href"] == "?mode=research&page=research-desk" +``` + +Use a URL-encoding assertion for a ticker such as `BRK/B`. The implementation must call `quote(..., safe="")`, so the expected query value is `BRK%2FB`. + +- [ ] **Step 2: Run the tests and verify red** + +Run: `python3 -m pytest tests/test_research_workspace.py -q` + +Expected: FAIL because Advanced Evidence still switches modes and `research_evidence_return_link` does not exist. + +- [ ] **Step 3: Implement minimal pure routing helpers** + +Import `research_evidence_return_link` in the test and implement: + +```python +def _quoted_ticker(ticker: str) -> str: + return quote(str(ticker or "").strip().upper(), safe="") + + +def advanced_evidence_links(ticker: str) -> list[dict[str, str]]: + symbol = _quoted_ticker(ticker) + suffix = f"&ticker={symbol}" if symbol else "" + return [ + { + "label": "Open Data Health", + "href": f"?mode=research&page=data-health{suffix}", + "purpose": "Inspect blocked inputs and source-proof paths.", + }, + { + "label": "Open Proof History", + "href": f"?mode=research&page=proof-history{suffix}", + "purpose": "Review evidence that changed a readiness state.", + }, + ] + + +def research_evidence_return_link(ticker: str) -> dict[str, str]: + symbol = _quoted_ticker(ticker) + if symbol: + return { + "label": "Return to Company Workbench", + "href": f"?mode=research&page=company-workbench&ticker={symbol}&open=1", + "purpose": "Continue the selected-company review without changing evidence state.", + } + return { + "label": "Return to Research Desk", + "href": "?mode=research&page=research-desk", + "purpose": "Return to the primary research workflow without changing evidence state.", + } +``` + +- [ ] **Step 4: Verify helper behavior** + +Run: `python3 -m pytest tests/test_research_workspace.py -q` + +Expected: PASS, including command-free Advanced Evidence HTML tests. + +- [ ] **Step 5: Commit the helper slice** + +```bash +git add -- src/research_workspace.py tests/test_research_workspace.py +make staged-hygiene-check +git diff --cached --check +git commit -m "Keep research evidence links in workspace" +``` + +### Task 2: Evidence-page return action and render coverage + +**Files:** +- Modify: `src/dashboard.py:330-345,34470-34493` +- Modify: `src/dashboard_render_smoke.py:84-127` +- Test: `tests/test_research_mode_dashboard_contract.py` +- Test: `tests/test_dashboard_render_smoke.py` + +**Interfaces:** +- Consumes: `research_evidence_return_link(ticker) -> dict[str, str]` from Task 1. +- Produces: a primary `st.link_button` on research-mode Data Health and Proof History, plus AppTest route contracts for both detours. + +- [ ] **Step 1: Write failing dashboard source-order tests** + +Add source-contract assertions that each research evidence branch obtains the current ticker, renders the research header, renders `research_evidence_return_link`, then renders the existing page: + +```python +def test_research_evidence_detours_offer_return_before_evidence_content(): + source = dashboard.Path(dashboard.__file__).read_text(encoding="utf-8") + data_start = source.index('elif content_page == "Data Health":') + data_end = source.index('elif content_page == PROOF_HISTORY_PATH_TITLE:', data_start) + data = source[data_start:data_end] + proof_start = data_end + proof_end = source.index('elif content_page == "Universe Manager":', proof_start) + proof = source[proof_start:proof_end] + + for branch, renderer in ((data, "render_data_health("), (proof, "render_proof_history(")): + header = branch.index("render_research_workspace_header(") + return_link = branch.index("research_evidence_return_link(", header) + button = branch.index("st.link_button(", return_link) + content = branch.index(renderer, button) + assert header < return_link < button < content +``` + +- [ ] **Step 2: Add failing render-route contracts** + +Append two `DashboardRenderRoute` entries to `RESEARCH_RENDER_ROUTES`: + +```python +DashboardRenderRoute( + name="Research Data Health", + query_params=(("mode", "research"), ("page", "data-health"), ("ticker", "NVDA")), + required_markers=("Data Health", "Return to Company Workbench", "Research-only"), +), +DashboardRenderRoute( + name="Research Proof History", + query_params=(("mode", "research"), ("page", "proof-history"), ("ticker", "NVDA")), + required_markers=("Proof History", "Return to Company Workbench", "Research-only"), +), +``` + +Extend `tests/test_dashboard_render_smoke.py` to expect the six research route names in order. + +- [ ] **Step 3: Run focused tests and verify red** + +Run: + +```bash +python3 -m pytest tests/test_research_mode_dashboard_contract.py tests/test_dashboard_render_smoke.py -q +``` + +Expected: FAIL because the return action is not integrated or rendered. + +- [ ] **Step 4: Implement the return action in existing branches** + +Import `research_evidence_return_link` beside the existing research workspace helpers. In each research-mode evidence branch: + +```python +ticker = str(st.query_params.get("ticker") or "").strip().upper() +render_research_workspace_header(..., ticker=ticker, ...) +return_link = research_evidence_return_link(ticker) +st.link_button(return_link["label"], return_link["href"], type="primary") +``` + +Do not render the button for Public or Operator modes. Leave `render_data_health(..., public_mode=not operator_mode)` and `render_proof_history(public_mode=not operator_mode)` unchanged. + +- [ ] **Step 5: Verify route contracts and render smoke** + +Run: + +```bash +python3 -m pytest tests/test_research_workspace.py tests/test_research_mode_dashboard_contract.py tests/test_dashboard_render_smoke.py -q +make research-dashboard-render-smoke +``` + +Expected: all six research routes pass; no screenshots or output files are created. + +- [ ] **Step 6: Commit the dashboard integration** + +```bash +git add -- src/dashboard.py src/dashboard_render_smoke.py tests/test_research_mode_dashboard_contract.py tests/test_dashboard_render_smoke.py +make staged-hygiene-check +git diff --cached --check +git commit -m "Add return path for research evidence detours" +``` + +### Task 3: Documentation, complete verification, and draft PR + +**Files:** +- Modify: `ROADMAP.md` +- Modify: `docs/PERSONAL_RESEARCH_MODE.md` +- Modify: `docs/DASHBOARD_QA.md` +- Modify: `docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md` +- Modify: `tests/test_public_v1_release_docs.py` +- Update: draft PR #113 comment only after verification. + +**Interfaces:** +- Consumes: verified same-mode evidence detours and return action. +- Produces: durable workflow and QA claims bounded to local route continuity. + +- [ ] **Step 1: Write failing documentation assertions** + +Require ROADMAP, Personal Research Mode, Dashboard QA, and the continuation prompt to state that Data Health and Proof History preserve Personal Research mode and expose a return path without changing readiness or evidence state. + +- [ ] **Step 2: Run the documentation contract and verify red** + +Run: `python3 -m pytest tests/test_public_v1_release_docs.py -q` + +Expected: FAIL because the continuity claim is not yet documented. + +- [ ] **Step 3: Update documentation without overstating maturity** + +Record that the slice improves local workflow continuity and evidence-review usability. State explicitly that it does not prove source rights, hosted behavior, accessibility compliance, reviewer adoption, market demand, calibration, or product-market fit. Preserve the four primary route hierarchy and secondary Advanced evidence boundary. + +- [ ] **Step 4: Run focused and full verification** + +```bash +python3 -m pytest tests/test_research_workspace.py tests/test_research_mode_dashboard_contract.py tests/test_dashboard_render_smoke.py tests/test_public_v1_release_docs.py -q +python3 -m pytest tests -q +make dashboard-smoke +make research-dashboard-render-smoke +make public-wording-check +make public-check +make commercial-beta-check +make commercial-beta-release-check +make pilot-readiness-check TOP_N=10 +make diff-hygiene-summary +git diff --check +``` + +Expected: executable local gates pass; pilot remains blocked by stale saved readiness; zero generated artifact candidates. + +- [ ] **Step 5: Commit and push exact documentation paths** + +```bash +git add -- ROADMAP.md docs/PERSONAL_RESEARCH_MODE.md docs/DASHBOARD_QA.md docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md tests/test_public_v1_release_docs.py +make staged-hygiene-check +git diff --cached --check +git commit -m "Document research evidence detour continuity" +git push origin codex/personal-research-mode-mvp +``` + +- [ ] **Step 6: Update draft PR #113 and reassess the active goal** + +Comment with the behavior, exact test evidence, no-artifact proof, screenshot-audit limitation, and remaining external gates. Verify the PR remains open and draft, branch divergence is zero, and the worktree is clean. Continue the overall goal unless every source, hosted, reviewer, evidence-depth, calibration, and operating gate has direct evidence. diff --git a/docs/superpowers/plans/2026-07-18-quarterly-cash-generation-adapter-acceptance.md b/docs/superpowers/plans/2026-07-18-quarterly-cash-generation-adapter-acceptance.md new file mode 100644 index 00000000..a1c322cf --- /dev/null +++ b/docs/superpowers/plans/2026-07-18-quarterly-cash-generation-adapter-acceptance.md @@ -0,0 +1,461 @@ +# Quarterly Cash-Generation Adapter Acceptance 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:** Build a pure one-company acceptance harness that fails closed on source rights, identity, cutoff, revision, Q4, component, and compatibility defects without reading or writing adapter files or activating production readiness. + +**Architecture:** A focused adapter-governance module composes the immutable commercial source-rights registry with the existing quarterly cash-generation derivation. It returns one immutable acceptance result with deterministic blockers and an explicit non-activation boundary. Documentation and contract tests keep `accepted_for_review` distinct from real source review, production activation, and market validation. + +**Tech Stack:** Python 3.12 dataclasses and immutable mappings, existing `src.commercial_source_rights` and `src.quarterly_cash_generation` contracts, pytest, Markdown contract tests, Make release gates. + +## Global Constraints + +- Accept only in-memory `QuarterlyBusinessObservation` and `QuarterlyActual` objects; add no file loader or writer. +- Add no CLI, Make target, network request, credential read, data directory, CSV, JSON, report, sample report, screenshot, timing output, template, or canonical row. +- The only successful status is `accepted_for_review`; it never means reviewed, production-ready, or commercially activated. +- `production_activation` is always `False`; `readiness_promotions` is always empty. +- Commercial rights and all three required source fields must be explicitly approved in the supplied registry. +- Q4 remains explicit filed-quarter only; annual-minus-nine-month derivation is forbidden. +- Capital expenditures preserve the reported sign; free cash flow remains cash from operations plus reported capital expenditures. +- Synthetic observations and synthetic rights records remain test-only. +- Stage exact intentional files only; never use `git add -A`; keep all generated churn excluded. +- Keep PR #113 open and draft; do not merge or deploy. + +--- + +### Task 1: Pure One-Company Acceptance Contract + +**Files:** +- Create: `src/quarterly_cash_generation_adapter.py` +- Create: `tests/test_quarterly_cash_generation_adapter.py` + +**Interfaces:** +- Consumes: `Mapping[str, SourceRights]`, `Iterable[QuarterlyBusinessObservation]`, `Iterable[QuarterlyActual]`, and optional `as_of: str | None`. +- Produces: `QuarterlyAdapterAcceptance` and `assess_quarterly_cash_generation_adapter(ticker, source_id, observations, revenue_actuals, *, rights_registry, as_of=None)`. + +- [ ] **Step 1: Add shared synthetic constructors and the first failing success-path test** + +Create `tests/test_quarterly_cash_generation_adapter.py` with constructors for one `QuarterlyBusinessObservation`, one compatible `QuarterlyActual`, and an immutable registry built through `build_source_rights_registry`. The success test must assert the complete public result: + +```python +def test_complete_one_company_batch_is_accepted_for_review_without_activation(): + observations = [ + _observation(metric="operating_income", value=50.0), + _observation(metric="cash_from_operations", value=60.0), + _observation(metric="capital_expenditures", value=-20.0), + ] + + result = assess_quarterly_cash_generation_adapter( + "SYN1", + "synthetic_adapter", + observations, + [_actual(revenue=200.0)], + rights_registry=_rights_registry(), + ) + + assert result.status == "accepted_for_review" + assert result.blockers == () + assert result.accepted_observation_count == 3 + assert result.reviewed_metrics == ( + "capital_expenditures", + "cash_from_operations", + "operating_income", + ) + assert result.derived_point_count == 3 + assert result.rights_status == "approved" + assert result.production_activation is False + assert result.readiness_promotions == () +``` + +- [ ] **Step 2: Run the test and verify RED** + +Run: + +```bash +python3 -m pytest tests/test_quarterly_cash_generation_adapter.py::test_complete_one_company_batch_is_accepted_for_review_without_activation -q +``` + +Expected: collection fails because `src.quarterly_cash_generation_adapter` does not exist. + +- [ ] **Step 3: Implement the immutable result and minimal success path** + +Create `src/quarterly_cash_generation_adapter.py` with: + +```python +from __future__ import annotations + +from dataclasses import dataclass +from typing import Iterable, Mapping + +from src.commercial_source_rights import SourceRights, commercial_eligibility +from src.earnings_nowcast_contract import QuarterlyActual +from src.quarterly_cash_generation import ( + QuarterlyBusinessObservation, + derive_quarterly_business_metrics, +) + +REQUIRED_SOURCE_FIELDS = frozenset( + {"operating_income", "cash_from_operations", "capital_expenditures"} +) + + +@dataclass(frozen=True) +class QuarterlyAdapterAcceptance: + ticker: str + source_id: str + status: str + blockers: tuple[str, ...] + accepted_observation_count: int + reviewed_metrics: tuple[str, ...] + derived_point_count: int + explicit_q4_periods: tuple[str, ...] + rights_status: str + production_activation: bool = False + readiness_promotions: tuple[str, ...] = () + + +def assess_quarterly_cash_generation_adapter( + ticker: str, + source_id: str, + observations: Iterable[QuarterlyBusinessObservation], + revenue_actuals: Iterable[QuarterlyActual], + *, + rights_registry: Mapping[str, SourceRights], + as_of: str | None = None, +) -> QuarterlyAdapterAcceptance: + symbol = str(ticker or "").strip().upper() + normalized_source = str(source_id or "").strip() + supplied = tuple(observations) + revenues = tuple(revenue_actuals) + decision = commercial_eligibility(rights_registry, normalized_source) + derivation = derive_quarterly_business_metrics(symbol, supplied, revenues, as_of=as_of) + reviewed_metrics = tuple(sorted({row.metric for row in supplied})) + q4_periods = tuple(sorted({row.fiscal_period for row in supplied if row.fiscal_period.endswith("-Q4")})) + return QuarterlyAdapterAcceptance( + ticker=symbol, + source_id=normalized_source, + status="accepted_for_review", + blockers=(), + accepted_observation_count=len(supplied), + reviewed_metrics=reviewed_metrics, + derived_point_count=len(derivation.points), + explicit_q4_periods=q4_periods, + rights_status=decision.status, + ) +``` + +- [ ] **Step 4: Run the success-path test and verify GREEN** + +Run the Step 2 command. Expected: 1 passed. + +- [ ] **Step 5: Add failing identity, rights, and required-field tests** + +Add separate tests asserting these exact blockers: + +```python +@pytest.mark.parametrize( + ("ticker", "source_id", "observations", "blocker"), + [ + ("", "synthetic_adapter", (), "ticker_required"), + ("SYN1", "", (), "source_id_required"), + ("SYN1", "synthetic_adapter", (), "observations_required"), + ], +) +def test_required_identity_and_observations_fail_closed(ticker, source_id, observations, blocker): + result = assess_quarterly_cash_generation_adapter( + ticker, + source_id, + observations, + [_actual()], + rights_registry=_rights_registry(), + ) + assert result.status == "blocked" + assert blocker in result.blockers + assert result.accepted_observation_count == 0 + + +def test_mixed_ticker_and_source_mismatch_are_reported_together(): + rows = [ + _observation(), + _observation(ticker="OTHER"), + _observation(metric="operating_income", source="other_source"), + ] + result = _assess(rows) + assert "mixed_ticker:OTHER" in result.blockers + assert "source_mismatch:other_source" in result.blockers + + +def test_unknown_or_unverified_rights_block_acceptance(): + unknown = _assess(_complete_rows(), source_id="unknown") + assert "source_rights:unknown_source" in unknown.blockers + unverified = _assess(_complete_rows(), rights_registry=_rights_registry(commercial_use="unverified")) + assert "source_rights:commercial_rights_unverified" in unverified.blockers + + +def test_approved_source_must_explicitly_support_every_component(): + result = _assess( + _complete_rows(), + rights_registry=_rights_registry(supported_fields=["operating_income"]), + ) + assert result.blockers == ( + "source_fields_missing:capital_expenditures,cash_from_operations", + ) +``` + +- [ ] **Step 6: Run the new tests and verify RED** + +Run: + +```bash +python3 -m pytest tests/test_quarterly_cash_generation_adapter.py -q +``` + +Expected: failures because the minimal implementation always accepts. + +- [ ] **Step 7: Implement deterministic identity and source-rights blockers** + +Add one blocker list, normalize identity before derivation, evaluate `commercial_eligibility`, compare `SourceRights.supported_fields` with `REQUIRED_SOURCE_FIELDS`, and collect sorted mixed ticker/source values. Return `blocked` whenever blockers are non-empty; blocked results set `accepted_observation_count=0`. + +Use exact blocker formats: + +```python +if not symbol: + blockers.append("ticker_required") +if not normalized_source: + blockers.append("source_id_required") +if not supplied: + blockers.append("observations_required") +for other in sorted({row.ticker for row in supplied if row.ticker != symbol}): + blockers.append(f"mixed_ticker:{other}") +for other in sorted({row.source for row in supplied if row.source != normalized_source}): + blockers.append(f"source_mismatch:{other}") +if not decision.allowed: + blockers.append(f"source_rights:{decision.status}") +record = rights_registry.get(normalized_source) +if record is not None: + missing_fields = sorted(REQUIRED_SOURCE_FIELDS - set(record.supported_fields)) + if missing_fields: + blockers.append(f"source_fields_missing:{','.join(missing_fields)}") +``` + +- [ ] **Step 8: Run the test module and verify GREEN** + +Run the Step 6 command. Expected: all current tests pass. + +- [ ] **Step 9: Add failing cutoff, revision, component, compatibility, and Q4 tests** + +Add tests that prove: + +- a post-cutoff observation returns its existing `YYYY-QN:metric:post_cutoff` blocker; +- one explicit revision leaf is accepted and increments only the derived revision behavior; +- conflicting capex leaves return `YYYY-QN:capital_expenditures:ambiguous_revision`; +- missing a required component returns `YYYY-QN:missing_component:`; +- missing or incompatible Revenue returns the existing operating-margin and FCF-margin blockers and prevents complete-period acceptance; +- incompatible cash-flow component definitions preserve the existing `free_cash_flow:incompatible_components` blocker; +- a complete Q4 batch with `explicit_filed_quarter` records `explicit_q4_periods == ("2025-Q4",)`; +- constructing a Q4 observation with `q4_evidence_state="not_q4"` still raises `ValueError` before the harness runs. + +The incomplete-period assertion must be: + +```python +assert "2025-Q1:missing_component:capital_expenditures" in result.blockers +assert "complete_derived_period_required" in result.blockers +``` + +- [ ] **Step 10: Run the test module and verify RED** + +Run the Step 6 command. Expected: new blocker and complete-period assertions fail. + +- [ ] **Step 11: Compose existing derivation blockers and enforce one complete derived period** + +For observations matching the requested ticker and source, group supplied metric names by fiscal period and append missing-component blockers in fiscal-period and metric order. Derive only requested-ticker observations, append `derivation.blockers`, and calculate periods containing all three derived metrics: + +```python +points_by_period: dict[str, set[str]] = {} +for point in derivation.points: + points_by_period.setdefault(point.fiscal_period, set()).add(point.metric) +complete_periods = tuple( + sorted( + period + for period, metrics in points_by_period.items() + if {"operating_margin", "free_cash_flow", "fcf_margin"}.issubset(metrics) + ) +) +if not complete_periods: + blockers.append("complete_derived_period_required") +``` + +Deduplicate blockers in insertion order with `tuple(dict.fromkeys(blockers))`. Set `derived_point_count` from the derivation even when blocked, but keep accepted observation count at zero. + +- [ ] **Step 12: Run the test module and verify GREEN** + +Run the Step 6 command. Expected: all acceptance tests pass. + +- [ ] **Step 13: Add and satisfy no-persistence surface tests** + +Add: + +```python +def test_adapter_acceptance_module_has_no_file_network_or_cli_surface(): + source = Path("src/quarterly_cash_generation_adapter.py").read_text(encoding="utf-8") + makefile = Path("Makefile").read_text(encoding="utf-8") + for forbidden in ( + "argparse", "requests", "urllib", "Path(", ".open(", "read_text(", + "write_text(", "csv", "json", "output_dir", "__main__", + ): + assert forbidden not in source + assert "quarterly-cash-generation-adapter" not in makefile +``` + +Run: + +```bash +python3 -m pytest tests/test_quarterly_cash_generation_adapter.py tests/test_quarterly_cash_generation.py -q +``` + +Expected: all tests pass. + +- [ ] **Step 14: Commit the contract slice** + +```bash +git add -- src/quarterly_cash_generation_adapter.py tests/test_quarterly_cash_generation_adapter.py +make staged-hygiene-check +git diff --cached --check +git commit -m "Add quarterly adapter acceptance harness" +git push origin codex/personal-research-mode-mvp +``` + +--- + +### Task 2: Methodology, Roadmap, And Continuation Contract + +**Files:** +- Modify: `docs/METHODOLOGY.md` +- Modify: `docs/PROVENANCE_CONTRACT.md` +- Modify: `docs/PERSONAL_RESEARCH_MODE.md` +- Modify: `ROADMAP.md` +- Modify: `docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md` +- Modify: `tests/test_public_v1_release_docs.py` + +**Interfaces:** +- Consumes: the Task 1 `accepted_for_review`, blocker, rights, and non-activation contract. +- Produces: durable maturity and continuation claims plus documentation regression coverage. + +- [ ] **Step 1: Write the failing documentation contract test** + +Add to `tests/test_public_v1_release_docs.py`: + +```python +def test_quarterly_adapter_acceptance_docs_keep_review_and_activation_separate(): + methodology = _read("docs/METHODOLOGY.md") + provenance = _read("docs/PROVENANCE_CONTRACT.md") + personal_mode = _read("docs/PERSONAL_RESEARCH_MODE.md") + roadmap = _read("ROADMAP.md") + prompt = _read("docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md") + + assert "accepted_for_review is not production activation" in methodology + assert "production_activation=false" in provenance + assert "readiness_promotions=()" in provenance + assert "no adapter file is loaded or written" in personal_mode + assert "one-company adapter acceptance harness" in roadmap + assert "does not prove a real-company source payload" in roadmap + assert "Quarterly adapter acceptance" in prompt + assert "accepted_for_review" in prompt +``` + +- [ ] **Step 2: Run the test and verify RED** + +Run: + +```bash +python3 -m pytest tests/test_public_v1_release_docs.py::test_quarterly_adapter_acceptance_docs_keep_review_and_activation_separate -q +``` + +Expected: failure because the new acceptance distinctions are absent. + +- [ ] **Step 3: Update methodology and provenance** + +Document the one-company scope, explicit rights-field checks, complete compatible period requirement, deterministic blockers, and the exact statement `accepted_for_review is not production activation`. In provenance, record `production_activation=false`, `readiness_promotions=()`, no file/network/credential surface, and unchanged external review requirement. + +- [ ] **Step 4: Update Personal Research guidance and ROADMAP** + +State that no adapter file is loaded or written and Company Workbench remains withheld because production supplies no accepted observations. Add the implemented one-company adapter acceptance harness to the current foundation and state that it does not prove a real-company source payload, reviewed rights expansion, or market validation. + +- [ ] **Step 5: Update the persistent continuation goal** + +Add the local acceptance harness to implemented capabilities. Add a `Quarterly adapter acceptance` boundary that requires the checked source-rights record to explicitly support all three component fields and still classifies real activation as `external_source_and_review_required`. Preserve automatic continuation across safe local work and the strict no-generated-artifact rule. + +- [ ] **Step 6: Run focused documentation and contract tests** + +```bash +python3 -m pytest tests/test_public_v1_release_docs.py tests/test_quarterly_cash_generation_adapter.py tests/test_quarterly_cash_generation.py -q +``` + +Expected: all pass. + +- [ ] **Step 7: Commit the documentation slice** + +```bash +git add -- docs/METHODOLOGY.md docs/PROVENANCE_CONTRACT.md docs/PERSONAL_RESEARCH_MODE.md ROADMAP.md docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md tests/test_public_v1_release_docs.py +make staged-hygiene-check +git diff --cached --check +git commit -m "Document quarterly adapter acceptance boundary" +git push origin codex/personal-research-mode-mvp +``` + +--- + +### Task 3: Full Release Verification And Draft PR Update + +**Files:** +- No new product files. +- Update remote draft PR #113 only after all local evidence passes. + +**Interfaces:** +- Consumes: committed Tasks 1-2. +- Produces: clean aligned branch, verified release evidence, updated draft PR, and next-goal prompt grounded in current truth. + +- [ ] **Step 1: Run the full verification bundle** + +```bash +python3 -m pytest tests -q +make dashboard-smoke +make research-dashboard-render-smoke +make public-wording-check +make public-check +make commercial-beta-check +make commercial-beta-release-check +make pilot-readiness-check TOP_N=10 +make diff-hygiene-summary +git diff --check +``` + +Expected: tests and static/runtime gates pass; pilot readiness may retain named manual external gates; no generated artifact candidate exists. + +- [ ] **Step 2: Verify repository and PR truth** + +```bash +git status --short --branch +git rev-list --left-right --count origin/codex/personal-research-mode-mvp...HEAD +gh pr view 113 --json state,isDraft,mergeStateStatus,headRefName,url +``` + +Expected: clean tree, `0 0` divergence, PR open and draft on the named branch. + +- [ ] **Step 3: Update draft PR #113** + +Add a verified comment covering: + +- pure one-company acceptance behavior; +- deterministic rights, identity, cutoff, revision, component, compatibility, and Q4 blockers; +- `accepted_for_review` versus production activation; +- no file, CLI, network, credential, writer, or generated artifact; +- exact test and release-gate results; +- the current SEC rights-record field limitation; +- `external_source_and_review_required` as the real activation gate. + +Keep the PR draft and do not merge or deploy. + +- [ ] **Step 4: Produce the next-goal prompt** + +Deliver a copy-ready prompt that starts from current repository truth, verifies the latest commit and draft PR, preserves no-file and independent-readiness boundaries, classifies the real adapter/source dependency once, and moves to the next safe local maturity gap without repeating broad source or generated-artifact loops. diff --git a/docs/superpowers/plans/2026-07-18-quarterly-cash-generation-contract.md b/docs/superpowers/plans/2026-07-18-quarterly-cash-generation-contract.md new file mode 100644 index 00000000..176c0ac8 --- /dev/null +++ b/docs/superpowers/plans/2026-07-18-quarterly-cash-generation-contract.md @@ -0,0 +1,397 @@ +# Quarterly Cash-Generation Evidence Contract 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:** Add source-bound quarterly operating-margin, free-cash-flow, and FCF-margin trend readiness without adding a data file, changing Earnings Nowcast, or fabricating real-company evidence. + +**Architecture:** A focused domain module validates immutable in-memory component observations and derives metric points from compatible evidence. The existing quarterly trend composer accepts observations optionally and keeps metric readiness independent; production callers supply none until a reviewed adapter exists, so real output remains withheld. Existing answer-first cards show conclusions while component lineage stays Advanced. + +**Tech Stack:** Python 3.12 dataclasses, existing `parse_utc_timestamp` and quarterly trend contracts, pytest, Streamlit AppTest. + +## Global Constraints + +- Do not add, generate, modify, or stage any CSV, JSON, report, sample report, screenshot, browser timing, canonical data, template, writer, or Make target. +- Keep `QuarterlyActual`, Earnings Nowcast input hashes, Revenue/EPS readiness, consensus readiness, ranges, backtesting, and calibration unchanged. +- Q4 requires `explicit_filed_quarter`; annual-minus-nine-month derivation is forbidden. +- Free cash flow is exactly `cash_from_operations + capital_expenditures`, preserving the reported capex cash-flow sign. +- Do not infer capex sign, accounting basis, duration basis, source time, fiscal identity, or metric compatibility. +- Supplemental readiness cannot unlock DCF, peers, catalysts, outcomes, Nowcast, backtesting, calibration, rankings, recommendations, or actions. +- Synthetic observations are test-only and use `source="synthetic_test_fixture"`. +- Empty production input stays visibly withheld. +- Stage exact code, documentation, and test paths only; never use `git add -A`. + +## File Map + +- Create `src/quarterly_cash_generation.py`: observation validation, revision resolution, compatibility, derived metric points, and blockers. +- Create `tests/test_quarterly_cash_generation.py`: pure in-memory domain tests. +- Modify `src/quarterly_business_trend.py` and `tests/test_quarterly_business_trend.py`: optional observations and independent trends. +- Modify `src/research_workspace.py`, `tests/test_research_workspace.py`, and `tests/test_dashboard_helpers.py`: answer-first cards and Advanced evidence boundary. +- Modify `docs/METHODOLOGY.md`, `docs/PROVENANCE_CONTRACT.md`, `docs/PERSONAL_RESEARCH_MODE.md`, `ROADMAP.md`, `docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md`, and `tests/test_public_v1_release_docs.py`: durable claims and maturity boundary. + +--- + +### Task 1: Immutable Quarterly Component Evidence + +**Files:** +- Create: `src/quarterly_cash_generation.py` +- Create: `tests/test_quarterly_cash_generation.py` + +**Interfaces:** +- Produces: `QuarterlyBusinessObservation`, `QuarterlyBusinessMetricPoint`, `QuarterlyBusinessDerivation`, and `derive_quarterly_business_metrics(ticker, observations, revenue_actuals, *, as_of=None)`. +- Consumes: `QuarterlyActual` and `parse_utc_timestamp` from `src.earnings_nowcast_contract`. + +- [ ] **Step 1: Write failing constructor tests** + +Use this fixture and add parameterized failures for unsupported metric, invalid fiscal period, non-finite value, non-positive scale, naive timestamps, and invalid Q4 evidence: + +```python +def observation(period="2025-Q1", metric="cash_from_operations", value=100.0, **overrides): + values = { + "ticker": "syn1", "fiscal_period": period, "period_end_date": "2025-03-31", + "metric": metric, "value": value, "currency": "usd", "unit_scale": 1.0, + "accounting_basis": "gaap", "duration_basis": "three_months", + "source": "synthetic_test_fixture", "source_ref": f"fixture:{period}:{metric}", + "published_at": "2025-05-15T12:00:00+00:00", + "retrieved_at": "2026-07-18T12:00:00+00:00", "q4_evidence_state": "not_q4", + } + values.update(overrides) + return QuarterlyBusinessObservation(**values) + +def test_observation_requires_explicit_q4_evidence(): + with pytest.raises(ValueError, match="Q4 requires explicit filed-quarter evidence"): + observation(period="2025-Q4", period_end_date="2025-12-31") +``` + +- [ ] **Step 2: Verify the tests fail** + +Run: `python3 -m pytest tests/test_quarterly_cash_generation.py -q` + +Expected: import failure because the module does not exist. + +- [ ] **Step 3: Implement immutable types and validation** + +Implement these exact public types: + +```python +SUPPORTED_COMPONENT_METRICS = frozenset({"operating_income", "cash_from_operations", "capital_expenditures"}) + +@dataclass(frozen=True) +class QuarterlyBusinessObservation: + ticker: str + fiscal_period: str + period_end_date: str + metric: str + value: float + currency: str + unit_scale: float + accounting_basis: str + duration_basis: str + source: str + source_ref: str + published_at: str + retrieved_at: str + q4_evidence_state: str = "not_q4" + supersedes_source_ref: str | None = None + +@dataclass(frozen=True) +class QuarterlyBusinessMetricPoint: + metric: str + fiscal_period: str + period_end_date: str + value: float + definition: tuple[object, ...] + source_refs: tuple[str, ...] + +@dataclass(frozen=True) +class QuarterlyBusinessDerivation: + points: tuple[QuarterlyBusinessMetricPoint, ...] + blockers: tuple[str, ...] + revision_count: int + supplied_observation_count: int +``` + +Validate ISO dates with `date.fromisoformat`, fiscal periods with `^\d{4}-Q[1-4]$`, numeric values with `math.isfinite`, timestamps with `parse_utc_timestamp`, and required text after normalization. Permit `not_q4` only for Q1-Q3 and `explicit_filed_quarter` only for Q4. + +- [ ] **Step 4: Verify constructor tests pass** + +Run: `python3 -m pytest tests/test_quarterly_cash_generation.py -q` + +Expected: constructor tests pass. + +- [ ] **Step 5: Write failing derivation tests** + +Test the exact formulas and source lineage: + +```python +def test_derivation_uses_explicit_components(): + result = derive_quarterly_business_metrics( + "SYN1", + [observation(metric="operating_income", value=50.0), + observation(metric="cash_from_operations", value=60.0), + observation(metric="capital_expenditures", value=-20.0)], + [actual("2025-Q1", revenue=200.0)], + ) + points = {point.metric: point for point in result.points} + assert points["operating_margin"].value == 0.25 + assert points["free_cash_flow"].value == 40.0 + assert points["fcf_margin"].value == 0.20 + assert points["free_cash_flow"].source_refs == ( + "fixture:2025-Q1:cash_from_operations", "fixture:2025-Q1:capital_expenditures" + ) +``` + +Also assert cutoff filtering, explicit revision resolution, affected-component ambiguity, and incompatibility across currency, scale, accounting basis, duration basis, fiscal period, and period end. + +- [ ] **Step 6: Implement revision resolution and derivation** + +Group by `(fiscal_period, metric)`, deduplicate source references, accept exact duplicates, and resolve only one explicit revision leaf. Record stable blockers such as `2025-Q1:capital_expenditures:ambiguous_revision`. Require compatible Revenue and a non-zero denominator for margin points. Calculate: + +```python +operating_margin = operating_income.value / revenue.revenue_actual +free_cash_flow = cash_from_operations.value + capital_expenditures.value +fcf_margin = free_cash_flow / revenue.revenue_actual +``` + +- [ ] **Step 7: Verify domain behavior and absence of persistence** + +Run: + +```bash +python3 -m pytest tests/test_quarterly_cash_generation.py -q +rg -n "open\(|write_text|write_bytes|to_csv|json\.dump|csv\.writer|argparse|output_dir" src/quarterly_cash_generation.py +``` + +Expected: tests pass and the persistence scan returns no matches. + +- [ ] **Step 8: Commit Task 1** + +```bash +git add -- src/quarterly_cash_generation.py tests/test_quarterly_cash_generation.py +git commit -m "Add quarterly cash generation evidence contract" +``` + +--- + +### Task 2: Independent Trend Composition + +**Files:** +- Modify: `src/quarterly_business_trend.py:13-345` +- Modify: `tests/test_quarterly_business_trend.py` + +**Interfaces:** +- Consumes Task 1 types and derivation. +- Produces `QuarterlyTrendPacket.operating_margin`, `.free_cash_flow`, `.fcf_margin`, and the `business_observations` builder argument. + +- [ ] **Step 1: Write failing integration tests** + +```python +def test_supplemental_metrics_stay_withheld_without_observations(): + packet = build_quarterly_trend_packet("SYN1", [_actual("2025-Q1", revenue=120, eps=1.2)]) + assert packet.operating_margin.status == "withheld" + assert packet.free_cash_flow.status == "withheld" + assert packet.fcf_margin.status == "withheld" + +def test_supplemental_metric_trends_are_independent(): + packet = build_quarterly_trend_packet( + "SYN1", actual_history(), business_observations=business_history() + ) + assert packet.operating_margin.status == "ready" + assert packet.free_cash_flow.status == "ready" + assert packet.fcf_margin.status == "ready" + assert packet.revenue.latest_value == 120 + assert packet.eps.latest_value == 1.2 +``` + +- [ ] **Step 2: Verify integration tests fail** + +Run: `python3 -m pytest tests/test_quarterly_business_trend.py -q` + +Expected: missing fields and keyword argument. + +- [ ] **Step 3: Extend the packet and builder** + +Add three `QuarterlyMetricTrend` fields after `eps`, plus: + +```python +def _withheld_metric(metric: str, reason: str) -> QuarterlyMetricTrend: + return QuarterlyMetricTrend(metric, "withheld", None, "", "", None, None, (), (), reason) + +def build_quarterly_trend_packet( + ticker: str, + actuals: Iterable[QuarterlyActual], + *, + as_of: str | None = None, + business_observations: Iterable[QuarterlyBusinessObservation] = (), +) -> QuarterlyTrendPacket: +``` + +Materialize the optional iterable once. Zero observations yield three withheld trends. Otherwise derive points from the resolved Revenue rows and reuse exact previous-quarter, prior-year-quarter, definition-compatibility, and percent-change behavior. Keep packet-level status based on Revenue/EPS exactly as before. + +- [ ] **Step 4: Render all five rows from packet state** + +Replace hard-coded supplemental placeholders with one loop over Revenue, EPS, operating margin, free cash flow, and FCF margin. Format margin display values as percentages while retaining decimal values in the domain packet. Preserve component references joined with `;` only in the Advanced row. + +- [ ] **Step 5: Run focused and Nowcast regressions** + +```bash +python3 -m pytest tests/test_quarterly_cash_generation.py tests/test_quarterly_business_trend.py -q +python3 -m pytest tests/test_earnings_nowcast_contract.py tests/test_earnings_nowcast_readiness.py tests/test_earnings_nowcast_report.py -q +``` + +Expected: all pass with no schema or input-hash change. + +- [ ] **Step 6: Commit Task 2** + +```bash +git add -- src/quarterly_business_trend.py tests/test_quarterly_business_trend.py +git commit -m "Compose independent quarterly cash generation trends" +``` + +--- + +### Task 3: Answer-First Workbench Rendering + +**Files:** +- Modify: `src/research_workspace.py:238-266` +- Modify: `tests/test_research_workspace.py:91-115` +- Modify: `tests/test_dashboard_helpers.py` + +**Interfaces:** +- Consumes Task 2 packet fields. +- Produces truthful supported or withheld cards without a loader, writer, or source promotion. + +- [ ] **Step 1: Write failing card tests** + +Assert `OPERATING MARGIN`, `FREE CASH FLOW`, and `FCF MARGIN` cards are `Withheld` without observations and say a reviewed quarterly source adapter is required. With ready in-memory observations, assert values and comparison changes appear, but raw source references and formula internals do not appear in primary card text. + +- [ ] **Step 2: Verify card tests fail** + +Run: `python3 -m pytest tests/test_research_workspace.py -q` + +Expected: supplemental cards are missing. + +- [ ] **Step 3: Extend the existing card loop** + +Use five entries with `number` display for Revenue/EPS/FCF and `percent` for operating margin/FCF margin. Card bodies contain comparison changes or a concise boundary only. Source references, component values, formula details, and commands remain outside primary cards. + +- [ ] **Step 4: Add dashboard boundary assertions** + +Assert the dashboard still loads no supplemental file and keeps the table inside the existing collapsed quarterly source-evidence expander. Assert no new writer, template, output path, or Make target exists for this feature. + +- [ ] **Step 5: Verify workspace and Research routes** + +```bash +python3 -m pytest tests/test_research_workspace.py tests/test_dashboard_helpers.py -q +make research-dashboard-render-smoke +``` + +Expected: tests and all four Research routes pass; production supplemental output remains withheld. + +- [ ] **Step 6: Commit Task 3** + +```bash +git add -- src/research_workspace.py tests/test_research_workspace.py tests/test_dashboard_helpers.py +git commit -m "Render cash generation evidence without file output" +``` + +--- + +### Task 4: Methodology, Maturity Boundary, And Release Verification + +**Files:** +- Modify: `docs/METHODOLOGY.md:41-49` +- Modify: `docs/PROVENANCE_CONTRACT.md:38-48` +- Modify: `docs/PERSONAL_RESEARCH_MODE.md:55-62` +- Modify: `ROADMAP.md` +- Modify: `docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md` +- Modify: `tests/test_public_v1_release_docs.py` + +**Interfaces:** +- Consumes verified Tasks 1-3. +- Produces durable methodology and maturity claims. + +- [ ] **Step 1: Write failing documentation tests** + +```python +def test_quarterly_cash_generation_docs_preserve_no_file_boundary(): + methodology = _read("docs/METHODOLOGY.md") + provenance = _read("docs/PROVENANCE_CONTRACT.md") + roadmap = _read("ROADMAP.md") + assert "cash from operations + reported capital expenditures" in methodology + assert "explicit_filed_quarter" in provenance + assert "no new data file, writer, template, or generated artifact" in provenance + assert "methodology maturity" in roadmap + assert "does not prove real-company coverage or market validation" in roadmap +``` + +- [ ] **Step 2: Verify documentation tests fail** + +Run: `python3 -m pytest tests/test_public_v1_release_docs.py -q` + +Expected: new claims are absent. + +- [ ] **Step 3: Update methodology, provenance, mode guide, roadmap, and continuation prompt** + +Document formulas, source timestamps, revision behavior, compatibility, Q4 evidence, independent readiness, no persistence surface, unchanged downstream readiness, and withheld production state. Record that this improves methodology completeness, cash-conversion transparency, adapter extensibility, and reviewer trust—but does not prove real-company coverage, licensed source operation, hosted reliability, external reviewer success, calibration, commercial demand, or product-market fit. + +- [ ] **Step 4: Run focused tests** + +```bash +python3 -m pytest tests/test_quarterly_cash_generation.py tests/test_quarterly_business_trend.py tests/test_research_workspace.py tests/test_public_v1_release_docs.py -q +``` + +Expected: all pass. + +- [ ] **Step 5: Run the complete verification bundle** + +```bash +python3 -m pytest tests -q +make dashboard-smoke +make research-dashboard-render-smoke +make public-wording-check +make public-check +make pilot-readiness-check TOP_N=10 +make diff-hygiene-summary +git diff --check +``` + +Expected: every gate passes; before commit only intentional code/docs/tests appear, with zero generated artifact candidates. + +- [ ] **Step 6: Stage exact implementation paths and verify** + +```bash +git add -- src/quarterly_cash_generation.py src/quarterly_business_trend.py src/research_workspace.py tests/test_quarterly_cash_generation.py tests/test_quarterly_business_trend.py tests/test_research_workspace.py tests/test_dashboard_helpers.py tests/test_public_v1_release_docs.py docs/METHODOLOGY.md docs/PROVENANCE_CONTRACT.md docs/PERSONAL_RESEARCH_MODE.md docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md ROADMAP.md +make staged-hygiene-check +git diff --cached --check +``` + +Expected: product/code/docs/test paths only; zero generated, canonical-data, report, sample-report, or manual-review paths. + +- [ ] **Step 7: Commit, push, and update PR #113** + +```bash +git commit -m "Add quarterly cash generation evidence contract" +git push origin codex/personal-research-mode-mvp +``` + +Update PR #113 with independent readiness, no-file boundary, results, maturity assessment, and the external reviewed-adapter dependency. Keep it draft. + +- [ ] **Step 8: Verify final truth** + +```bash +git status --short --branch +git rev-list --left-right --count HEAD...origin/codex/personal-research-mode-mvp +gh pr view 113 --json state,isDraft,mergeStateStatus,headRefOid,url +make pilot-readiness-check TOP_N=10 +make diff-hygiene-summary +``` + +Expected: clean tree, `0 0`, PR open and draft at HEAD, pilot-ready with manual gates, clean hygiene. + +## Plan Self-Review + +- Every spec field, formula, cutoff, revision, Q4, readiness, rendering, no-file, documentation, and verification requirement maps to Tasks 1-4. +- Scope is one domain contract and existing-workflow integration; no adapter, extraction, persistence, new route, scheduler, or external activation. +- Task 1 type names and Task 2-3 consumers are consistent. +- The plan creates Python code/tests and edits docs/tests only; it introduces no generated artifact path. +- Maturity claims distinguish methodology and trust gains from market validation, hosting, licensed operation, calibration, and product-market fit. diff --git a/docs/superpowers/plans/2026-07-18-readiness-change-cause-review.md b/docs/superpowers/plans/2026-07-18-readiness-change-cause-review.md new file mode 100644 index 00000000..1ca66995 --- /dev/null +++ b/docs/superpowers/plans/2026-07-18-readiness-change-cause-review.md @@ -0,0 +1,34 @@ +# Readiness Change Cause Review Implementation Plan + +**Goal:** Make every major saved-versus-proposed readiness transition semantically inspectable without writing artifacts or changing readiness decisions. + +**Architecture:** Add a behavior-equivalent named-reason interface to `src.company_analysis_scope`, then add a pure change-transition review to `src.readiness_preview`. The existing preview builder supplies proposed metadata plus canonical fundamentals to the review after its single no-write readiness build. + +## Task 1: Named company-scope reasons + +- Add failing tests for each existing exclusion family, overlapping reasons, nonpositive-revenue inputs, and unchanged boolean results. +- Implement ordered named patterns and `company_dcf_exclusion_reasons`. +- Keep `COMPANY_DCF_EXCLUDED_TEXT_PATTERNS` and boolean helpers backward compatible. +- Run focused company-scope and readiness-engine tests. + +## Task 2: Pure readiness transition review + +- Add failing tests for added/removed rows, ready/partial/excluded set transitions, primary DCF reason counts, and unexplained exclusions. +- Add frozen change-review structures and pure set parsing. +- Integrate the review using proposed ticker metadata and canonical fundamentals without a second readiness build. +- Render counts under `Readiness Change Cause Review` with an explicit non-mutually-exclusive transition boundary. +- Prove the real command preserves a complete before/after byte fingerprint. + +## Task 3: Documentation and verification + +- Update ROADMAP, methodology, provenance, data strategy, continuation prompt, Make help, and contract tests. +- Run focused tests, full tests, dashboard and Personal Research render smoke, public wording/check, commercial beta/release, pilot readiness, diff hygiene, and whitespace checks. +- Stage exact code/test/docs/Make files only; run staged hygiene; commit and push only the feature branch. +- Update draft PR #113 with verified counts and keep it draft. + +## Stop Rules + +- Do not run `make readiness`. +- Do not change the exclusion decision or infer a reason from absent evidence. +- Do not write or stage CSV, JSON, reports, screenshots, timings, caches, or sample reports. +- Do not describe an exclusion as a negative research conclusion. diff --git a/docs/superpowers/plans/2026-07-18-readiness-impact-preview.md b/docs/superpowers/plans/2026-07-18-readiness-impact-preview.md new file mode 100644 index 00000000..0eafa920 --- /dev/null +++ b/docs/superpowers/plans/2026-07-18-readiness-impact-preview.md @@ -0,0 +1,367 @@ +# Readiness Impact Preview 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:** Add a deterministic stdout-only preview of saved-versus-proposed readiness changes without creating or modifying any file. + +**Architecture:** Existing universe and readiness builders gain explicit no-write flags whose defaults preserve current write behavior. A focused preview module loads the saved readiness CSV, builds the proposed frames in memory, compares only stable readiness fields, and renders capped human-readable output. The pilot gate routes stale operators to the preview while keeping the intentional readiness rebuild as a separate approval boundary. + +**Tech Stack:** Python 3, pandas, argparse, pytest, GNU Make, Markdown. + +## Global Constraints + +- Do not run `make readiness` during this slice. +- Do not generate or stage CSV, JSON, report, sample-report, screenshot, or timing churn. +- Default write-mode behavior must remain unchanged for existing callers. +- The preview must not create directories, expose an output-path option, or offer JSON/file output. +- Stable comparison fields are `overall_readiness_state`, `price_ready`, `momentum_ready`, `fundamentals_ready`, `dcf_ready`, `peer_ready`, `earnings_ready`, `analyst_estimates_ready`, `ready_features`, `partial_features`, `blocked_features`, and `excluded_features`. +- Missing saved readiness fails closed; no stable changes do not make stale readiness current. +- Preserve research-only, source-rights, candidate-context, synthetic-fixture, Q4, EPS split-basis, and independent readiness boundaries. + +--- + +### Task 1: No-write universe preparation + +**Files:** +- Modify: `src/universe_model.py:170-217,299-339` +- Test: `tests/test_universe_model.py` + +**Interfaces:** +- Consumes: existing `_read_csv`, `_write_csv`, `_legacy_universe_to_master`, and `_legacy_universe_to_active` helpers. +- Produces: `ensure_universe_files(base_dir=None, *, data_dir=None, write_outputs: bool = True) -> tuple[pd.DataFrame, pd.DataFrame]` and `build_universe_coverage_report(base_dir=None, *, data_dir=None, output_path=None, write_output: bool = True) -> pd.DataFrame`. + +- [ ] **Step 1: Write failing no-write universe tests** + +Add tests that create only `data/universe.csv`, capture a manifest of all relative file paths and bytes, call both no-write helpers, and assert the frames contain the legacy ticker while the manifest is identical: + +```python +from src.universe_model import build_universe_coverage_report + + +def _file_manifest(root: Path) -> dict[str, bytes]: + return {path.relative_to(root).as_posix(): path.read_bytes() for path in root.rglob("*") if path.is_file()} + + +def test_ensure_universe_files_no_write_builds_frames_without_creating_canonical_files(tmp_path: Path): + data_dir = tmp_path / "data" + data_dir.mkdir() + pd.DataFrame([{"Ticker": "NVDA", "CompanyName": "NVIDIA", "DefaultPurpose": "Core Compounder"}]).to_csv( + data_dir / "universe.csv", index=False + ) + before = _file_manifest(tmp_path) + + master, active = ensure_universe_files(tmp_path, write_outputs=False) + + assert set(master["ticker"]) == {"NVDA"} + assert set(active["ticker"]) == {"NVDA"} + assert _file_manifest(tmp_path) == before + + +def test_universe_coverage_no_write_creates_no_report_or_directory(tmp_path: Path): + data_dir = tmp_path / "data" + data_dir.mkdir() + pd.DataFrame([{"Ticker": "NVDA", "CompanyName": "NVIDIA", "DefaultPurpose": "Core Compounder"}]).to_csv( + data_dir / "universe.csv", index=False + ) + before = _file_manifest(tmp_path) + + report = build_universe_coverage_report(tmp_path, write_output=False) + + assert set(report["ticker"]) == {"NVDA"} + assert _file_manifest(tmp_path) == before +``` + +- [ ] **Step 2: Run the tests and confirm the new keyword arguments fail** + +Run: `python3 -m pytest tests/test_universe_model.py -q` + +Expected: FAIL because `write_outputs` and `write_output` are not accepted. + +- [ ] **Step 3: Add guarded universe writes** + +Change the signatures and guard all `_write_csv` and coverage output operations: + +```python +def ensure_universe_files( + base_dir: Path | str | None = None, + *, + data_dir: Path | str | None = None, + write_outputs: bool = True, +) -> tuple[pd.DataFrame, pd.DataFrame]: +``` + +For each existing universe write use `if write_outputs: _write_csv(...)`. Add `write_output: bool = True` to the coverage signature, call `ensure_universe_files(..., write_outputs=write_output)`, and wrap `output.parent.mkdir(...)` plus `report.to_csv(...)` in `if write_output:`. Always return the in-memory coverage frame. + +- [ ] **Step 4: Verify focused universe behavior** + +Run: `python3 -m pytest tests/test_universe_model.py -q` + +Expected: PASS, including existing write-mode tests. + +- [ ] **Step 5: Commit the universe boundary** + +Run: + +```bash +git add -- src/universe_model.py tests/test_universe_model.py +make staged-hygiene-check +git diff --cached --check +git commit -m "Add no-write universe preparation" +``` + +### Task 2: No-write readiness engine + +**Files:** +- Modify: `src/readiness_engine.py:988-1174` +- Test: `tests/test_readiness_engine.py` + +**Interfaces:** +- Consumes: Task 1 no-write universe interfaces. +- Produces: `build_ticker_readiness_report(base_dir=None, *, data_dir=None, output_dir=None, write_outputs: bool = True) -> dict[str, pd.DataFrame]`. + +- [ ] **Step 1: Write a failing filesystem-preservation test** + +Reuse the existing readiness fixture setup, call the builder with `write_outputs=False`, and assert all expected in-memory report keys exist while the complete file manifest is byte-for-byte unchanged and no output directory appears: + +```python +def test_ticker_readiness_no_write_returns_reports_without_mutating_files(tmp_path: Path, monkeypatch): + # Arrange the same minimal canonical inputs used by the main readiness-state test. + data_dir = tmp_path / "data" + _write_minimal_readiness_inputs(data_dir) + before = _file_manifest(tmp_path) + + reports = build_ticker_readiness_report( + tmp_path, + data_dir=data_dir, + output_dir=tmp_path / "outputs", + write_outputs=False, + ) + + assert "ticker_readiness_report" in reports + assert "data_source_status" in reports + assert not reports["ticker_readiness_report"].empty + assert _file_manifest(tmp_path) == before + assert not (tmp_path / "outputs").exists() +``` + +Factor the existing minimal input rows into `_write_minimal_readiness_inputs(data_dir: Path) -> None`; do not reduce existing write-mode assertions. + +- [ ] **Step 2: Run the focused test and confirm failure** + +Run: `python3 -m pytest tests/test_readiness_engine.py -q` + +Expected: FAIL because `write_outputs` is not accepted. + +- [ ] **Step 3: Guard every readiness write** + +Add `write_outputs: bool = True` to the builder. Call `ensure_universe_files(..., write_outputs=write_outputs)` and `build_universe_coverage_report(..., write_output=write_outputs)`. Use the returned universe frames instead of re-reading files that no-write preparation may intentionally not create. Wrap the report loop, compatibility copies, output-directory creation, and output copies in one `if write_outputs:` block. Return the same report dictionary in both modes. + +- [ ] **Step 4: Verify no-write and regression behavior** + +Run: `python3 -m pytest tests/test_readiness_engine.py tests/test_universe_model.py -q` + +Expected: PASS; existing write-mode files are still created and no-write mode leaves the manifest unchanged. + +- [ ] **Step 5: Commit the readiness boundary** + +Run: + +```bash +git add -- src/readiness_engine.py tests/test_readiness_engine.py +make staged-hygiene-check +git diff --cached --check +git commit -m "Add no-write readiness build mode" +``` + +### Task 3: Stable readiness impact comparison and CLI + +**Files:** +- Create: `src/readiness_preview.py` +- Create: `tests/test_readiness_preview.py` + +**Interfaces:** +- Consumes: `build_ticker_readiness_report(..., write_outputs=False)` from Task 2. +- Produces: `STABLE_READINESS_FIELDS: tuple[str, ...]`, `build_readiness_impact_preview(root: Path, *, data_dir: Path | None = None, top_n: int = 20) -> ReadinessImpactPreview`, `render_readiness_impact_preview(preview: ReadinessImpactPreview) -> str`, and CLI `main() -> int`. + +- [ ] **Step 1: Write failing pure comparison tests** + +Define two small readiness frames and require timestamp-only differences to produce `no_readiness_changes`, stable state changes to produce `changes_detected`, and details to cap at `top_n` while retaining the total changed count. Include added and removed ticker rows with changed field `row_presence`. + +```python +def test_compare_ignores_updated_at_but_reports_stable_fields(): + saved = pd.DataFrame([_row("AAA", price_ready=False, updated_at="2026-01-01")]) + timestamp_only = pd.DataFrame([_row("AAA", price_ready=False, updated_at="2026-07-18")]) + changed = pd.DataFrame([_row("AAA", price_ready=True, updated_at="2026-07-18")]) + + assert compare_readiness_frames(saved, timestamp_only, top_n=20).status == "no_readiness_changes" + preview = compare_readiness_frames(saved, changed, top_n=20) + assert preview.status == "changes_detected" + assert preview.changed_ticker_count == 1 + assert preview.changed_tickers[0].fields == ("price_ready",) +``` + +- [ ] **Step 2: Run the new test module and confirm import failure** + +Run: `python3 -m pytest tests/test_readiness_preview.py -q` + +Expected: FAIL because `src.readiness_preview` does not exist. + +- [ ] **Step 3: Implement immutable comparison structures and normalization** + +Create frozen dataclasses for `ReadinessTickerChange` and `ReadinessImpactPreview`. Normalize tickers to uppercase, booleans to strict booleans, missing feature strings to `""`, and compare the union of ticker keys in sorted order. Summarize overall-state counts and true counts for the seven named readiness flags. Do not inspect `updated_at`, source attempt times, or explanatory command text. + +- [ ] **Step 4: Write failing integration tests for missing snapshots, output wording, and no writes** + +Monkeypatch `build_ticker_readiness_report` to return an in-memory proposed frame. Assert a missing `data/reports/ticker_readiness_report.csv` returns `missing_saved_snapshot`, renders a non-unlock explanation, and does not create the path. With a saved fixture, capture a byte manifest before calling `build_readiness_impact_preview` and assert it is unchanged. Require rendered output to include: + +```text +Read-only: no files were created, modified, or deleted. +This preview does not make saved readiness current. +An intentional reviewed make readiness run remains the separate rebuild boundary. +``` + +- [ ] **Step 5: Implement the preview loader, renderer, and CLI** + +The CLI accepts only `--project-root`, `--data-dir`, and `--top-n`; validates `top_n >= 1`; calls the no-write builder; prints the human-readable result; returns `0` for `changes_detected` or `no_readiness_changes`, `2` for `missing_saved_snapshot`, and `1` for a concise in-memory build error. It must not accept `--output` or `--json`. + +- [ ] **Step 6: Verify preview unit and integration coverage** + +Run: `python3 -m pytest tests/test_readiness_preview.py tests/test_readiness_engine.py tests/test_universe_model.py -q` + +Expected: PASS. + +- [ ] **Step 7: Commit the preview module** + +Run: + +```bash +git add -- src/readiness_preview.py tests/test_readiness_preview.py +make staged-hygiene-check +git diff --cached --check +git commit -m "Add no-write readiness impact preview" +``` + +### Task 4: Command, pilot routing, and user contract + +**Files:** +- Modify: `Makefile:1,315-317,1287-1291` +- Modify: `src/pilot_readiness.py:304-315` +- Modify: `tests/test_pilot_readiness.py:450-480` +- Modify: `tests/test_public_v1_release_docs.py` + +**Interfaces:** +- Consumes: `python3 -m src.readiness_preview --top-n N`. +- Produces: `make readiness-preview TOP_N=20` and stale pilot routing to that command without changing the blocked verdict. + +- [ ] **Step 1: Write failing command-contract assertions** + +Add tests requiring `readiness-preview` in `.PHONY`, help text describing it as stdout-only/no-write, and the recipe: + +```make +readiness-preview: + @python3 -m src.readiness_preview --top-n $(or $(TOP_N),20) +``` + +Change the stale freshness pilot expectation to `make readiness-preview TOP_N=20`, while asserting status remains `blocked` and the stop rule still prevents quoting final counts or proof deltas. + +- [ ] **Step 2: Run focused contract tests and confirm failure** + +Run: `python3 -m pytest tests/test_pilot_readiness.py tests/test_public_v1_release_docs.py -q` + +Expected: FAIL because the target and stale route do not exist. + +- [ ] **Step 3: Add the Make target and safe pilot route** + +Add the target to `.PHONY`, help, and the readiness target block. In `_freshness_check`, use `make readiness-preview TOP_N=20` when stale or missing. Extend the stop rule to say the preview is inspection evidence only and the reviewed write rebuild remains separately gated. + +- [ ] **Step 4: Prove the real command changes no project files** + +Capture `git status --porcelain=v1`, a SHA-256/size/mtime manifest for all tracked and untracked files, run `make readiness-preview TOP_N=20`, and compare the manifests. Expected: command prints saved/proposed counts and the explicit no-write/non-unlock boundary; both manifests remain identical. + +- [ ] **Step 5: Run command and pilot focused tests** + +Run: + +```bash +python3 -m pytest tests/test_readiness_preview.py tests/test_readiness_engine.py tests/test_universe_model.py tests/test_pilot_readiness.py tests/test_public_v1_release_docs.py -q +make pilot-readiness-check TOP_N=10 +``` + +Expected: tests PASS; pilot remains blocked because saved readiness is stale and names the preview as the next inspection command. + +- [ ] **Step 6: Commit the command surface** + +Run: + +```bash +git add -- Makefile src/pilot_readiness.py tests/test_pilot_readiness.py tests/test_public_v1_release_docs.py +make staged-hygiene-check +git diff --cached --check +git commit -m "Route stale readiness to no-write preview" +``` + +### Task 5: Documentation, complete verification, and draft PR + +**Files:** +- Modify: `ROADMAP.md` +- Modify: `docs/DATA_STRATEGY.md` +- Modify: `docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md` +- Modify: draft PR #113 body/comment only after local verification. + +**Interfaces:** +- Consumes: verified no-write preview and unchanged blocked freshness gate. +- Produces: durable operator guidance and PR evidence for the coherent slice. + +- [ ] **Step 1: Update the durable operating contract** + +Document that `make readiness-preview TOP_N=20` computes proposed stable readiness in memory, writes nothing, cannot make the saved snapshot current, and does not authorize `make readiness`. ROADMAP must distinguish implemented preview inspection from the still-open intentional rebuild/review gate. The continuation prompt must preserve the current no-generated-artifact boundary and name the next executable lanes after preview. + +- [ ] **Step 2: Run focused documentation and feature checks** + +Run: + +```bash +python3 -m pytest tests/test_readiness_preview.py tests/test_readiness_engine.py tests/test_universe_model.py tests/test_pilot_readiness.py tests/test_public_v1_release_docs.py -q +git diff --check +make diff-hygiene-summary +``` + +Expected: PASS; zero generated CSV/JSON/report/sample-report/screenshot/timing candidates. + +- [ ] **Step 3: Run the full approved verification bundle** + +Run: + +```bash +python3 -m pytest tests -q +make dashboard-smoke +make research-dashboard-render-smoke +make public-wording-check +make public-check +make commercial-beta-check +make commercial-beta-release-check +make pilot-readiness-check TOP_N=10 +make diff-hygiene-summary +git diff --check +``` + +Expected: all executable local gates PASS; pilot remains truthfully blocked only by stale saved readiness and other already-classified external/manual gates. Do not run `make readiness`. + +- [ ] **Step 4: Stage exact documentation and verify hygiene** + +Run: + +```bash +git add -- ROADMAP.md docs/DATA_STRATEGY.md docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md +make staged-hygiene-check +git diff --cached --check +git commit -m "Document readiness impact preview" +``` + +- [ ] **Step 5: Push only the approved branch and update draft PR #113** + +Run `git push origin codex/personal-research-mode-mvp`, verify zero divergence, then update PR #113 with the slice summary, exact test counts/gates, explicit no-artifact evidence, and remaining stale-readiness boundary. Confirm the PR remains open and draft; do not merge. + +- [ ] **Step 6: Continue the active goal** + +Re-read ROADMAP and the continuation prompt from current HEAD. Select the next highest-value safe local/source/hosted-preview/beta/evidence/calibration/operating slice. If no safe local task remains, classify the exact external unblock conditions once and leave the overall goal active rather than claiming completion. diff --git a/docs/superpowers/plans/2026-07-18-readiness-promotion-evidence-review.md b/docs/superpowers/plans/2026-07-18-readiness-promotion-evidence-review.md new file mode 100644 index 00000000..032c9248 --- /dev/null +++ b/docs/superpowers/plans/2026-07-18-readiness-promotion-evidence-review.md @@ -0,0 +1,77 @@ +# Readiness Promotion Evidence Review Implementation Plan + +**Goal:** Extend the existing stdout-only readiness preview with a fail-closed explanation of proposed fundamentals and DCF promotions by exact source, provenance, commercial-rights status, and registered field scope. + +**Architecture:** Add pure promotion-review structures and comparison helpers to `src.readiness_preview`. The integrated builder reuses the already computed in-memory readiness frames, reads canonical fundamentals and the checked-in immutable source-rights registry, and attaches an independent evidence review to the technical impact preview. The existing Make command, no-write behavior, and rebuild boundary remain unchanged. + +**Constraints:** Do not run `make readiness`; do not add an output path or file format; do not write or stage CSV, JSON, reports, screenshots, timings, caches, or readiness artifacts; do not split composite source identifiers or infer rights/field support; preserve technical readiness independently from commercial/evidence review. + +## Task 1: Pure promotion-evidence contract + +**Files:** + +- Modify `tests/test_readiness_preview.py` +- Modify `src/readiness_preview.py` + +1. Add failing tests for false-to-true fundamentals/DCF selection, exact source-rights decisions, required field support, provenance completeness, duplicate ticker failure, and `TOP_N` capping. +2. Run `python3 -m pytest tests/test_readiness_preview.py -q` and confirm the new contract fails before implementation. +3. Add frozen `ReadinessPromotionEvidence` and `ReadinessPromotionReview` structures. +4. Implement a pure `review_readiness_promotions(...)` helper that: + - indexes saved/proposed readiness deterministically; + - identifies unique fundamentals/DCF promotions only; + - fails closed on duplicate/missing canonical fundamentals evidence; + - evaluates the complete source value with `commercial_eligibility`; + - checks source, as-of date, SEC accession/source reference, and registered required-field coverage; + - returns counts plus capped rows without mutating inputs. +5. Run the focused tests and keep the stable readiness comparison behavior unchanged. + +## Task 2: Integrated no-write preview and operator wording + +**Files:** + +- Modify `tests/test_readiness_preview.py` +- Modify `src/readiness_preview.py` +- Modify `tests/test_public_v1_release_docs.py` +- Modify `Makefile` help text only if the existing description no longer explains the expanded review. + +1. Add failing integration assertions that the preview builder passes `write_outputs=False`, loads the checked-in registry, attaches the review, and preserves the complete filesystem manifest. +2. Require output to distinguish technical promotions from provenance, rights, field-scope, DCF price-source, and rebuild decisions. +3. Integrate the review after the one in-memory production readiness build; do not invoke a second build. +4. Render total and capped evidence summaries under `Promotion Evidence Review`. +5. Prove `make readiness-preview TOP_N=20` leaves a before/after file fingerprint unchanged. +6. Run `python3 -m pytest tests/test_readiness_preview.py tests/test_public_v1_release_docs.py -q`. + +## Task 3: Durable methodology and continuation contract + +**Files:** + +- Modify `ROADMAP.md` +- Modify `docs/METHODOLOGY.md` +- Modify `docs/PROVENANCE_CONTRACT.md` +- Modify `docs/DATA_STRATEGY.md` +- Modify `docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md` +- Modify documentation contract tests as required + +1. Record that proposed technical promotions do not establish provenance, field support, commercial rights, complete DCF price provenance, or rebuild approval. +2. Record the exact-source fail-closed rule and the current review result without turning in-memory counts into current product claims. +3. Keep the continuation prompt's next step executable and no-write. +4. Run focused documentation and preview tests, `git diff --check`, and `make diff-hygiene-summary`. + +## Task 4: Full verification, commit, push, and PR evidence + +1. Run: + - `python3 -m pytest tests -q` + - `make dashboard-smoke` + - the repository's Personal Research render smoke target + - `make public-wording-check` + - `make public-check` + - `make commercial-beta-check` + - `make commercial-beta-release-check` + - `make pilot-readiness-check TOP_N=10` + - `make diff-hygiene-summary` + - `git diff --check` +2. Confirm the pilot remains blocked by stale saved readiness and no generated artifact is tracked or staged. +3. Stage exact code/test/docs/Make files only and run `make staged-hygiene-check`. +4. Commit the coherent implementation and documentation slice. +5. Push only `codex/personal-research-mode-mvp`. +6. Update draft PR #113 with verified counts, checks, boundaries, external dependency classification, and the next executable step. Keep it draft; do not merge or deploy. diff --git a/docs/superpowers/plans/2026-07-18-research-desk-answer-first.md b/docs/superpowers/plans/2026-07-18-research-desk-answer-first.md new file mode 100644 index 00000000..cee9b9c8 --- /dev/null +++ b/docs/superpowers/plans/2026-07-18-research-desk-answer-first.md @@ -0,0 +1,142 @@ +# Research Desk Answer-First 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:** Put Research Desk's weekly summary, four direct answers, and Discover action before cohort technical context while preserving all cohort evidence under Advanced. + +**Architecture:** Move existing focused-cohort and cohort-coverage cards into the top of the existing collapsed Advanced Evidence drawer. Keep all helper outputs and full evidence frames unchanged, and make the browser performance gate measure the weekly summary as the first useful answer. + +**Tech Stack:** Python 3.12, Streamlit, pytest, source-level dashboard contract tests, Markdown documentation. + +## Global Constraints + +- Research-only; no recommendation, expected-return ranking, broker integration, order routing, auto-trading, or direct buy/sell instruction. +- Preserve independent actuals, consensus, Revenue, EPS, valuation, catalyst, outcome, backtesting, and calibration readiness states. +- Do not refresh, import, apply, or fabricate source data. +- Keep candidate context unable to modify deterministic scenarios or become trusted evidence. +- Keep generated CSV, JSON, report, sample-report, screenshot, and timing churn unstaged. +- Keep PR #113 draft; do not merge or deploy. + +--- + +### Task 1: Make Research Desk answer-first + +**Files:** +- Modify: `tests/test_research_mode_dashboard_contract.py` +- Modify: `tests/test_public_performance_gate.py` +- Modify: `src/dashboard.py` +- Modify: `src/public_performance_gate.py` +- Modify: `docs/PERSONAL_RESEARCH_MODE.md` +- Modify: `docs/DASHBOARD_QA.md` +- Modify: `ROADMAP.md` + +**Interfaces:** +- Consumes: existing `weekly_summary_cards`, `research_desk_cards`, `research_desk_cards_html`, `focused_cohort_cards`, `focused_cohort_coverage_cards`, and `RESEARCH_ROUTE_SPECS` contracts. +- Produces: Research Desk order `header -> weekly summary -> direct answers -> Discover action -> collapsed cohort evidence`, with unchanged data and readiness semantics. + +- [ ] **Step 1: Write the failing route-order contract test** + +```python +def test_research_desk_renders_answers_before_advanced_cohort_context(): + source = dashboard.Path(dashboard.__file__).read_text(encoding="utf-8") + desk_start = source.index("def render_research_desk(") + desk_end = source.index("def render_research_monitor(", desk_start) + desk = source[desk_start:desk_end] + + weekly = desk.index('st.markdown("### Weekly research summary")') + weekly_cards = desk.index("weekly_summary_cards(weekly_summary)", weekly) + answers = desk.index("cards = research_desk_cards(", weekly_cards) + answers_html = desk.index("research_desk_cards_html(cards)", answers) + discover = desk.index('st.link_button("Open Discover"', answers_html) + advanced = desk.index('with st.expander("Advanced Evidence", expanded=False):', discover) + cohort = desk.index("focused_cohort_cards(cohort)", advanced) + coverage = desk.index("focused_cohort_coverage_cards(coverage)", cohort) + cohort_frame = desk.index("focused_cohort_frame(cohort)", coverage) + coverage_frame = desk.index("focused_cohort_coverage_frame(coverage)", cohort_frame) + + assert weekly < weekly_cards < answers < answers_html < discover < advanced + assert advanced < cohort < coverage < cohort_frame < coverage_frame +``` + +- [ ] **Step 2: Run the route test and verify the expected failure** + +Run: `python3 -m pytest tests/test_research_mode_dashboard_contract.py::test_research_desk_renders_answers_before_advanced_cohort_context -q` + +Expected: FAIL because the concise cohort calls occur before the weekly summary and cannot be found after Advanced. + +- [ ] **Step 3: Write the failing performance-marker contract** + +Require Research Desk's first-useful marker to be `Weekly research summary` and its full markers to be `Weekly research summary`, `What should I review next?`, `Open Discover`, `Advanced Evidence`, and `Research-only`. + +Run: `python3 -m pytest tests/test_public_performance_gate.py::test_research_performance_contract_covers_the_commercial_beta_workflow -q` + +Expected: FAIL because the existing route spec still uses `Research Desk` as first useful and `Focused cohort` as a visible full marker. + +- [ ] **Step 4: Implement the minimal route hierarchy** + +Remove the two concise cohort-card calls above `Weekly research summary`. Add them at the top of the existing Advanced Evidence block: + +```python + with st.expander("Advanced Evidence", expanded=False): + render_signal_cards(focused_cohort_cards(cohort), show_commands=False, variant="queue") + render_signal_cards(focused_cohort_coverage_cards(coverage), show_commands=False, variant="queue") + cohort_frame = focused_cohort_frame(cohort) +``` + +Update the Research Desk `PublicRouteSpec` to: + +```python + PublicRouteSpec( + "Research Desk", + "/?mode=research&page=research-desk", + "Weekly research summary", + ( + "Weekly research summary", + "What should I review next?", + "Open Discover", + "Advanced Evidence", + "Research-only", + ), + True, + ), +``` + +- [ ] **Step 5: Run focused tests and verify green** + +Run: `python3 -m pytest tests/test_research_mode_dashboard_contract.py tests/test_research_workspace.py tests/test_dashboard_render_smoke.py tests/test_public_performance_gate.py -q` + +Expected: all focused tests pass with no new warnings or failures. + +- [ ] **Step 6: Update workflow, QA, and roadmap documentation** + +Document the weekly-answer-first order, collapsed cohort context, unchanged evidence boundaries, desktop and phone first-view acceptance, and Monitor as the remaining Stage 1 route audit. + +- [ ] **Step 7: Run the full release verification bundle** + +Run: + +```text +python3 -m pytest tests -q +make dashboard-smoke +make research-dashboard-render-smoke +make commercial-beta-performance-gate +make public-wording-check +make public-check +make pilot-readiness-check TOP_N=10 +make diff-hygiene-summary +git diff --check +``` + +Expected: every command exits zero; generated churn remains excluded. The pilot checklist may retain truthful manual source, hosting, or review gates while the command succeeds. + +- [ ] **Step 8: Stage exact files, verify staged hygiene, commit, and push** + +```text +git add -- src/dashboard.py src/public_performance_gate.py tests/test_research_mode_dashboard_contract.py tests/test_public_performance_gate.py ROADMAP.md docs/PERSONAL_RESEARCH_MODE.md docs/DASHBOARD_QA.md docs/superpowers/specs/2026-07-18-research-desk-answer-first-design.md docs/superpowers/plans/2026-07-18-research-desk-answer-first.md +make staged-hygiene-check +git diff --cached --check +git commit -m "Prioritize Research Desk answers" +git push origin codex/personal-research-mode-mvp +``` + +Expected: exact reviewed files only are committed and pushed; PR #113 remains open and draft. diff --git a/docs/superpowers/plans/2026-07-18-stale-readiness-continuation-gate.md b/docs/superpowers/plans/2026-07-18-stale-readiness-continuation-gate.md new file mode 100644 index 00000000..a1cf9f51 --- /dev/null +++ b/docs/superpowers/plans/2026-07-18-stale-readiness-continuation-gate.md @@ -0,0 +1,353 @@ +# Stale Readiness Continuation Gate 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 every read-only continuation surface agree that stale selected-profile readiness permits no-write inspection only and does not authorize broad source, coverage, apply, or readiness-rebuild work. + +**Architecture:** Add one pure `ContinuationGate` derived from `ProfileContext`, then consume it in project status, provider setup, and coverage-frontier rendering. Existing readiness calculations, provider availability, external dependency classifications, and ranked lane data remain unchanged; only the continuation-safe action layer overrides contradictory execution guidance while readiness is not current. + +**Tech Stack:** Python 3.12, dataclasses, pytest, existing CLI renderers, Make release gates. + +## Global Constraints + +- Research-only; no investment advice, broker integration, order routing, auto-trading, direct buy/sell instructions, or post-earnings price prediction. +- Do not run `make readiness` or write generated CSV, JSON, report, sample-report, screenshot, timing, directory, or bytecode churn. +- `make readiness-preview TOP_N=20` is inspection evidence only and cannot make saved readiness current. +- Preserve independent readiness for actuals, consensus, Revenue, EPS, operating margin, free cash flow, FCF margin, valuation, trusted relationships, peer comparability, peer valuation anchors, catalysts, outcomes, backtesting, and calibration. +- Candidate context cannot modify forecasts or become trusted evidence. +- Stage exact intentional files only; never use `git add -A`. + +--- + +### Task 1: Pure continuation gate + +**Files:** +- Create: `src/continuation_gate.py` +- Create: `tests/test_continuation_gate.py` + +**Interfaces:** +- Consumes: `src.profile_context.ProfileContext` +- Produces: `ContinuationGate(state, next_safe_command, reason, rebuild_command, stop_rule, suppress_execution)` and `build_continuation_gate(context)` + +- [ ] **Step 1: Write the failing tests** + +```python +from pathlib import Path + +from src.continuation_gate import build_continuation_gate +from src.profile_context import CoverageCounts, ProfileContext + + +def _context(state: str) -> ProfileContext: + return ProfileContext( + profile_key="default", + profile_label="Default", + data_dir=Path("data"), + outputs_dir=Path("outputs"), + source_as_of="2026-06-26", + readiness_built_at="2026-06-07T03:00:43+00:00", + snapshot_identity="abc", + snapshot_identity_short="abc", + freshness_state=state, + freshness_message=f"Readiness is {state}.", + refresh_command="make readiness", + coverage=CoverageCounts(), + lane_source_dates=(), + snapshot_inputs=(), + ) + + +def test_stale_readiness_routes_to_no_write_preview(): + gate = build_continuation_gate(_context("stale")) + assert gate.state == "inspection_only" + assert gate.next_safe_command == "make readiness-preview TOP_N=20" + assert gate.rebuild_command == "make readiness" + assert gate.suppress_execution is True + + +def test_current_readiness_does_not_override_source_routing(): + gate = build_continuation_gate(_context("current")) + assert gate.state == "current" + assert gate.next_safe_command == "" + assert gate.suppress_execution is False +``` + +- [ ] **Step 2: Run tests to verify the module is missing** + +Run: `python3 -m pytest tests/test_continuation_gate.py -q` + +Expected: FAIL with `ModuleNotFoundError: No module named 'src.continuation_gate'`. + +- [ ] **Step 3: Implement the pure gate** + +```python +from dataclasses import dataclass + +from src.profile_context import ProfileContext + + +@dataclass(frozen=True) +class ContinuationGate: + state: str + next_safe_command: str + reason: str + rebuild_command: str + stop_rule: str + suppress_execution: bool + + +def build_continuation_gate(context: ProfileContext) -> ContinuationGate: + if context.freshness_state == "current": + return ContinuationGate("current", "", context.freshness_message, context.refresh_command, "", False) + state = "inspection_only" if context.freshness_state == "stale" else "inspection_required" + return ContinuationGate( + state=state, + next_safe_command="make readiness-preview TOP_N=20", + reason=context.freshness_message, + rebuild_command=context.refresh_command or "make readiness", + stop_rule=( + "Do not start broad refresh, source-proof, apply, or readiness-rebuild work from stale or incomplete readiness counts." + ), + suppress_execution=True, + ) +``` + +- [ ] **Step 4: Run focused tests** + +Run: `python3 -m pytest tests/test_continuation_gate.py tests/test_profile_context.py -q` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add -- src/continuation_gate.py tests/test_continuation_gate.py +git commit -m "Add stale readiness continuation gate" +``` + +### Task 2: Fail-closed project-status routing + +**Files:** +- Modify: `src/project_status.py` +- Modify: `tests/test_project_status.py` + +**Interfaces:** +- Consumes: `ContinuationGate` from Task 1 +- Produces: `continuation_gate` in project-status payloads and a stale-aware human rendering contract + +- [ ] **Step 1: Write the failing project-status test** + +```python +def test_project_status_stale_gate_suppresses_broad_next_steps(tmp_path, monkeypatch, capsys): + _write_fast_status_artifacts(tmp_path) + payload = project_status._fast_status_payload_from_outputs(tmp_path, top_n=5) + gate = ContinuationGate( + state="inspection_only", + next_safe_command="make readiness-preview TOP_N=20", + reason="Selected-profile source dates are newer than saved readiness.", + rebuild_command="make readiness", + stop_rule="Do not start broad refresh, source-proof, apply, or readiness-rebuild work.", + suppress_execution=True, + ) + + project_status._print_human(payload, continuation_gate=gate) + output = capsys.readouterr().out + + assert "Continuation-safe next action: make readiness-preview TOP_N=20" in output + assert "Rebuild boundary: make readiness" in output + assert "make price-refresh-loop DRY_RUN=1" not in output + assert "make trusted-data-pilot-candidates TOP_N=10" not in output +``` + +- [ ] **Step 2: Run the test and verify the signature/behavior fails** + +Run: `python3 -m pytest tests/test_project_status.py::test_project_status_stale_gate_suppresses_broad_next_steps -q` + +Expected: FAIL because `_print_human` does not accept `continuation_gate` and broad recommendations remain visible. + +- [ ] **Step 3: Apply the gate in the CLI and renderer** + +Build the profile context once in `main`, derive the gate, and pass it to `_print_human`. When `suppress_execution` is true: + +```python +print(f"Continuation gate: {continuation_gate.state}") +print(f"- Continuation-safe next action: {continuation_gate.next_safe_command}") +print(f"- Rebuild boundary: {continuation_gate.rebuild_command} requires an intentional reviewed write.") +print(f"- Stop rule: {continuation_gate.stop_rule}") +``` + +Do not render ticker-specific locked-input commands or generic recommended command rows in that state. Render one replacement row for the no-write preview and keep saved/stale coverage counts visible. + +- [ ] **Step 4: Run project-status tests** + +Run: `python3 -m pytest tests/test_project_status.py tests/test_continuation_gate.py -q` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add -- src/project_status.py tests/test_project_status.py +git commit -m "Route stale project status to inspection only" +``` + +### Task 3: Align provider setup and coverage frontier + +**Files:** +- Modify: `src/source_activation_guide.py` +- Modify: `src/readiness_ops.py` +- Modify: `tests/test_source_activation_guide.py` +- Modify: `tests/test_readiness_ops.py` + +**Interfaces:** +- Consumes: `build_continuation_gate(build_profile_context(...))` +- Produces: stale-aware provider `current_gate` and coverage-frontier continuation banner + +- [ ] **Step 1: Write failing provider and frontier tests** + +```python +def test_provider_checklist_stale_profile_overrides_saved_preflight_action(tmp_path, monkeypatch): + monkeypatch.setattr( + source_activation_guide, + "build_profile_context", + lambda **_kwargs: SimpleNamespace( + freshness_state="stale", + freshness_message="Selected-profile source dates are newer than saved readiness.", + refresh_command="make readiness", + ), + ) + current_preflight = { + "source_activation_console_v2": { + "operator_summary": { + "can_run_now": ["sec_fundamentals_share_count"], + "needs_setup": ["fmp", "alpha_vantage", "finnhub"], + "avoid_repeating": [], + "next_step": "make coverage-frontier TOP_N=10", + } + } + } + checklist = build_provider_setup_checklist(current_preflight, root=tmp_path) + assert checklist["current_gate"]["can_run_now"] == "inspection_only" + assert checklist["current_gate"]["next_step"] == "make readiness-preview TOP_N=20" + assert "fmp" in checklist["current_gate"]["needs_setup"] + + +def test_coverage_frontier_stale_banner_marks_rows_planning_only(): + gate = ContinuationGate( + state="inspection_only", + next_safe_command="make readiness-preview TOP_N=20", + reason="Selected-profile source dates are newer than saved readiness.", + rebuild_command="make readiness", + stop_rule="Do not start broad refresh, source-proof, apply, or readiness-rebuild work.", + suppress_execution=True, + ) + rendered = render_coverage_frontier([], continuation_gate=gate) + assert "Continuation gate: inspection_only" in rendered + assert "make readiness-preview TOP_N=20" in rendered + assert "ranked rows below are planning context only" in rendered.lower() + assert "make readiness is a separate intentional reviewed write" in rendered +``` + +- [ ] **Step 2: Run the tests and verify they fail** + +Run: `python3 -m pytest tests/test_source_activation_guide.py tests/test_readiness_ops.py -q` + +Expected: FAIL because neither surface consumes the continuation gate. + +- [ ] **Step 3: Implement provider checklist override** + +In `build_provider_setup_checklist`, build the selected profile context for `root`, derive the gate, and override only action fields when fail-closed: + +```python +current_gate.update( + { + "can_run_now": gate.state, + "avoid_repeating": "broad_refresh, source_proof, readiness_rebuild", + "next_step": gate.next_safe_command, + "next_step_reason": gate.reason, + } +) +``` + +Preserve `needs_setup`, provider rows, key-state classifications, and source-rights boundaries. + +- [ ] **Step 4: Implement coverage-frontier banner** + +Add an optional `continuation_gate` parameter to `render_coverage_frontier`. When fail-closed, render the gate before ranked lanes and state that lane commands are planning context only. In the CLI, build the selected profile context and pass the gate; keep pure frontier ranking unchanged. + +- [ ] **Step 5: Run focused status tests** + +Run: `python3 -m pytest tests/test_continuation_gate.py tests/test_project_status.py tests/test_source_activation_guide.py tests/test_readiness_ops.py -q` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add -- src/source_activation_guide.py src/readiness_ops.py tests/test_source_activation_guide.py tests/test_readiness_ops.py +git commit -m "Align stale readiness operator guidance" +``` + +### Task 4: Documentation, full verification, and release handoff + +**Files:** +- Modify: `ROADMAP.md` +- Modify: `docs/DATA_STRATEGY.md` +- Modify: `docs/DASHBOARD_QA.md` +- Modify: `docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md` +- Modify: `tests/test_public_v1_release_docs.py` + +**Interfaces:** +- Consumes: verified runtime behavior from Tasks 1-3 +- Produces: durable operating-maturity and continuation-contract evidence + +- [ ] **Step 1: Add a failing documentation contract** + +Require all four documents to contain the phrases `stale readiness continuation gate`, `make readiness-preview TOP_N=20`, `planning context only`, and `separate intentional reviewed write`. + +- [ ] **Step 2: Run the documentation test and verify failure** + +Run: `python3 -m pytest tests/test_public_v1_release_docs.py -q` + +Expected: FAIL until the docs describe the verified gate. + +- [ ] **Step 3: Update documentation** + +Document that project status, provider setup, and coverage frontier now agree on inspection-only routing while selected-profile readiness is stale. State that this improves operating reliability but does not refresh data, prove source correctness, authorize a rebuild, satisfy hosted/reviewer/source/calibration gates, or establish market validation. + +- [ ] **Step 4: Run focused and full verification** + +Run: + +```bash +python3 -m pytest tests/test_continuation_gate.py tests/test_project_status.py tests/test_source_activation_guide.py tests/test_readiness_ops.py tests/test_public_v1_release_docs.py -q +python3 -m pytest tests -q +make dashboard-smoke +make research-dashboard-render-smoke +make public-wording-check +make public-check +make commercial-beta-check +make commercial-beta-release-check +make pilot-readiness-check TOP_N=10 +make diff-hygiene-summary +git diff --check +``` + +Expected: all tests and local release gates pass; pilot readiness remains truthfully blocked by stale readiness and external evidence; generated churn remains zero. + +- [ ] **Step 5: Stage exact files and verify staged hygiene** + +```bash +git add -- ROADMAP.md docs/DATA_STRATEGY.md docs/DASHBOARD_QA.md docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md tests/test_public_v1_release_docs.py +make staged-hygiene-check +git diff --cached --check +``` + +- [ ] **Step 6: Commit, push, and update draft PR #113** + +```bash +git commit -m "Document stale readiness continuation gate" +git push origin codex/personal-research-mode-mvp +``` + +Update PR #113 with the implementation, direct checks, zero-artifact result, current external classifications, and exact next external resume condition. Keep the PR draft and do not merge or deploy. diff --git a/docs/superpowers/plans/2026-07-19-cohort-price-history-scope.md b/docs/superpowers/plans/2026-07-19-cohort-price-history-scope.md new file mode 100644 index 00000000..a366b338 --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-cohort-price-history-scope.md @@ -0,0 +1,27 @@ +# Cohort Price-History Scope 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. + +**Goal:** Prevent saved price readiness from becoming commercially usable cohort evidence without row-level provenance, exact-source rights, and registered `prices` scope. + +**Architecture:** Review grouped canonical price rows inside `derive_cohort_evidence`, pass the result into the existing coverage builder, and load `prices.csv` through the dashboard’s current read-only optional loader. Keep research mode and all mutation paths unchanged. + +**Tech Stack:** Python 3.12, pandas, pytest, immutable source-rights registry + +### Task 1: Add failing price-history evidence tests + +**Files:** `tests/test_focused_cohort_coverage.py`, `tests/test_research_mode_dashboard_contract.py` + +Test missing rows, missing lineage, approved scoped rows, missing scope, mixed histories, research compatibility, and the real dashboard loader contract. Run the two focused files and confirm failures against the saved-readiness-only behavior. + +### Task 2: Implement row-group price review + +**Files:** `src/focused_cohort_coverage.py`, `src/dashboard.py` + +Add optional `prices` and injectable registry inputs. Normalize grouped rows, retain technically valid rows, require `source`, `source_ref`, and `retrieved_at` for every commercial row, review each exact source with `review_commercial_field_scope(..., ("prices",))`, and return a deterministic state/evidence message. Conjunct the commercial evidence with saved `price_ready`; preserve research mode. + +### Task 3: Document and verify + +**Files:** `ROADMAP.md`, `docs/METHODOLOGY.md`, `docs/PROVENANCE_CONTRACT.md`, `docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md` + +Record only the verified cohort display boundary and keep Priority 5 chronology plus quarterly Revenue/EPS open. Run focused tests, full tests, dashboard/render, public, commercial, pilot, hygiene, and whitespace gates. Stage exact paths, commit, push, update draft PR #113, and require hosted CI on the exact head. diff --git a/docs/superpowers/plans/2026-07-19-cohort-quarterly-field-scope.md b/docs/superpowers/plans/2026-07-19-cohort-quarterly-field-scope.md new file mode 100644 index 00000000..aba95be1 --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-cohort-quarterly-field-scope.md @@ -0,0 +1,15 @@ +# Cohort Quarterly Field-Scope Implementation Plan + +1. Add failing focused-cohort tests for independent Revenue/EPS commercial + source-rights and field-scope decisions, including mixed-source failure and + unchanged Research-mode behavior. +2. Extend cohort evidence derivation with accepted quarterly actual rows and + produce independent commercial Revenue/EPS evidence states. +3. Conjoin those commercial evidence states with the existing technical trend + packet states when composing cohort coverage. +4. Pass the dashboard's accepted canonical quarterly actual rows into evidence + derivation; do not refresh, repair, or write the ledger. +5. Update methodology, provenance, roadmap, continuation contract, and dashboard + contract tests. +6. Run focused and full tests, every required product/release/hygiene gate, exact + staging, commit, push, draft-PR update, and exact-head hosted verification. diff --git a/docs/superpowers/plans/2026-07-19-cohort-saved-row-field-scope.md b/docs/superpowers/plans/2026-07-19-cohort-saved-row-field-scope.md new file mode 100644 index 00000000..83d33e82 --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-cohort-saved-row-field-scope.md @@ -0,0 +1,63 @@ +# Cohort Saved-Row Field-Scope 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. + +**Goal:** Prevent one source-level commercial approval from unlocking unrelated focused-cohort fields while preserving research-mode behavior and independent lane states. + +**Architecture:** Replace the shared `_source_backed` commercial boolean in `focused_cohort_coverage.py` with pure per-row field review helpers using `review_commercial_field_scope`. Compose each saved-row lane from separate technical, provenance, rights, and exact-field decisions, while retaining the existing coverage frame and Advanced dashboard boundary. + +**Tech Stack:** Python 3.12, pandas, pytest, immutable YAML source-rights registry + +## Constraints + +- Do not edit `config/source_rights.yml`, activate a provider, or fetch data. +- Do not change price readiness or canonical quarterly Revenue/EPS packets in this slice. +- Preserve candidate-only states and research-mode compatibility. +- Do not run `make readiness` or generate repository artifacts. + +### Task 1: Prove the source-level permission leak + +**Files:** +- Modify: `tests/test_focused_cohort_coverage.py` +- Modify: `src/focused_cohort_coverage.py` + +1. Add a checked-registry regression showing an SEC Companyfacts row with margin, FCF, cash, debt, shares, and filing date keeps only shares and filing date commercially usable. +2. Add injected-registry tests showing one exact supported field cannot unlock sibling fields and cash/debt remain independent. +3. Run `python3 -m pytest tests/test_focused_cohort_coverage.py -q` and confirm the old source-level behavior fails. +4. Add a frozen row review result containing technical availability, provenance completeness, rights state, required fields, missing fields, and combined usability. +5. Use `review_commercial_field_scope` for exact populated fields and make commercial evidence text name independent blockers. +6. Rerun the focused file and confirm green. + +### Task 2: Enforce earnings, consensus, and trusted-peer scope + +**Files:** +- Modify: `tests/test_focused_cohort_coverage.py` +- Modify: `src/focused_cohort_coverage.py` + +1. Add failing tests for missing `earnings_dates` scope, Revenue/EPS consensus scope independence, blocked date-only consensus, and a trusted row missing `trusted_peers` scope. +2. Review earnings dates with exact `earnings_dates` scope. +3. Require consensus values and review every populated metric with `revenue_consensus` or `eps_consensus`; retain cutoff/fiscal-period/provenance checks. +4. Require every trusted row to pass `trusted_peers`; leave candidate rows untouched. +5. Run `python3 -m pytest tests/test_focused_cohort_coverage.py -q` and confirm green. + +### Task 3: Preserve dashboard and documentation contracts + +**Files:** +- Modify: `ROADMAP.md` +- Modify: `docs/METHODOLOGY.md` +- Modify: `docs/PROVENANCE_CONTRACT.md` +- Modify: `docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md` +- Test: `tests/test_dashboard_research_mode.py` or the narrow existing dashboard contract test if an assertion is needed + +1. Add a dashboard contract assertion that field-scope blockers stay in Advanced cohort evidence and do not alter the primary research answer. +2. Document the saved-row lane mapping and truthful remaining Priority 2 gaps: prices and canonical quarterly Revenue/EPS. +3. Run focused cohort/dashboard/docs tests, public wording, and commercial beta checks. + +### Task 4: Verify and publish the coherent slice + +1. Run `PYTHONDONTWRITEBYTECODE=1 python3 -m pytest -p no:cacheprovider tests -q`. +2. Run dashboard, six-route Personal Research render, public wording/check, commercial beta/release, pilot readiness, diff hygiene, and whitespace gates. +3. Confirm zero generated CSV/JSON/report/sample-report/screenshot/timing/canonical-data changes. +4. Stage exact implementation, tests, ROADMAP, methodology, provenance, continuation prompt, design, and plan paths only. +5. Run staged hygiene and cached whitespace checks, commit, and push only the feature branch. +6. Update draft PR #113 with red-green evidence and remaining audit boundaries; verify current-head hosted CI and keep the PR draft. diff --git a/docs/superpowers/plans/2026-07-19-commercial-field-scope-review.md b/docs/superpowers/plans/2026-07-19-commercial-field-scope-review.md new file mode 100644 index 00000000..a1c4a7e2 --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-commercial-field-scope-review.md @@ -0,0 +1,260 @@ +# Commercial Field-Scope Review 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:** Give consensus source validation and prospective collection one immutable exact-source commercial-rights and required-field scope decision without changing either consumer's public behavior. + +**Architecture:** Add a domain-neutral frozen result and pure function to the existing source-rights module. Keep consumer-specific blocker strings, technical states, cutoff/lineage checks, preview states, and write authorization in the two consensus modules while replacing their duplicate registry and missing-field calculations. + +**Tech Stack:** Python 3.12, frozen dataclasses, immutable source-rights registry, pytest, Make product gates, Git/GitHub draft PR workflow. + +## Global Constraints + +- Research-only; no investment advice, broker integration, order routing, auto-trading, direct buy/sell instructions, or post-earnings price prediction. +- Preserve exact source IDs; do not split composite labels, infer aliases, or borrow rights. +- Keep commercial rights and required-field scope independently visible. +- Keep technical validity, candidate/history state, collection, Revenue, EPS, write authorization, nowcast readiness, backtesting, and calibration independent. +- Preserve existing collector and validator result fields, blocker strings, and state transitions. +- Do not modify price, DCF, fundamentals, cash-generation, or other domain evidence models in this slice. +- Do not run `make readiness` or create/stage CSV, JSON, report, sample-report, screenshot, browser timing, or bytecode churn. +- Stage exact intentional product/code/docs/test files only; never use `git add -A`. + +--- + +### Task 1: Pure commercial field-scope decision + +**Files:** +- Modify: `tests/test_commercial_source_rights.py` +- Modify: `src/commercial_source_rights.py` + +**Interfaces:** +- Consumes: `commercial_eligibility(registry, source_id) -> CommercialEligibility` and exact `SourceRights.supported_fields`. +- Produces: `review_commercial_field_scope(registry, source_id, required_fields) -> CommercialFieldScopeReview`. + +- [ ] **Step 1: Write failing approved and missing-scope tests** + +Use the checked-in immutable registry. Assert stable supplied order, exact missing fields, and combined readiness: + +```python +complete = module.review_commercial_field_scope( + _registry(), "sec_companyfacts", ("revenue", "shares_outstanding") +) +missing = module.review_commercial_field_scope( + _registry(), "sec_companyfacts", ("revenue", "free_cash_flow") +) +assert complete.required_supported_fields == ("revenue", "shares_outstanding") +assert complete.missing_supported_fields == () +assert complete.commercial_evidence_ready is True +assert missing.missing_supported_fields == ("free_cash_flow",) +assert missing.commercial_rights_approved is True +assert missing.commercial_evidence_ready is False +``` + +- [ ] **Step 2: Write failing independence and exact-source tests** + +Assert that `yfinance` has supported `prices` scope but unverified rights, and a composite source remains unknown with every required field missing: + +```python +unverified = module.review_commercial_field_scope(_registry(), "yfinance", ("prices",)) +unknown = module.review_commercial_field_scope( + _registry(), "sec_companyfacts + yfinance", ("revenue", "prices") +) +assert unverified.missing_supported_fields == () +assert unverified.rights_status == "commercial_rights_unverified" +assert unverified.commercial_evidence_ready is False +assert unknown.rights_status == "unknown_source" +assert unknown.missing_supported_fields == ("revenue", "prices") +``` + +- [ ] **Step 3: Write failing immutability and caller-contract tests** + +Assert an approved empty-field decision is rights-ready, the result is frozen, and blank or duplicate required fields raise: + +```python +rights_only = module.review_commercial_field_scope(_registry(), "sec_companyfacts", ()) +assert rights_only.commercial_evidence_ready is True +with pytest.raises(FrozenInstanceError): + rights_only.commercial_evidence_ready = False +with pytest.raises(ValueError, match="non-empty unique strings"): + module.review_commercial_field_scope(_registry(), "sec_companyfacts", ("revenue", "")) +with pytest.raises(ValueError, match="non-empty unique strings"): + module.review_commercial_field_scope(_registry(), "sec_companyfacts", ("revenue", "revenue")) +``` + +- [ ] **Step 4: Run the source-rights tests and verify RED** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests/test_commercial_source_rights.py -q +``` + +Expected: failures because `CommercialFieldScopeReview` and `review_commercial_field_scope` do not exist. + +- [ ] **Step 5: Add the frozen result** + +Add after `CommercialEligibility`: + +```python +@dataclass(frozen=True) +class CommercialFieldScopeReview: + source_id: str + rights_status: str + commercial_rights_approved: bool + required_supported_fields: tuple[str, ...] + missing_supported_fields: tuple[str, ...] + commercial_evidence_ready: bool +``` + +- [ ] **Step 6: Implement the pure exact-source review** + +Add after `commercial_eligibility`: + +```python +def review_commercial_field_scope( + registry: Mapping[str, SourceRights], + source_id: str, + required_fields: Sequence[str], +) -> CommercialFieldScopeReview: + normalized_source_id = str(source_id or "").strip() + normalized_fields = tuple(str(field or "").strip() for field in required_fields) + if any(not field for field in normalized_fields) or len(set(normalized_fields)) != len(normalized_fields): + raise ValueError("required fields must be non-empty unique strings") + rights = commercial_eligibility(registry, normalized_source_id) + record = registry.get(normalized_source_id) + supported_fields = set(record.supported_fields) if record is not None else set() + missing_fields = tuple(field for field in normalized_fields if field not in supported_fields) + return CommercialFieldScopeReview( + source_id=normalized_source_id, + rights_status=rights.status, + commercial_rights_approved=rights.allowed, + required_supported_fields=normalized_fields, + missing_supported_fields=missing_fields, + commercial_evidence_ready=rights.allowed and not missing_fields, + ) +``` + +- [ ] **Step 7: Run the source-rights tests and verify GREEN** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests/test_commercial_source_rights.py -q +``` + +Expected: all source-rights tests pass. + +### Task 2: Migrate consensus consumers without behavior changes + +**Files:** +- Modify: `src/earnings_consensus_collector.py` +- Modify: `src/earnings_consensus_sources.py` +- Test: `tests/test_earnings_consensus_collector.py` +- Test: `tests/test_earnings_consensus_sources.py` + +**Interfaces:** +- Consumes: `review_commercial_field_scope(...) -> CommercialFieldScopeReview` from Task 1. +- Produces: unchanged `CollectionPreview`, `SourceCommercialReview`, and `SourceValidationResult` public behavior. + +- [ ] **Step 1: Refactor collector commercial evidence** + +Replace the collector's direct `commercial_eligibility`, exact-record lookup, supported-field set, and missing-field calculation with one shared review call. Map the shared fields into existing blocker strings and `CollectionPreview`: + +```python +commercial_review = review_commercial_field_scope( + rights_registry, proposed.source, required_supported_fields +) +if not commercial_review.commercial_rights_approved: + commercial_blockers.append(f"commercial_rights:{commercial_review.rights_status}") +commercial_blockers.extend( + f"registered_consensus_scope_missing:{field}" + for field in commercial_review.missing_supported_fields +) +``` + +Do not change technical `write_allowed`, `commercial_write_allowed`, state, reason, identity, batch, or mutation behavior. + +- [ ] **Step 2: Refactor source-validator commercial evidence** + +Use an empty-field shared review for source-level rights. For each technically accepted row, pass its populated Revenue/EPS tuple to the shared review and map the result into `SourceCommercialReview`. Preserve all cutoff, scope, timestamp, schema, comparability, state, count, row-number, and aggregate behavior. + +- [ ] **Step 3: Run consumer regression tests** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests/test_commercial_source_rights.py tests/test_earnings_consensus_sources.py tests/test_earnings_consensus_collector.py -q +``` + +Expected: all source-rights, source-validator, and collector tests pass with unchanged public evidence. + +- [ ] **Step 4: Review duplication removal** + +Use `rg` and the diff to confirm both consensus modules call `review_commercial_field_scope` and no longer independently read `supported_fields` or calculate `missing_supported_fields`. Confirm their domain-specific blocker strings remain local. Run `git diff --check`. + +### Task 3: Documentation and release verification + +**Files:** +- Modify: `ROADMAP.md` +- Modify: `docs/DATA_STRATEGY.md` +- Modify: `docs/EARNINGS_NOWCAST_PILOT.md` +- Modify: `docs/METHODOLOGY.md` +- Modify: `docs/PROVENANCE_CONTRACT.md` +- Modify: `docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md` + +**Interfaces:** +- Consumes: the verified shared field-scope decision and unchanged consumer evidence. +- Produces: truthful roadmap, methodology, provenance, continuation, and PR evidence for the reliability slice. + +- [ ] **Step 1: Document the implemented roadmap item** + +Add item 33 and maturity commentary: both consensus paths now share one exact-source rights/field-scope decision, reducing semantic drift without changing readiness or providing source evidence. State explicitly that price, DCF, fundamentals, and cash-generation domain reviews remain separate. + +- [ ] **Step 2: Update source strategy, pilot, method, and provenance** + +Document the shared decision fields and the retained separation: consumer technical states and blocker copy remain local; registry metadata cannot prove payloads, timestamps, comparability, review intent, collection, activation, backtesting, or calibration. + +- [ ] **Step 3: Update the continuation contract** + +Add the committed design/plan lineage anchor, shared-decision capability, truthful boundary, and Stage 2 instruction. Preserve stale-readiness, external dependency, and no-generated-write rules. + +- [ ] **Step 4: Run focused and full verification** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests/test_commercial_source_rights.py tests/test_earnings_consensus_sources.py tests/test_earnings_consensus_collector.py -q +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests -q +``` + +Expected: zero failures; the existing third-party `dateutil` warning may remain. + +- [ ] **Step 5: Run required non-writing product gates** + +Run: + +```bash +make dashboard-smoke +make research-dashboard-render-smoke +make public-wording-check +make public-check +make commercial-beta-check +make commercial-beta-release-check +make pilot-readiness-check TOP_N=10 +make diff-hygiene-summary +git diff --check +``` + +Expected: executable gates pass, pilot readiness remains stale, and generated churn remains zero. + +- [ ] **Step 6: Stage exact files and verify hygiene** + +Stage only the three source modules, the three named test modules if changed, the six named product documents, and this plan if corrected during execution. Run `make staged-hygiene-check` and `git diff --cached --check`. + +- [ ] **Step 7: Commit, push, and update draft PR #113** + +Commit with message `Unify consensus commercial evidence review`, push only `codex/personal-research-mode-mvp`, confirm 0/0 alignment, and post verified scope, red-green evidence, full gates, unchanged outputs, external dependencies, and next step to PR #113. Keep it draft. + +- [ ] **Step 8: Re-audit the handoff state** + +Verify clean status, pushed HEAD, draft PR state, artifact hygiene, stale readiness, remaining stage gates, review safety, and active overall goal. diff --git a/docs/superpowers/plans/2026-07-19-commercial-price-apply-guard.md b/docs/superpowers/plans/2026-07-19-commercial-price-apply-guard.md new file mode 100644 index 00000000..06f2ac70 --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-commercial-price-apply-guard.md @@ -0,0 +1,59 @@ +# Commercial Price Apply Guard Implementation Plan + +> **For Codex:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Fail closed before staged-price mutation in explicit commercial mode unless lineage, exact-source commercial rights, and registered `prices` scope are complete for every valid row. + +**Architecture:** Extend `apply_price_import_merge(...)` with optional explicit mode and registry injection. Reuse the existing preview summary as the guard input, return a deterministic non-writing blocked result before validation replay, backup, or canonical merge, and preserve the existing research-mode path. + +**Constraints:** Use temporary pytest paths only; do not run repository `price-apply`, normalize, refresh, or readiness commands; do not edit source rights; do not infer providers; do not make guard passage automatic apply authorization or readiness evidence. + +## Task 1: Add failing guard tests + +**Files:** + +- Modify `tests/test_data_update.py` + +- [ ] Assert explicit research mode retains existing unregistered-source apply behavior. +- [ ] Assert explicit commercial mode blocks the same fixture with rights and price-scope blockers and leaves canonical bytes unchanged with no backup. +- [ ] Add approved-price fixtures proving missing lineage and missing registered price scope block independently. +- [ ] Add a complete approved-price fixture proving the existing merge path can run after the guard passes. +- [ ] Run focused tests and confirm the new signature/guard assertions fail before implementation. + +## Task 2: Implement the pre-mutation commercial guard + +**Files:** + +- Modify `src/data_update.py` + +- [ ] Import and reuse `commercial_mode_enabled`. +- [ ] Add optional `commercial_mode` and `rights_registry` parameters to apply. +- [ ] Pass the same registry into preview and later validation. +- [ ] Build the ordered blocker list from lineage, rights, and price-scope summaries. +- [ ] Return before backup/write when explicit commercial mode has blockers. +- [ ] Preserve existing missing/invalid and research-mode behavior. +- [ ] Run focused tests and require them to pass. + +## Task 3: Durable contracts + +**Files:** + +- Modify `ROADMAP.md` +- Modify `docs/DATA_STRATEGY.md` +- Modify `docs/METHODOLOGY.md` +- Modify `docs/PROVENANCE_CONTRACT.md` +- Modify `docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md` + +- [ ] Record the explicit commercial-mode mutation guard and local-research compatibility. +- [ ] Preserve the unchanged canonical 146-row audit and external source requirement. +- [ ] Record that tests use temporary fixtures and no repository apply or readiness write occurred. + +## Task 4: Full verification and delivery + +- [ ] Run focused changed-module/docs tests and public wording. +- [ ] Run the full test suite, dashboard smoke, six research-route render smoke, public check, commercial beta/release checks, pilot readiness, hygiene, and whitespace checks. +- [ ] Verify zero generated churn and no repository apply/readiness write. +- [ ] Stage exact files only and pass staged hygiene. +- [ ] Commit and push only the feature branch. +- [ ] Update draft PR #113 and verify it remains open and draft. +- [ ] Reassess remaining executable gates without claiming source, hosted, reviewer, calibration, or market completion. diff --git a/docs/superpowers/plans/2026-07-19-commercial-price-refresh-enforcement.md b/docs/superpowers/plans/2026-07-19-commercial-price-refresh-enforcement.md new file mode 100644 index 00000000..00c2f9d1 --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-commercial-price-refresh-enforcement.md @@ -0,0 +1,182 @@ +# Commercial Price Refresh Enforcement 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. + +**Goal:** Make every Commercial Research direct price refresh fail closed unless the exact reachable and selected provider independently has approved commercial rights and registered `prices` scope. + +**Architecture:** Add stable identities to concrete price sources, make the ladder validate and preserve exact child identity, and reuse the commercial field-scope review at both construction and mutation boundaries. Research mode remains compatible; injected registries provide deterministic tests without changing checked-in rights or calling providers. + +**Tech Stack:** Python 3.12, pandas, pytest, YAML source-rights registry + +## Global Constraints + +- Do not edit `config/source_rights.yml` or approve a provider. +- Do not call an external provider or run broad price/readiness refreshes. +- Do not generate or stage CSV, JSON, report, sample-report, screenshot, or timing churn. +- Keep exact-source rights, registered field scope, technical validity, and readiness independent. +- Use exact staging only and keep PR #113 open and draft. + +--- + +### Task 1: Lock exact price-provider identity + +**Files:** +- Modify: `src/data_update.py` +- Test: `tests/test_data_update.py` + +**Step 1: Write failing identity tests** + +Add assertions that the six concrete implementations expose these exact IDs: `stooq`, `yahoo`, `fmp`, `alpha_vantage`, `finnhub`, and `ibkr`. Add a ladder test proving a caller label that differs from the child's exact ID raises `ValueError`. + +**Step 2: Run the narrow tests and confirm RED** + +Run: + +```bash +python3 -m pytest tests/test_data_update.py -q -k 'source_id or ladder_rejects' +``` + +Expected: failures because the identities and ladder invariant do not yet exist. + +**Step 3: Implement the identity contract** + +Add `source_id` to `PriceHistorySource` and each concrete implementation. Add a helper that reads a nonblank exact ID. In `PriceSourceLadder.__init__`, require every route label to equal its child's `source_id`; store the possible IDs and a separate `last_source_id`. Set the latter only when that child returns usable rows. + +**Step 4: Run the narrow tests and confirm GREEN** + +Run the command from Step 2 and the existing ladder tests: + +```bash +python3 -m pytest tests/test_data_update.py -q -k 'PriceSourceLadder or source_id or ladder' +``` + +### Task 2: Enforce commercial rights before provider construction + +**Files:** +- Modify: `src/data_update.py` +- Test: `tests/test_data_update.py` + +**Step 1: Write failing construction tests** + +Build injected registries with `build_source_rights_registry`. Prove that: + +- an exact commercial provider with unknown/unapproved rights raises `commercial_price_source_review_required`; +- an approved provider without `prices` scope raises `commercial_price_scope_review_required`; +- automatic commercial construction retains only independently approved and scoped legs; +- an automatic ladder with no eligible leg fails closed. + +Do not fetch data in these tests. + +**Step 2: Run the construction tests and confirm RED** + +```bash +python3 -m pytest tests/test_data_update.py -q -k 'commercial and make_price_source' +``` + +**Step 3: Implement the shared review and construction filter** + +Import `commercial_mode_enabled` and `review_commercial_field_scope`. Add a deterministic review helper that distinguishes rights failure from missing `prices` scope. Extend `make_price_source(provider, *, commercial_mode=None, rights_registry=None)`. In commercial mode, review exact providers before construction and build the auto ladder from lazy provider factories only after filtering each exact ID. Preserve the existing order and behavior in research mode. + +**Step 4: Run the construction tests and confirm GREEN** + +Run the command from Step 2, then all source-construction tests. + +### Task 3: Enforce supplied and selected sources before mutation + +**Files:** +- Modify: `src/data_update.py` +- Test: `tests/test_data_update.py` + +**Step 1: Write failing mutation-boundary tests** + +Add spy sources and temporary paths proving: + +- a supplied source without exact ID fails before `fetch_history()`; +- an unapproved or scope-incomplete supplied source fails before fetch; +- no canonical or status file exists after either failure; +- an approved and scoped supplied source retains the existing successful refresh behavior; +- a source that changes its selected exact identity during fetch raises `commercial_price_source_changed` before canonical/status mutation. + +**Step 2: Run the boundary tests and confirm RED** + +```bash +python3 -m pytest tests/test_data_update.py -q -k 'commercial and update_local_price_data' +``` + +**Step 3: Implement pre-fetch and post-fetch checks** + +Extend `update_local_price_data(..., commercial_mode=None, rights_registry=None)`. Resolve every reachable exact source before loading tickers or fetching and review it once. After a nonempty response, resolve the actual selected source, require it to be in the pre-reviewed set, and repeat the exact rights/scope decision before appending rows or status. Missing, changed, or composite identity must raise before mutation. + +**Step 4: Run focused regression tests and confirm GREEN** + +```bash +python3 -m pytest tests/test_data_update.py tests/test_commercial_source_rights.py -q +``` + +### Task 4: Record the verified boundary + +**Files:** +- Modify: `ROADMAP.md` +- Modify: `docs/METHODOLOGY.md` +- Modify: `docs/PROVENANCE_CONTRACT.md` +- Modify: `docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md` +- Test: `tests/test_public_v1_release_docs.py` if existing contract assertions need extension + +**Step 1: Add documentation contract assertions where appropriate** + +Require public/internal documentation to state that direct commercial price refresh needs exact-provider approved rights and registered `prices` scope, while no provider approval, row lineage, current readiness, or market operation is implied. + +**Step 2: Update the roadmap and contracts** + +Record this as a verified local enforcement slice. Keep price lineage, atomic apply, an approved provider, fresh readiness, hosted beta evidence, reviewer validation, calibration, and operating controls open. + +**Step 3: Run documentation checks** + +```bash +python3 -m pytest tests/test_public_v1_release_docs.py -q +make public-wording-check +make commercial-beta-check +``` + +### Task 5: Verify, commit, push, and update draft PR #113 + +**Files:** All intentionally changed files above only. + +**Step 1: Run fresh focused and full verification** + +```bash +python3 -m pytest tests/test_data_update.py tests/test_commercial_source_rights.py -q +python3 -m pytest tests -q +make dashboard-smoke +make research-render-smoke +make public-wording-check +make public-check +make commercial-beta-check +make commercial-beta-release-check +make pilot-readiness-check TOP_N=10 +make diff-hygiene-summary +git diff --check +``` + +Expected: engineering checks pass; pilot/readiness verdict may remain truthfully blocked solely by declared stale or unavailable evidence. + +**Step 2: Confirm generated-artifact hygiene** + +Compare the tracked generated-artifact digest to the baseline, inspect `git status`, and ensure no generated CSV, JSON, report, sample-report, screenshot, or timing file changed. + +**Step 3: Stage exact files and verify staging** + +Stage only the implementation, tests, ROADMAP, methodology/provenance docs, continuation prompt, design, and plan files. Run: + +```bash +make staged-hygiene-check +git diff --cached --check +``` + +**Step 4: Commit and push the coherent slice** + +Create one implementation commit, push only `codex/personal-research-mode-mvp`, and verify zero ahead/behind against its remote. + +**Step 5: Update and verify PR #113** + +Add a concise audit-resolution note with scope, tests, truthful open boundaries, and generated-artifact exclusion. Keep the PR draft. Confirm the PR head matches the pushed commit and inspect current hosted checks without merging or deploying. diff --git a/docs/superpowers/plans/2026-07-19-consensus-batch-preflight.md b/docs/superpowers/plans/2026-07-19-consensus-batch-preflight.md new file mode 100644 index 00000000..9d5e6a0c --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-consensus-batch-preflight.md @@ -0,0 +1,346 @@ +# Prospective Consensus Batch Preflight 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 prospective consensus preview and record evaluate one ordered whole-batch contract so a later deterministic rejection cannot leave earlier proposed rows appended. + +**Architecture:** Add an immutable batch preview composed from the existing row preview and an in-memory virtual ledger. Add one batch append function that runs preflight once, selects the research/commercial gate without coupling their evidence states, and writes all accepted rows through one append handle only after the whole batch passes. Keep the single-row API by delegating it to the batch path. + +**Tech Stack:** Python 3.12, frozen dataclasses, standard-library CSV/JSON/path handling, pytest, Make-based repository gates. + +## Global Constraints + +- Research-only; no investment advice, broker integration, order routing, auto-trading, direct buy/sell instructions, or post-earnings price prediction. +- No provider fetch, source-rights edit, readiness rebuild, or repository CSV/JSON/report/sample-report/screenshot/timing write. +- Technical append eligibility, commercial rights, Revenue scope, EPS scope, actuals, consensus, backtesting, and calibration remain independent. +- Input order is authoritative; do not sort rows, infer providers, repair revision chains, invent supersession, or select a conflicting row. +- Promise deterministic preflight all-or-none behavior only; do not claim crash-safe transactionality or concurrent-writer locking. +- Use temporary pytest paths for every write test. +- Stage exact intentional files only; never use `git add -A`. + +--- + +### Task 1: Prove the current partial-write and preview mismatch + +**Files:** +- Modify: `tests/test_earnings_consensus_collector.py` +- Test: `tests/test_earnings_consensus_collector.py` + +**Interfaces:** +- Consumes: existing `FIELDS`, `ProspectiveConsensusRecord`, `main(...)`, and `load_snapshots(...)`. +- Produces: failing behavioral evidence for whole-batch preview and record. + +- [ ] **Step 1: Add temporary CSV and revision helpers** + +Add `csv`, `json`, and `asdict` imports, import `FIELDS` and `main`, and add helpers equivalent to: + +```python +def _write_records(path: Path, records: tuple[ProspectiveConsensusRecord, ...]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", newline="", encoding="utf-8") as handle: + writer = csv.DictWriter(handle, fieldnames=FIELDS) + writer.writeheader() + writer.writerows(asdict(record) for record in records) + + +def _revision(record: ProspectiveConsensusRecord, *, snapshot_id: str = "snap-002") -> ProspectiveConsensusRecord: + return replace( + record, + snapshot_id=snapshot_id, + snapshot_at="2026-07-25T05:00:00Z", + retrieved_at="2026-07-25T05:00:01Z", + source_ref=f"file://reviewed/{record.ticker}/{record.fiscal_period}/20260725", + revenue_consensus="102", + supersedes_snapshot_id=record.snapshot_id, + ) +``` + +- [ ] **Step 2: Add a failing record test for a later duplicate** + +```python +def test_record_preflights_whole_batch_before_any_append(tmp_path: Path): + input_path = tmp_path / "input.csv" + ledger = tmp_path / "ledger.csv" + first = _record() + _write_records(input_path, (first, first)) + + with pytest.raises(ValueError, match="duplicate"): + main(["record", "--input", str(input_path), "--ledger", str(ledger), "--confirm-reviewed"]) + + assert not ledger.exists() +``` + +- [ ] **Step 3: Add failing preview tests for virtual-ledger lineage** + +Capture stdout with `capsys`, parse JSON, and assert: + +```python +payload = json.loads(capsys.readouterr().out) +assert payload.get("state") == "reviewable_batch" +assert [row["state"] for row in payload["rows"]] == ["reviewable_new", "reviewable_revision"] +assert payload["technical_write_allowed"] is True +``` + +Add a second input containing the same row twice and assert the later row is `duplicate`, `state` is `rejected_batch`, and `technical_write_allowed` is false. + +- [ ] **Step 4: Add a failing empty-input record test** + +Write only the header and assert `main(record...)` raises `ValueError` containing `empty_batch` while the destination and its missing parent remain absent. + +- [ ] **Step 5: Run the focused tests and verify RED** + +Run: + +```bash +python3 -m pytest tests/test_earnings_consensus_collector.py -q +``` + +Expected: assertion failures proving current preview misses intra-batch lineage/conflicts, record leaves a partial ledger, and empty record is silently accepted. + +--- + +### Task 2: Add the pure ordered batch preview + +**Files:** +- Modify: `src/earnings_consensus_collector.py` +- Modify: `tests/test_earnings_consensus_collector.py` + +**Interfaces:** +- Consumes: `CollectionPreview`, `preview_collection(...)`, `SourceRights`, and the immutable source-rights registry. +- Produces: `BatchCollectionPreview` and `preview_collection_batch(...)`. + +- [ ] **Step 1: Add the immutable batch result** + +```python +@dataclass(frozen=True) +class BatchCollectionPreview: + mode: str + write_performed: bool + state: str + row_count: int + reviewable_count: int + technical_write_allowed: bool + commercial_evidence_ready: bool + commercial_write_allowed: bool + technical_blockers: tuple[str, ...] + commercial_blockers: tuple[str, ...] + rows: tuple[CollectionPreview, ...] +``` + +- [ ] **Step 2: Implement ordered virtual-ledger evaluation** + +Add: + +```python +def preview_collection_batch( + existing: Sequence[ProspectiveConsensusRecord], + proposed: Sequence[ProspectiveConsensusRecord], + *, + as_of: str | None = None, + cooldown_hours: int = 0, + rights_registry: Mapping[str, SourceRights] | None = None, +) -> BatchCollectionPreview: +``` + +Resolve the registry once. For every one-based row position, call `preview_collection(...)` with `as_of` when supplied or that row's `retrieved_at`. Append technically reviewable rows to a mutable virtual ledger. Build blockers as: + +```python +f"row_{index}:{preview.state}:{preview.reason}" +f"row_{index}:{blocker}" +``` + +For no proposed rows, return `state="empty_batch"`, false gates, and `("batch:empty_input",)` technical and commercial blockers. Otherwise return `reviewable_batch` only when every row is technically writeable, and compute commercial evidence independently across every row. + +- [ ] **Step 3: Route CLI preview through the batch function** + +Replace the independent list comprehension with: + +```python +result = preview_collection_batch(existing, proposed, as_of=args.as_of) +print(json.dumps(asdict(result), indent=2, sort_keys=True)) +``` + +- [ ] **Step 4: Add and verify reversed-revision behavior** + +Preview a revision before its target and assert `rejected_batch`, first-row reason contains `supersedes_snapshot_id does not exist`, and input order is unchanged in the returned row results. + +- [ ] **Step 5: Run focused tests and verify the preview tests are GREEN** + +Run: + +```bash +python3 -m pytest tests/test_earnings_consensus_collector.py -q +``` + +Expected: preview/empty tests pass; the partial-write test still fails until Task 3. + +--- + +### Task 3: Add one preflighted batch append path + +**Files:** +- Modify: `src/earnings_consensus_collector.py` +- Modify: `tests/test_earnings_consensus_collector.py` + +**Interfaces:** +- Consumes: `preview_collection_batch(...)` and `commercial_mode_enabled()`. +- Produces: `append_reviewed_batch(...)`; `append_reviewed_snapshot(...)` delegates to it. + +- [ ] **Step 1: Add failing API tests for research and commercial batches** + +Use `getattr(collector, "append_reviewed_batch", None)` and first assert it is callable so the pre-implementation run fails as an assertion rather than a collection error. Then prove: + +- an existing ledger remains byte-identical when a later same-period row is rejected; +- a later unregistered commercial row leaves a missing destination parent absent; +- a valid new row followed by its explicit revision appends both rows in order in research mode; +- the same ordered batch appends in commercial mode with an injected approved registry; +- an empty batch is rejected without filesystem mutation. + +- [ ] **Step 2: Run the focused tests and verify RED for the missing batch API** + +Run: + +```bash +python3 -m pytest tests/test_earnings_consensus_collector.py -q +``` + +Expected: failures identify the missing `append_reviewed_batch` behavior. + +- [ ] **Step 3: Implement `append_reviewed_batch(...)`** + +Use this signature: + +```python +def append_reviewed_batch( + path: Path | str, + records: Sequence[ProspectiveConsensusRecord], + *, + confirm_reviewed: bool, + commercial_mode: bool | None = None, + rights_registry: Mapping[str, SourceRights] | None = None, +) -> Path: +``` + +Convert records to a tuple, validate confirmation, load the existing ledger and registry once, run batch preview with `as_of=None`, reject technical blockers first, resolve commercial mode once, reject commercial blockers second, then create the parent and append every row using one `csv.DictWriter` and `writerows(...)` call. + +Use stable errors: + +```python +raise ValueError(f"{preview.state}: " + "; ".join(preview.technical_blockers)) +raise ValueError("batch_commercial_evidence_review_required: " + "; ".join(preview.commercial_blockers)) +``` + +- [ ] **Step 4: Delegate single-row append and CLI record** + +Make `append_reviewed_snapshot(...)` call `append_reviewed_batch(path, (record,), ...)`. Replace the CLI record loop with one `append_reviewed_batch(...)` call over the loaded tuple. + +- [ ] **Step 5: Run focused collector and rights tests and verify GREEN** + +Run: + +```bash +python3 -m pytest tests/test_earnings_consensus_collector.py tests/test_earnings_consensus_sources.py tests/test_commercial_source_rights.py -q +``` + +Expected: all focused tests pass with only the existing dependency warning. + +--- + +### Task 4: Update durable operating contracts + +**Files:** +- Modify: `ROADMAP.md` +- Modify: `docs/EARNINGS_NOWCAST_PILOT.md` +- Modify: `docs/DATA_STRATEGY.md` +- Modify: `docs/METHODOLOGY.md` +- Modify: `docs/PROVENANCE_CONTRACT.md` +- Modify: `docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md` + +**Interfaces:** +- Consumes: verified batch preview/record behavior. +- Produces: roadmap item 30 and a continuation anchor for this design/plan lineage. + +- [ ] **Step 1: Document the preview/record convergence** + +State that preview simulates proposed rows in input order against a virtual append-only ledger and record uses that exact result before mutation. + +- [ ] **Step 2: Preserve independent evidence and truthful limits** + +State that technical and commercial batch gates remain independent; research mode does not satisfy Commercial Research activation; no source, rights, snapshot, readiness, calibration, hosted, reviewer, or market gate changed. + +- [ ] **Step 3: Document the transaction boundary** + +State explicitly that deterministic preflight prevents known later rejections from causing partial writes but does not provide concurrent-writer locking or crash-safe filesystem transactions. + +- [ ] **Step 4: Update the continuation prompt** + +Add the design/plan commit anchor, implemented capability, truth boundary, and exact next-stage meaning without changing the external dependency classifications. + +- [ ] **Step 5: Run wording and whitespace checks** + +Run: + +```bash +make public-wording-check +git diff --check +``` + +Expected: both pass. + +--- + +### Task 5: Full verification, exact delivery, and clean-state audit + +**Files:** +- Stage only the exact changed code/test/docs paths from Tasks 1-4. + +**Interfaces:** +- Consumes: the completed implementation and durable contracts. +- Produces: one verified implementation commit, pushed draft PR update, and a clean aligned branch. + +- [ ] **Step 1: Run full tests** + +```bash +python3 -m pytest tests -q +``` + +Expected: zero failures; record the exact pass count and warnings. + +- [ ] **Step 2: Run product and release gates** + +```bash +make dashboard-smoke +make research-dashboard-render-smoke +make public-wording-check +make public-check +make commercial-beta-check +make commercial-beta-release-check +make pilot-readiness-check TOP_N=10 +make diff-hygiene-summary +git diff --check +``` + +Expected: local code/render/public/commercial gates pass; pilot readiness may remain blocked only by the already documented stale-readiness or external gates. + +- [ ] **Step 3: Verify zero generated churn** + +Confirm `git status --short` lists only the exact intended code/test/docs paths and no CSV, JSON, report, sample-report, screenshot, timing, readiness, or canonical-data path. + +- [ ] **Step 4: Stage exact files and verify staged hygiene** + +Run `git add --` with the explicit paths, then: + +```bash +git diff --cached --check +make staged-hygiene-check +``` + +Expected: product/code/docs/test files only; zero generated or manual-review paths. + +- [ ] **Step 5: Commit, push, and update PR #113** + +Commit with `Guard prospective consensus batches`, push only `codex/personal-research-mode-mvp`, and post the behavior, verification, maturity boundary, artifact boundary, and external dependency status to the existing draft PR. Do not merge or deploy. + +- [ ] **Step 6: Reverify final truth** + +Confirm clean 0/0 alignment, HEAD ancestry, PR open/draft/mergeable state, zero generated churn, stale-readiness truth, and the next executable local gate. Keep the overall goal active. diff --git a/docs/superpowers/plans/2026-07-19-consensus-ledger-integrity.md b/docs/superpowers/plans/2026-07-19-consensus-ledger-integrity.md new file mode 100644 index 00000000..3499984d --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-consensus-ledger-integrity.md @@ -0,0 +1,17 @@ +# Consensus Ledger Integrity Implementation Plan + +1. Add failing tests for invalid existing ledgers, all linear-chain violations, + non-leaf revisions, row-numbered errors, and unchanged bytes on failure. +2. Add failing tests for preview cutoff/input/ledger/commercial-mode receipt + binding and required record arguments. +3. Separate validated existing-ledger loading from individually validated + proposed-batch loading. +4. Implement semantic digests, full chain validation, leaf-only revision checks, + normalized cutoff output, and deterministic preview receipts. +5. Require exact cutoff and receipt at programmatic and CLI/Make record boundaries; + revalidate and recompute before append. +6. Update Make targets, methodology, provenance, operator/pilot docs, roadmap, and + continuation contract without changing the source-review input contract. +7. Run focused/full tests and every required product, release, pilot, PR-range, + and hygiene check; stage exact files, commit, push, update draft PR #113, and + verify hosted CI on the exact head. diff --git a/docs/superpowers/plans/2026-07-19-consensus-source-review-command.md b/docs/superpowers/plans/2026-07-19-consensus-source-review-command.md new file mode 100644 index 00000000..42f9737e --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-consensus-source-review-command.md @@ -0,0 +1,639 @@ +# Consensus Source Review Command 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:** Add a supported read-only command that loads one supplied consensus CSV and exposes the existing technical, temporal, rights, and Revenue/EPS scope review before collection preview. + +**Architecture:** Extend `src.earnings_consensus_sources` with two pure presentation-boundary helpers and an explicit CLI review mode while leaving `validate_source_rows(...)` as the only validation decision. Add one bytecode-free Make target; existing provider-status behavior remains the default. + +**Tech Stack:** Python 3.12 standard library (`argparse`, `csv`, `dataclasses`, `json`, `pathlib`), pytest, GNU Make, Markdown documentation. + +## Global Constraints + +- The command is read-only: no provider fetch, normalization, directory creation, ledger append, apply, readiness rebuild, or generated output file. +- Require explicit `INPUT`, exact `PROVIDER`, and UTC `AS_OF`; supply no defaults. +- Preserve technical acceptance, candidate/history state, commercial rights, Revenue scope, EPS scope, collection, and activation as independent evidence. +- A readable review returns zero even when rows are rejected or commercially incomplete; invocation and CSV-shape errors return nonzero. +- Keep the existing `earnings-consensus-source-status` CLI and Make behavior compatible. +- Set `PYTHONDONTWRITEBYTECODE=1` on the new Make target. +- Do not create or stage CSV, JSON, report, sample-report, screenshot, browser-timing, readiness, canonical-data, or proof-ledger churn. +- Never use `git add -A`; stage only exact reviewed product/code/docs/test paths. +- Keep PR #113 open and draft; do not merge or deploy. + +--- + +### Task 1: Read and render one source-review CSV + +**Files:** +- Modify: `src/earnings_consensus_sources.py` +- Test: `tests/test_earnings_consensus_sources.py` + +**Interfaces:** +- Consumes: `validate_source_rows(provider: str, rows: Sequence[Mapping[str, object]], *, as_of: object, rights_registry: Mapping[str, SourceRights] | None = None) -> SourceValidationResult`. +- Produces: `load_source_review_csv(path: Path | str) -> tuple[dict[str, object], ...]`. +- Produces: `render_source_validation_result(result: SourceValidationResult) -> str`. + +- [ ] **Step 1: Add failing loader and renderer tests** + +Task 1 needs the existing `Path` and `pytest` imports. Extend the source import and add these tests: + +Extend the source import to include `load_source_review_csv` and `render_source_validation_result`: + +```python +from src.earnings_consensus_sources import ( + consensus_source_statuses, + load_source_review_csv, + render_source_validation_result, + validate_source_rows, +) +``` + +```python +def test_load_source_review_csv_preserves_rows_without_writing(tmp_path: Path): + input_path = tmp_path / "reviewed.csv" + input_path.write_text( + "ticker,fiscal_period,snapshot_at,retrieved_at,source_ref,revenue_consensus,eps_consensus,history_scope\n" + "NVDA,2027-Q1,2026-07-18T05:00:00Z,2026-07-18T05:00:01Z,fixture://NVDA,1,2,current_only\n", + encoding="utf-8", + ) + before = input_path.read_bytes() + + rows = load_source_review_csv(input_path) + + assert rows == ( + { + "ticker": "NVDA", + "fiscal_period": "2027-Q1", + "snapshot_at": "2026-07-18T05:00:00Z", + "retrieved_at": "2026-07-18T05:00:01Z", + "source_ref": "fixture://NVDA", + "revenue_consensus": "1", + "eps_consensus": "2", + "history_scope": "current_only", + }, + ) + assert input_path.read_bytes() == before + assert {path.name for path in tmp_path.iterdir()} == {"reviewed.csv"} + + +@pytest.mark.parametrize( + ("contents", "message"), + [ + ("", "header row"), + ("ticker,ticker\nNVDA,NVDA\n", "non-blank unique column names"), + ("ticker,,fiscal_period\nNVDA,x,2027-Q1\n", "non-blank unique column names"), + ("ticker\nNVDA,extra\n", "more values than the header"), + ], +) +def test_load_source_review_csv_rejects_ambiguous_shapes( + tmp_path: Path, + contents: str, + message: str, +): + input_path = tmp_path / "reviewed.csv" + input_path.write_text(contents, encoding="utf-8") + + with pytest.raises(ValueError, match=message): + load_source_review_csv(input_path) + + +def test_load_source_review_csv_reports_unreadable_input(tmp_path: Path): + with pytest.raises(ValueError, match="cannot read consensus source review CSV"): + load_source_review_csv(tmp_path / "missing.csv") + with pytest.raises(ValueError, match="cannot read consensus source review CSV"): + load_source_review_csv(tmp_path) + + +def test_load_source_review_csv_accepts_header_only_as_empty_review(tmp_path: Path): + input_path = tmp_path / "reviewed.csv" + input_path.write_text("ticker,fiscal_period\n", encoding="utf-8") + + rows = load_source_review_csv(input_path) + result = validate_source_rows( + "reviewed_csv", + rows, + as_of=REVIEW_CUTOFF, + ) + + assert rows == () + assert result.state == "still_blocked" + assert result.accepted_count == 0 + assert result.auto_apply is False + + +def test_render_source_validation_result_keeps_technical_and_commercial_evidence_separate(): + result = validate_source_rows( + "licensed_consensus", + [ + _historical_row(eps_consensus=""), + _current_row(retrieved_at="2026-07-18T06:00:01Z"), + ], + as_of=REVIEW_CUTOFF, + rights_registry=_rights_registry(supported_fields=("revenue_consensus",)), + ) + + rendered = render_source_validation_result(result) + + assert "Consensus Source Review" in rendered + assert "state: historical_evidence_reviewable" in rendered + assert "accepted_count: 1" in rendered + assert "rejected_count: 1" in rendered + assert "commercial_evidence_ready: true" in rendered + assert "- row 2: retrieved_at is after review cutoff" in rendered + assert "- row 1: required=revenue_consensus; missing=none; ready=true; blockers=none" in rendered + assert "auto_apply: false" in rendered + assert "collection preview remains a separate reviewed gate" in rendered +``` + +- [ ] **Step 2: Run the new tests and require the intended red state** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest \ + tests/test_earnings_consensus_sources.py::test_load_source_review_csv_preserves_rows_without_writing \ + tests/test_earnings_consensus_sources.py::test_load_source_review_csv_rejects_ambiguous_shapes \ + tests/test_earnings_consensus_sources.py::test_load_source_review_csv_reports_unreadable_input \ + tests/test_earnings_consensus_sources.py::test_load_source_review_csv_accepts_header_only_as_empty_review \ + tests/test_earnings_consensus_sources.py::test_render_source_validation_result_keeps_technical_and_commercial_evidence_separate -q +``` + +Expected: collection fails because `load_source_review_csv` and `render_source_validation_result` are not defined. + +- [ ] **Step 3: Implement strict read-only CSV loading** + +Import `csv`, then add: + +```python +def load_source_review_csv(path: Path | str) -> tuple[dict[str, object], ...]: + """Load supplied consensus source rows without normalizing or writing evidence.""" + + review_path = Path(path) + try: + with review_path.open("r", encoding="utf-8", newline="") as handle: + reader = csv.DictReader(handle, strict=True) + if reader.fieldnames is None: + raise ValueError("consensus source review CSV must contain a header row") + fieldnames = tuple(str(field or "") for field in reader.fieldnames) + if ( + any(not field.strip() for field in fieldnames) + or len(set(fieldnames)) != len(fieldnames) + ): + raise ValueError( + "consensus source review CSV headers must be non-blank unique column names" + ) + rows: list[dict[str, object]] = [] + for row_number, row in enumerate(reader, start=1): + if None in row: + raise ValueError( + f"consensus source review CSV row {row_number} has more values than the header" + ) + rows.append({str(key): value for key, value in row.items()}) + return tuple(rows) + except ValueError: + raise + except (OSError, UnicodeError, csv.Error) as exc: + raise ValueError( + f"cannot read consensus source review CSV: {review_path}" + ) from exc +``` + +Do not strip or rename headers and do not coerce cells. Core validation must expose missing exact fields. + +- [ ] **Step 4: Implement deterministic human rendering** + +Add: + +```python +def _joined(values: Sequence[object]) -> str: + return ",".join(str(value) for value in values) if values else "none" + + +def render_source_validation_result(result: SourceValidationResult) -> str: + lines = [ + "Consensus Source Review", + "Read-only: this command does not fetch, normalize, record, apply, rebuild readiness, or write artifacts.", + f"provider: {result.provider or '-'}", + f"review_cutoff: {result.review_cutoff}", + f"state: {result.state}", + f"accepted_count: {result.accepted_count}", + f"rejected_count: {result.rejected_count}", + f"historical_snapshot_count: {result.historical_snapshot_count}", + f"candidate_context_count: {result.candidate_context_count}", + f"rights_status: {result.rights_status}", + f"commercial_rights_approved: {str(result.commercial_rights_approved).lower()}", + f"commercial_ready_count: {result.commercial_ready_count}", + f"commercial_review_required_count: {result.commercial_review_required_count}", + f"commercial_evidence_ready: {str(result.commercial_evidence_ready).lower()}", + f"commercial_blockers: {_joined(result.commercial_blockers)}", + "rejected_rows:", + ] + if result.rejected_rows: + lines.extend( + f"- row {row['row_number']}: {row['reason']}" + for row in result.rejected_rows + ) + else: + lines.append("- none") + lines.append("commercial_review_rows:") + if result.commercial_review_rows: + lines.extend( + "- row " + f"{row.row_number}: required={_joined(row.required_supported_fields)}; " + f"missing={_joined(row.missing_supported_fields)}; " + f"ready={str(row.commercial_evidence_ready).lower()}; " + f"blockers={_joined(row.commercial_blockers)}" + for row in result.commercial_review_rows + ) + else: + lines.append("- none") + lines.extend( + [ + f"auto_apply: {str(result.auto_apply).lower()}", + "next_gate: collection preview remains a separate reviewed gate after the payload and evidence are accepted.", + "Boundary: reviewability is not collection, activation, readiness, backtesting, calibration, or investment advice.", + ] + ) + return "\n".join(lines) +``` + +- [ ] **Step 5: Run the full source test module** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests/test_earnings_consensus_sources.py -q +``` + +Expected: all tests pass; no repository file changes beyond the two intentional source/test files. + +- [ ] **Step 6: Review Task 1 diff** + +Run `git diff --check` and inspect the source/test diff. Confirm no status or validator decision changed and no generated file exists. + +--- + +### Task 2: Expose explicit CLI and Make review modes + +**Files:** +- Modify: `src/earnings_consensus_sources.py` +- Modify: `Makefile` +- Test: `tests/test_earnings_consensus_sources.py` +- Test: `tests/test_launchers.py` + +**Interfaces:** +- Consumes: `load_source_review_csv(...)`, `validate_source_rows(...)`, and `render_source_validation_result(...)` from Task 1. +- Produces: `python3 -m src.earnings_consensus_sources --review-csv PATH --provider SOURCE --as-of TIMESTAMP [--json]`. +- Produces: `make earnings-consensus-source-review INPUT=... PROVIDER=... AS_OF=... [JSON=1]`. + +- [ ] **Step 1: Add failing CLI and Make contract tests** + +Add `json`, `os`, `subprocess`, and `sys` imports to `tests/test_earnings_consensus_sources.py`, then add the exact test-only CSV constant and tests below. + +Add this exact test-only constant: + +```python +HISTORICAL_REVIEW_CSV = ( + "ticker,fiscal_period,snapshot_at,retrieved_at,source_ref,revenue_consensus,eps_consensus,history_scope," + "revenue_currency,revenue_unit_scale,revenue_basis,eps_currency,eps_basis,eps_share_basis,eps_operations_basis,split_adjustment_basis\n" + "NVDA,2027-Q1,2026-07-18T05:00:00Z,2026-07-18T05:00:01Z,fixture://consensus/NVDA/2027-Q1,1,,point_in_time," + "USD,1,reported,USD,gaap,diluted,reported,as_reported\n" +) +``` + +```python +def test_source_review_cli_renders_supplied_rows_without_artifacts(tmp_path: Path): + input_path = tmp_path / "reviewed.csv" + input_path.write_text(HISTORICAL_REVIEW_CSV, encoding="utf-8") + before = input_path.read_bytes() + + result = subprocess.run( + [ + sys.executable, + "-m", + "src.earnings_consensus_sources", + "--review-csv", + str(input_path), + "--provider", + "sec_companyfacts", + "--as-of", + REVIEW_CUTOFF, + ], + check=False, + capture_output=True, + text=True, + env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"}, + ) + + assert result.returncode == 0 + assert "state: historical_evidence_reviewable" in result.stdout + assert "rights_status: approved" in result.stdout + assert "registered_consensus_scope_missing:revenue_consensus" in result.stdout + assert input_path.read_bytes() == before + assert {path.name for path in tmp_path.iterdir()} == {"reviewed.csv"} + + +def test_source_review_cli_json_matches_result_contract(tmp_path: Path): + input_path = tmp_path / "reviewed.csv" + input_path.write_text(HISTORICAL_REVIEW_CSV, encoding="utf-8") + + result = subprocess.run( + [ + sys.executable, + "-m", + "src.earnings_consensus_sources", + "--review-csv", + str(input_path), + "--provider", + "sec_companyfacts", + "--as-of", + REVIEW_CUTOFF, + "--json", + ], + check=False, + capture_output=True, + text=True, + env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"}, + ) + + payload = json.loads(result.stdout) + assert result.returncode == 0 + assert payload["provider"] == "sec_companyfacts" + assert payload["review_cutoff"] == REVIEW_CUTOFF + assert payload["historical_snapshot_count"] == 1 + assert payload["commercial_rights_approved"] is True + assert payload["commercial_evidence_ready"] is False + assert payload["auto_apply"] is False + + +@pytest.mark.parametrize( + "args", + [ + ["--review-csv", "missing.csv", "--as-of", REVIEW_CUTOFF], + ["--review-csv", "missing.csv", "--provider", "reviewed_csv"], + ["--provider", "reviewed_csv", "--as-of", REVIEW_CUTOFF], + [ + "--review-csv", + "missing.csv", + "--reviewed-csv", + "other.csv", + "--provider", + "reviewed_csv", + "--as-of", + REVIEW_CUTOFF, + ], + ], +) +def test_source_review_cli_requires_complete_review_mode(args: list[str]): + result = subprocess.run( + [sys.executable, "-m", "src.earnings_consensus_sources", *args], + check=False, + capture_output=True, + text=True, + env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"}, + ) + + assert result.returncode != 0 + assert "error:" in result.stderr + + +def test_source_review_cli_rejects_invalid_cutoff(tmp_path: Path): + input_path = tmp_path / "reviewed.csv" + input_path.write_text("ticker,fiscal_period\n", encoding="utf-8") + + result = subprocess.run( + [ + sys.executable, + "-m", + "src.earnings_consensus_sources", + "--review-csv", + str(input_path), + "--provider", + "reviewed_csv", + "--as-of", + "not-a-cutoff", + ], + check=False, + capture_output=True, + text=True, + env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"}, + ) + + assert result.returncode != 0 + assert "review cutoff" in result.stderr +``` + +In `tests/test_launchers.py`, add: + +```python +def test_makefile_exposes_bytecode_free_consensus_source_review_target(): + makefile = Path("Makefile").read_text(encoding="utf-8") + + assert "earnings-consensus-source-review:" in makefile + assert "PYTHONDONTWRITEBYTECODE=1 python3 -m src.earnings_consensus_sources" in makefile + assert '--review-csv "$(INPUT)"' in makefile + assert '--provider "$(PROVIDER)"' in makefile + assert '--as-of "$(AS_OF)"' in makefile + assert "$(if $(JSON),--json,)" in makefile +``` + +- [ ] **Step 2: Run the new CLI/Make tests and require failure** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest \ + tests/test_earnings_consensus_sources.py \ + tests/test_launchers.py \ + -k "source_review_cli or makefile_exposes_bytecode_free_consensus_source_review_target" -q +``` + +Expected: CLI tests fail because the review arguments are unknown and the Make target assertion fails. + +- [ ] **Step 3: Extend CLI argument routing without changing status mode** + +Add arguments: + +```python +parser.add_argument("--review-csv", type=Path) +parser.add_argument("--provider") +parser.add_argument("--as-of") +``` + +Before the existing status rendering, add: + +```python +if args.review_csv is not None: + if args.reviewed_csv is not None: + parser.error("--review-csv cannot be combined with --reviewed-csv") + if not str(args.provider or "").strip(): + parser.error("--provider is required with --review-csv") + if not str(args.as_of or "").strip(): + parser.error("--as-of is required with --review-csv") + try: + result = validate_source_rows( + args.provider, + load_source_review_csv(args.review_csv), + as_of=args.as_of, + ) + except ValueError as exc: + parser.error(str(exc)) + if args.json: + print(json.dumps(asdict(result), indent=2, sort_keys=True)) + else: + print(render_source_validation_result(result)) + return 0 + +if args.provider is not None or args.as_of is not None: + parser.error("--provider and --as-of require --review-csv") +``` + +Leave the existing `consensus_source_statuses(...)` branch and its JSON/list shape unchanged. + +- [ ] **Step 4: Add the Make target** + +Add `earnings-consensus-source-review` to the existing `.PHONY` line and add after status: + +```make +earnings-consensus-source-review: + @PYTHONDONTWRITEBYTECODE=1 python3 -m src.earnings_consensus_sources --review-csv "$(INPUT)" --provider "$(PROVIDER)" --as-of "$(AS_OF)" $(if $(JSON),--json,) +``` + +- [ ] **Step 5: Run focused CLI, launcher, and existing-status tests** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest \ + tests/test_earnings_consensus_sources.py \ + tests/test_launchers.py -q +``` + +Expected: all tests pass and existing status tests remain unchanged. + +- [ ] **Step 6: Run a temporary-directory CLI smoke** + +Use `mktemp -d`, copy a test-only CSV into it, run the Python review command with `PYTHONDONTWRITEBYTECODE=1`, inspect the output, and confirm the directory still contains only the input. Do not place the fixture under `data/`, `outputs/`, or the repository. + +--- + +### Task 3: Document the Stage 2 review gate and release the slice + +**Files:** +- Modify: `ROADMAP.md` +- Modify: `docs/DATA_STRATEGY.md` +- Modify: `docs/EARNINGS_NOWCAST_PILOT.md` +- Modify: `docs/METHODOLOGY.md` +- Modify: `docs/PROVENANCE_CONTRACT.md` +- Modify: `docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md` +- Modify: `tests/test_public_v1_release_docs.py` + +**Interfaces:** +- Consumes: the verified command and Make target from Task 2. +- Produces: roadmap item 34, Stage 2 command ordering, design/plan lineage anchor, and truthful review boundaries. + +- [ ] **Step 1: Update roadmap and methodology documentation** + +Add roadmap item 34: one supplied consensus CSV can now enter a read-only, explicit-provider, explicit-cutoff source review before collection preview. State that it improves Stage 2 operating reliability and rejection visibility but supplies no source, rights, payload proof, collection, readiness, backtesting, calibration, hosting, reviewers, or market validation. + +Document the command sequence: + +```bash +SOURCE_INPUT= +make earnings-consensus-source-review INPUT=$SOURCE_INPUT PROVIDER= AS_OF= +COLLECTION_INPUT= +make earnings-consensus-collection-preview INPUT=$COLLECTION_INPUT AS_OF= +``` + +Clarify that these are distinct input contracts: the second command is appropriate only after separate payload/evidence review and explicit evidence-preserving mapping into the checked-in prospective schema. No command infers or writes that mapping, and neither gate writes without the explicit later record command. + +- [ ] **Step 2: Update data strategy, pilot, provenance, and continuation contract** + +Record: + +- strict CSV header/shape checks; +- original one-based rejected row numbers; +- explicit provider and cutoff; +- human/JSON stdout only; +- `auto_apply=false`; +- checked-in registry evidence remains metadata, not payload permission; +- no default provider, cutoff, collection, or readiness promotion; +- current consensus source/data/right classifications remain external and unchanged. + +Add design/plan lineage anchor `6aa9c0c44` or the later plan commit, the capability bullet, truthful boundary, and Stage 2 source-review-before-preview instruction to the continuation prompt. + +- [ ] **Step 3: Add a failing documentation contract test, then make it pass** + +Add to `tests/test_public_v1_release_docs.py`: + +```python +def test_consensus_source_review_docs_keep_review_collection_and_activation_separate(): + roadmap = _read("ROADMAP.md") + data_strategy = _read("docs/DATA_STRATEGY.md") + pilot = _read("docs/EARNINGS_NOWCAST_PILOT.md") + methodology = _read("docs/METHODOLOGY.md") + provenance = _read("docs/PROVENANCE_CONTRACT.md") + prompt = _read("docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md") + + for text in (roadmap, data_strategy, pilot, methodology, provenance, prompt): + assert "earnings-consensus-source-review" in text + assert "collection preview" in text.lower() + assert "read-only" in text.lower() + assert "explicit provider" in roadmap.lower() + assert "original one-based" in provenance.lower() + assert "auto_apply=false" in pilot + assert "source-review-before-preview" in prompt.lower() +``` + +Run this exact test before the docs edit and require failure, then rerun it after the six documentation changes and require a pass. + +- [ ] **Step 4: Run focused and full verification** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest \ + tests/test_earnings_consensus_sources.py \ + tests/test_launchers.py \ + tests/test_public_v1_release_docs.py -q +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests -q +``` + +Expected: zero failures; the existing third-party dateutil warning may remain. + +- [ ] **Step 5: Run all required non-writing product gates** + +Run: + +```bash +make dashboard-smoke +make research-dashboard-render-smoke +make public-wording-check +make public-check +make commercial-beta-check +make commercial-beta-release-check +make pilot-readiness-check TOP_N=10 +make diff-hygiene-summary +git diff --check +``` + +Expected: executable gates pass, pilot readiness remains blocked by stale saved readiness, and generated churn remains zero. + +- [ ] **Step 6: Stage exact files and verify hygiene** + +Stage only the source module, Makefile, the two or three named test files actually changed, the six named product documents, and this plan if corrected during execution. Run: + +```bash +make staged-hygiene-check +git diff --cached --check +``` + +Expected: only intentional product/code/docs/test files; zero generated or manual-review paths. + +- [ ] **Step 7: Commit, push, and update draft PR #113** + +Commit the implementation/docs slice with message `Add consensus source review command`. Push only `codex/personal-research-mode-mvp`. Post the exact command, red-green evidence, full gate results, no-write proof, unchanged external classifications, and next external unblock to PR #113. Keep it draft. + +- [ ] **Step 8: Re-audit the handoff** + +Verify clean status, 0/0 upstream alignment, open/draft/mergeable PR state, generated-artifact hygiene, stale readiness, and active overall goal. Do not merge or deploy. diff --git a/docs/superpowers/plans/2026-07-19-consensus-source-rights-guard.md b/docs/superpowers/plans/2026-07-19-consensus-source-rights-guard.md new file mode 100644 index 00000000..64f2468b --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-consensus-source-rights-guard.md @@ -0,0 +1,79 @@ +# Prospective Consensus Source-Rights Guard 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. + +**Goal:** Expose independent Revenue/EPS commercial source-rights evidence in prospective consensus preview and block unapproved ledger writes only in explicit Commercial Research mode. + +**Architecture:** Reuse the immutable commercial source-rights registry and exact-source eligibility decision. Add a pure preview helper that derives required metric scopes from the populated record, keep technical `write_allowed` separate, and make append enforce the combined evidence gate before filesystem mutation when commercial mode is enabled. + +**Constraints:** Use temporary pytest paths only; do not collect provider data, run repository record/apply/readiness commands, edit source rights, infer providers, or generate/stage CSV, JSON, report, sample-report, screenshot, or timing churn. + +## Task 1: Add failing preview evidence tests + +**Files:** + +- Modify `tests/test_earnings_consensus_collector.py` + +- [ ] Build injected immutable rights fixtures for approved, unverified, and scope-limited sources. +- [ ] Assert approved Revenue and EPS scopes produce independent, deterministic preview evidence. +- [ ] Assert a Revenue-only record does not require EPS scope. +- [ ] Assert a mixed record reports only its missing metric scope. +- [ ] Assert unknown and composite exact sources fail closed without provider inference. +- [ ] Run the focused collector tests and confirm the new contract fails before implementation. + +## Task 2: Add failing commercial append tests + +**Files:** + +- Modify `tests/test_earnings_consensus_collector.py` + +- [ ] Assert research mode preserves reviewed local append compatibility. +- [ ] Assert commercial mode blocks an unregistered row before ledger or parent-directory creation. +- [ ] Assert an approved, fully scoped source can append to a temporary ledger in commercial mode. +- [ ] Assert technical rejection remains independently authoritative. +- [ ] Run the focused collector tests and confirm the guard assertions fail before implementation. + +## Task 3: Implement preview and pre-write guard + +**Files:** + +- Modify `src/earnings_consensus_collector.py` + +- [ ] Import the existing mode, registry, and eligibility contracts. +- [ ] Derive ordered required fields from populated Revenue/EPS values. +- [ ] Add exact-source rights, supported-scope, evidence-ready, combined-write, and blocker fields to `CollectionPreview`. +- [ ] Preserve existing technical preview states and `write_allowed` meaning. +- [ ] Add optional mode and registry injection to append. +- [ ] Use one registry decision for preview and append. +- [ ] Reject a commercial evidence failure before directory creation or ledger mutation. +- [ ] Keep CLI preview non-writing and make the record command inherit the environment gate. +- [ ] Run the focused collector and source-rights tests and require them to pass. + +## Task 4: Durable research and operating contracts + +**Files:** + +- Modify `ROADMAP.md` +- Modify `docs/EARNINGS_NOWCAST_PILOT.md` +- Modify `docs/DATA_STRATEGY.md` +- Modify `docs/METHODOLOGY.md` +- Modify `docs/PROVENANCE_CONTRACT.md` +- Modify `docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md` + +- [ ] Record the independent Revenue/EPS scope states and explicit commercial pre-write guard. +- [ ] Preserve the research-only append path and all actuals, Q4, split-basis, calibration, synthetic-fixture, and candidate-context boundaries. +- [ ] Record that the checked-in registry still has no approved prospective-consensus source or scope. +- [ ] Record that this local guard is not provider, hosted, reviewer, data-depth, calibration, or market evidence. +- [ ] Update the continuation prompt with the new commit anchor after verified implementation. + +## Task 5: Full verification and delivery + +- [ ] Run focused collector/source-rights tests. +- [ ] Run `python3 -m pytest tests -q`. +- [ ] Run dashboard smoke and all six Research-route render smokes. +- [ ] Run public wording, public, commercial beta/release, pilot readiness, diff hygiene, and whitespace checks. +- [ ] Verify zero generated churn and no repository consensus write or readiness rebuild. +- [ ] Stage exact intentional files only and pass staged hygiene. +- [ ] Commit and push only `codex/personal-research-mode-mvp`. +- [ ] Update draft PR #113 and verify it remains open and draft. +- [ ] Reassess the next executable gate without claiming source, hosted, reviewer, calibration, operating, or market completion. diff --git a/docs/superpowers/plans/2026-07-19-consensus-source-temporal-integrity.md b/docs/superpowers/plans/2026-07-19-consensus-source-temporal-integrity.md new file mode 100644 index 00000000..f2851f70 --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-consensus-source-temporal-integrity.md @@ -0,0 +1,216 @@ +# Consensus Source Temporal Integrity 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:** Require an explicit review cutoff, valid history scope, and ordered source timestamps before consensus rows can become candidate or historical-reviewable evidence. + +**Architecture:** Parse one mandatory cutoff before row processing, keep one ordered row-validation path, and add the normalized cutoff to the immutable result. Temporal failures remain technical rejections; exact-source rights and Revenue/EPS scope are calculated only for technically accepted rows and remain independent. + +**Tech Stack:** Python 3.12, frozen dataclasses, existing UTC timestamp parser, pytest, Make product gates, Git/GitHub draft PR workflow. + +## Global Constraints + +- Research-only; no investment advice, broker integration, order routing, auto-trading, direct buy/sell instructions, or post-earnings price prediction. +- Every accepted source row must prove `snapshot_at <= retrieved_at <= review_cutoff`. +- `history_scope` must be exactly `current_only` or `point_in_time`; unknown values cannot become candidate context. +- Keep technical acceptance, source rights, Revenue scope, EPS scope, historical availability, collection, nowcast readiness, backtesting, and calibration independent. +- Candidate context cannot modify deterministic forecasts or become trusted evidence. +- Do not run `make readiness` or create/stage CSV, JSON, report, sample-report, screenshot, browser timing, or bytecode churn. +- Stage exact intentional product/code/docs/test files only; never use `git add -A`. + +--- + +### Task 1: Mandatory source-row temporal contract + +**Files:** +- Modify: `tests/test_earnings_consensus_sources.py` +- Modify: `src/earnings_consensus_sources.py` + +**Interfaces:** +- Consumes: `parse_utc_timestamp(value, *, label) -> datetime` and the existing registry-derived commercial-review contract. +- Produces: `validate_source_rows(provider, rows, *, as_of, rights_registry=None) -> SourceValidationResult` with normalized `review_cutoff`. + +- [ ] **Step 1: Update valid test calls to use one explicit cutoff** + +Add: + +```python +REVIEW_CUTOFF = "2026-07-18T06:00:00Z" +``` + +Pass `as_of=REVIEW_CUTOFF` to every valid or row-focused validator call. Keep the caller-rights-label signature test focused on `rights_status` by also supplying `as_of`. + +- [ ] **Step 2: Write failing cutoff contract tests** + +Assert that a valid result exposes `review_cutoff == REVIEW_CUTOFF`, and invalid cutoff input raises before reviewing rows: + +```python +with pytest.raises(ValueError, match="review cutoff"): + validate_source_rows("reviewed_csv", (), as_of="not-a-cutoff") +``` + +- [ ] **Step 3: Write failing explicit-scope tests** + +Parametrize missing and unknown values and assert rejection, zero accepted rows, `still_blocked`, and no commercial-review rows: + +```python +@pytest.mark.parametrize("scope", ["", "historical", "latest"]) +def test_source_rows_require_an_explicit_supported_history_scope(scope): + result = validate_source_rows( + "licensed_consensus", + [_current_row(history_scope=scope)], + as_of=REVIEW_CUTOFF, + rights_registry=_rights_registry(), + ) + assert "history_scope must be current_only or point_in_time" in result.rejected_rows[0]["reason"] + assert result.commercial_review_rows == () +``` + +- [ ] **Step 4: Write failing ordering and cutoff tests** + +Add separate tests for snapshot after retrieval, snapshot after cutoff, and retrieval after cutoff. Assert the field-specific technical reason and that approved source rights remain source-level evidence while no row becomes commercially ready. + +```python +result = validate_source_rows( + "licensed_consensus", + [_current_row(snapshot_at="2026-07-18T05:00:02Z")], + as_of=REVIEW_CUTOFF, + rights_registry=_rights_registry(), +) +assert "snapshot_at cannot be after retrieved_at" in result.rejected_rows[0]["reason"] +assert result.commercial_rights_approved is True +assert result.commercial_ready_count == 0 +``` + +- [ ] **Step 5: Write failing equality and mixed-batch tests** + +Prove equality at retrieval/cutoff is allowed. In a two-row batch with the first row after cutoff and the second valid, assert one rejection, one accepted candidate, and `commercial_review_rows[0].row_number == 2`. + +- [ ] **Step 6: Run the focused tests and verify RED** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests/test_earnings_consensus_sources.py -q +``` + +Expected: failures because `as_of` is not accepted, `review_cutoff` is absent, invalid scopes are still candidates, and reversed/future timestamps are accepted. + +- [ ] **Step 7: Add the normalized cutoff to the result** + +Add `review_cutoff: str` to `SourceValidationResult`. Change the signature to require `as_of`, parse it once, and normalize UTC: + +```python +cutoff = parse_utc_timestamp(as_of, label="review cutoff") +review_cutoff = cutoff.isoformat().replace("+00:00", "Z") +``` + +Return `review_cutoff` for every result, including a result whose rows are all rejected. + +- [ ] **Step 8: Implement explicit scope and row-time validation** + +Before `ConsensusSnapshot` construction, reject an unsupported scope. Parse the two row timestamps into a dictionary only when present; after successful parsing enforce ordering and cutoff: + +```python +if scope not in {"current_only", "point_in_time"}: + reasons.append("history_scope must be current_only or point_in_time") + +parsed_timestamps = {} +for timestamp in ("snapshot_at", "retrieved_at"): + if str(row.get(timestamp) or "").strip(): + try: + parsed_timestamps[timestamp] = parse_utc_timestamp(row[timestamp], label=timestamp) + except ValueError as exc: + reasons.append(str(exc)) + +snapshot_at = parsed_timestamps.get("snapshot_at") +retrieved_at = parsed_timestamps.get("retrieved_at") +if snapshot_at is not None and retrieved_at is not None and snapshot_at > retrieved_at: + reasons.append("snapshot_at cannot be after retrieved_at") +for field, timestamp in parsed_timestamps.items(): + if timestamp > cutoff: + reasons.append(f"{field} is after review cutoff") +``` + +Do not add a commercial review for any row with temporal or other technical reasons. + +- [ ] **Step 9: Run focused tests and verify GREEN** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests/test_earnings_consensus_sources.py tests/test_earnings_consensus_collector.py -q +``` + +Expected: all source-validator and collector tests pass. + +- [ ] **Step 10: Review the production diff** + +Confirm there is no provider call, file writer, source-rights edit, ledger mutation, readiness mutation, or automatic apply path. Run `git diff --check`. + +### Task 2: Evidence-governance documentation and release verification + +**Files:** +- Modify: `ROADMAP.md` +- Modify: `docs/EARNINGS_NOWCAST_PILOT.md` +- Modify: `docs/DATA_STRATEGY.md` +- Modify: `docs/METHODOLOGY.md` +- Modify: `docs/PROVENANCE_CONTRACT.md` +- Modify: `docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md` + +**Interfaces:** +- Consumes: the verified mandatory-cutoff `SourceValidationResult` contract from Task 1. +- Produces: truthful roadmap, method, provenance, pilot, continuation, and draft-PR evidence for the temporal boundary. + +- [ ] **Step 1: Add the implemented roadmap item and maturity boundary** + +Add item 32 stating that source validation requires explicit scope and `snapshot_at <= retrieved_at <= review_cutoff`. State that this improves leakage resistance and review reliability but does not supply a provider, real snapshot, source rights, history depth, readiness, calibration, reviewers, or market validation. + +- [ ] **Step 2: Update pilot, strategy, method, and provenance contracts** + +Document that both candidate and historical source rows require the same cutoff truth; invalid scopes and reversed/future timestamps are technical rejections; only accepted rows enter commercial review. Make clear that cutoff passage does not prove publication availability, payload correctness, rights, freshness, collection, activation, backtesting, or calibration. + +- [ ] **Step 3: Update the continuation contract** + +Add the committed design/plan lineage anchor, implemented capability, truthful boundary, and Stage 2 review instruction. Preserve the stale-readiness no-write instruction and all external dependency classifications. + +- [ ] **Step 4: Run focused and full verification** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests/test_earnings_consensus_sources.py tests/test_earnings_consensus_collector.py -q +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests -q +``` + +Expected: zero failures; the existing third-party `dateutil` deprecation warning may remain. + +- [ ] **Step 5: Run every required non-writing product gate** + +Run: + +```bash +make dashboard-smoke +make research-dashboard-render-smoke +make public-wording-check +make public-check +make commercial-beta-check +make commercial-beta-release-check +make pilot-readiness-check TOP_N=10 +make diff-hygiene-summary +git diff --check +``` + +Expected: executable gates pass; pilot readiness still reports stale saved readiness; no readiness build or generated artifact appears. + +- [ ] **Step 6: Stage exact files and verify hygiene** + +Stage only the source module, its test, the six named product documents, and this plan if corrected during execution. Run `make staged-hygiene-check` and `git diff --cached --check`. Expected: zero staged generated CSV/JSON/report/sample-report/screenshot/timing churn. + +- [ ] **Step 7: Commit, push, and update draft PR #113** + +Commit with message `Enforce consensus source review cutoff`, push only `codex/personal-research-mode-mvp`, confirm 0/0 remote alignment, and post verified scope, red-green evidence, full gates, truthful boundaries, generated-artifact exclusion, unchanged dependencies, and the next executable step to PR #113. Keep it open and draft. + +- [ ] **Step 8: Re-audit the handoff state** + +Verify clean status, pushed HEAD, draft PR state, artifact hygiene, stale readiness, review safety, remaining stage gates, and that the overall goal remains active. diff --git a/docs/superpowers/plans/2026-07-19-consensus-source-validation-rights.md b/docs/superpowers/plans/2026-07-19-consensus-source-validation-rights.md new file mode 100644 index 00000000..e2b7121b --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-consensus-source-validation-rights.md @@ -0,0 +1,219 @@ +# Consensus Source Validation Rights 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:** Replace caller-declared consensus source rights with exact registry-derived commercial rights and independent Revenue/EPS scope evidence while preserving technically valid research review. + +**Architecture:** Keep schema/comparability validation in `earnings_consensus_sources.py`, inject or load the existing immutable source-rights registry, and add immutable row-level commercial reviews plus aggregate counts to the result. Commercial evidence never changes technical acceptance, candidate-context classification, or the non-writing boundary. + +**Tech Stack:** Python 3.12, frozen dataclasses, PyYAML-backed `commercial_source_rights`, pytest, Make product gates, Git/GitHub draft PR workflow. + +## Global Constraints + +- Research-only; no investment advice, broker integration, order routing, auto-trading, direct buy/sell instructions, or post-earnings price prediction. +- Do not add, infer, or approve a provider, entitlement, source-rights record, consensus value, source, timestamp, or recommendation. +- Candidate context cannot modify deterministic forecasts or become trusted evidence. +- Keep technical acceptance, historical availability, exact-source rights, Revenue scope, EPS scope, nowcast readiness, backtesting, and calibration independent. +- Historical rows are reviewable evidence only; they are not activated evidence or nowcast readiness. +- Do not run `make readiness` or create/stage CSV, JSON, report, sample-report, screenshot, browser timing, or bytecode churn. +- Stage exact intentional product/code/docs/test files only; never use `git add -A`. + +--- + +### Task 1: Registry-derived validator contract + +**Files:** +- Modify: `tests/test_earnings_consensus_sources.py` +- Modify: `src/earnings_consensus_sources.py` + +**Interfaces:** +- Consumes: `commercial_eligibility(registry: Mapping[str, SourceRights], source_id: str) -> CommercialEligibility` and `load_source_rights_registry() -> Mapping[str, SourceRights]`. +- Produces: `SourceCommercialReview` and `validate_source_rows(provider, rows, *, rights_registry=None) -> SourceValidationResult` with registry-derived aggregate evidence. + +- [ ] **Step 1: Add test-only exact-source rights fixtures** + +Add a `_rights_registry` helper using `build_source_rights_registry` with explicit `source_id`, `commercial_use`, and `supported_fields`. Add `_current_row` and `_historical_row` helpers that populate real validator fields but use only synthetic values and references. + +```python +def _rights_registry(*, source_id="licensed_consensus", commercial_use="approved", supported_fields=None): + return build_source_rights_registry( + [{ + "source_id": source_id, + "display_name": "Licensed consensus fixture", + "permitted_use": "test_only", + "commercial_use": commercial_use, + "redistribution": "test_only", + "storage_limits": "temporary in-memory tests only", + "attribution": "fixture", + "rate_limits": "not applicable", + "authentication": "not applicable", + "expected_freshness": "fixture cutoff", + "supported_fields": supported_fields or ["revenue_consensus", "eps_consensus"], + "fallback_priority": 1, + }] + ) +``` + +- [ ] **Step 2: Write failing tests for default fail-closed evidence and removed caller approval** + +Update existing calls to omit `rights_status`. Assert that a technically valid Alpha Vantage current-only row remains accepted candidate context while its exact source is unknown, both populated scopes are missing, `commercial_ready_count == 0`, and `auto_apply is False`. Add a test proving `rights_status=` is no longer accepted by the function signature. + +```python +assert result.state == "candidate_context_only" +assert result.rights_status == "unknown_source" +assert result.commercial_review_rows[0].missing_supported_fields == ("revenue_consensus", "eps_consensus") +assert result.commercial_evidence_ready is False +with pytest.raises(TypeError, match="rights_status"): + validate_source_rows("alpha_vantage", (), rights_status="approved_for_project_use") +``` + +- [ ] **Step 3: Write failing tests for historical state and metric-specific scope** + +Assert that a technically valid point-in-time row is `historical_evidence_reviewable`. Cover approved Revenue-only, EPS-only, and mixed rows; a source approved only for Revenue must report EPS as the sole missing scope for a mixed row. Assert commercial counts and ordered row numbers. + +```python +assert historical.state == "historical_evidence_reviewable" +assert revenue_only.commercial_review_rows[0].required_supported_fields == ("revenue_consensus",) +assert eps_only.commercial_review_rows[0].required_supported_fields == ("eps_consensus",) +assert mixed.commercial_review_rows[0].missing_supported_fields == ("eps_consensus",) +assert mixed.commercial_rights_approved is True +assert mixed.commercial_review_required_count == 1 +``` + +- [ ] **Step 4: Write a failing exact composite-source test** + +Inject a registry that approves `licensed_consensus` and validate a row whose provider is `licensed_consensus + reviewed_csv`. Assert `unknown_source`, missing populated scopes, and no inferred identity. + +- [ ] **Step 5: Run the focused tests and verify RED** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests/test_earnings_consensus_sources.py -q +``` + +Expected: failures because the old function still requires `rights_status`, lacks registry-derived fields, and returns `historical_evidence_ready`. + +- [ ] **Step 6: Add immutable commercial-review result types** + +Import `SourceRights`, `commercial_eligibility`, and `load_source_rights_registry`. Add: + +```python +@dataclass(frozen=True) +class SourceCommercialReview: + row_number: int + required_supported_fields: tuple[str, ...] + missing_supported_fields: tuple[str, ...] + commercial_evidence_ready: bool + commercial_blockers: tuple[str, ...] +``` + +Extend `SourceValidationResult` with `commercial_rights_approved`, `commercial_ready_count`, `commercial_review_required_count`, `commercial_evidence_ready`, `commercial_blockers`, and `commercial_review_rows`. Keep `rights_status` registry-derived and `auto_apply=False`. + +- [ ] **Step 7: Implement independent exact-source commercial evidence** + +Change the function signature to optional injected rights registry, normalize the provider once, and calculate the source decision once. For every technically accepted row, derive populated metric scopes in stable order, exact-record missing scopes, and stable blockers. + +```python +source_id = str(provider).strip().lower() +registry = load_source_rights_registry() if rights_registry is None else rights_registry +rights = commercial_eligibility(registry, source_id) +rights_record = registry.get(source_id) +supported_fields = set(rights_record.supported_fields) if rights_record else set() +required_supported_fields = tuple( + field for field in ("revenue_consensus", "eps_consensus") + if str(row.get(field) or "").strip() +) +missing_supported_fields = tuple( + field for field in required_supported_fields if field not in supported_fields +) +``` + +Technical reasons must no longer include source rights. Count only technically accepted rows in the row-level commercial evidence. Aggregate readiness is true only when at least one row is accepted and every accepted row is commercially ready. Return `historical_evidence_reviewable` when any accepted point-in-time row exists. + +- [ ] **Step 8: Run the focused tests and verify GREEN** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests/test_earnings_consensus_sources.py tests/test_earnings_consensus_collector.py -q +``` + +Expected: all source-validator and collector contract tests pass. + +- [ ] **Step 9: Review the production diff** + +Confirm there is no provider call, file writer, source-rights config change, readiness mutation, automatic apply path, or generated artifact change. Run `git diff --check`. + +### Task 2: Product contract documentation and release verification + +**Files:** +- Modify: `ROADMAP.md` +- Modify: `docs/EARNINGS_NOWCAST_PILOT.md` +- Modify: `docs/DATA_STRATEGY.md` +- Modify: `docs/METHODOLOGY.md` +- Modify: `docs/PROVENANCE_CONTRACT.md` +- Modify: `docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md` + +**Interfaces:** +- Consumes: the verified `SourceValidationResult` evidence contract from Task 1. +- Produces: truthful product-stage, boundary, continuation-anchor, and next-step documentation for draft PR #113. + +- [ ] **Step 1: Document the completed local slice without activation claims** + +Add ROADMAP implemented item 31 and maturity commentary. State that upstream source rows now use exact registry-derived rights and row-specific Revenue/EPS scope while technical acceptance and candidate context remain independent. Explicitly state that the checked-in registry still unlocks no prospective consensus source, no real snapshot was added, and nowcast/calibration readiness did not change. + +- [ ] **Step 2: Update method, provenance, strategy, and pilot guidance** + +Document these exact boundaries: + +- caller labels cannot grant rights; +- invalid technical rows do not enter commercial-ready counts; +- `historical_evidence_reviewable` is not activation; +- composite IDs remain unknown exact sources; +- commercial evidence readiness is not payload approval, collection, history depth, nowcast readiness, backtesting, or calibration. + +- [ ] **Step 3: Update the continuation contract** + +Add the committed design/plan lineage anchor, add the upstream validation capability and its truthful boundary, retain the stale-readiness no-write instruction, and set the exact next executable local lane from the re-audited roadmap. Do not change external dependency classifications without new evidence. + +- [ ] **Step 4: Run focused and full verification** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests/test_earnings_consensus_sources.py tests/test_earnings_consensus_collector.py -q +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests -q +``` + +Expected: zero test failures; the existing third-party `dateutil` deprecation warning may remain. + +- [ ] **Step 5: Run every required non-writing product gate** + +Run: + +```bash +make dashboard-smoke +make research-dashboard-render-smoke +make public-wording-check +make public-check +make commercial-beta-check +make commercial-beta-release-check +make pilot-readiness-check TOP_N=10 +make diff-hygiene-summary +git diff --check +``` + +Expected: all executable gates pass; pilot readiness truthfully reports the known stale saved readiness rather than regenerating it; no generated artifact changes appear. + +- [ ] **Step 6: Stage exact files and run staged hygiene** + +Stage only the source module, its test, and the six named product documents. The design and plan have separate reviewed commits. Run `make staged-hygiene-check` and `git diff --cached --check`. Expected: no staged generated CSV/JSON/report/sample-report/screenshot/timing churn. + +- [ ] **Step 7: Commit, push, and update draft PR #113** + +Commit the coherent implementation/docs slice with message `Derive consensus source validation rights`, push only `codex/personal-research-mode-mvp`, confirm 0/0 remote alignment, and post the verified scope, tests, truthful boundaries, generated-artifact exclusion, external dependency status, and next executable step to PR #113. Keep the PR open and draft. + +- [ ] **Step 8: Re-audit the handoff state** + +Verify clean status, pushed HEAD, draft PR state, generated-artifact hygiene, current external dependency ledger, review safety, and that the overall `/goal` remains active because hosted, reviewer, real-source, evidence-depth, backtesting, calibration, and operating gates remain incomplete or unproven. diff --git a/docs/superpowers/plans/2026-07-19-dcf-price-lineage-review.md b/docs/superpowers/plans/2026-07-19-dcf-price-lineage-review.md new file mode 100644 index 00000000..d70e0f85 --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-dcf-price-lineage-review.md @@ -0,0 +1,118 @@ +# DCF Price Lineage Review Implementation Plan + +> **For Codex:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Extend the existing stdout-only readiness preview with a fail-closed review of the exact latest canonical price row supporting each proposed DCF promotion. + +**Architecture:** Add a pure `src.dcf_price_lineage` module that selects one latest usable row per proposed DCF promotion and independently evaluates selection, lineage, exact-source rights, and registered `prices` support. Integrate its frozen review result into `src.readiness_preview` after the existing in-memory readiness build. Keep the Make target, production readiness, price provider, canonical schema, valuation math, and no-write boundary unchanged. + +**Tech Stack:** Python 3, frozen dataclasses, pandas, PyYAML-backed immutable source-rights registry, pytest, Make. + +**Constraints:** Do not run `make readiness`; do not mutate or migrate canonical price data; do not write or stage CSV, JSON, reports, sample reports, screenshots, timing files, caches, or readiness artifacts; do not infer a provider from file names, adapters, dates, values, or history; do not split composite source identifiers; preserve technical DCF readiness independently from price selection, lineage, rights, registered field scope, reviewer approval, and rebuild authorization. + +## Task 1: Pure DCF price-lineage contract + +**Files:** + +- Create `tests/test_dcf_price_lineage.py` +- Create `src/dcf_price_lineage.py` + +- [ ] Add a test where `AAA` moves `dcf_ready=False -> True`, `BBB` remains ready, and `CCC` only promotes fundamentals; assert only `AAA` enters the DCF price review. +- [ ] Define test price rows where `AAA` has two valid dates and complete latest-row evidence: `source=approved_prices`, `source_ref=https://example.test/prices/AAA/2026-01-03`, and `retrieved_at=2026-01-03T23:00:00Z`. +- [ ] Define an exact rights record with `commercial_use=approved` and `supported_fields=("prices",)`; require `status="price_lineage_review_complete"`. +- [ ] Run `python3 -m pytest tests/test_dcf_price_lineage.py -q` and confirm collection fails because `src.dcf_price_lineage` does not exist. +- [ ] Add frozen `DcfPriceLineageEvidence` and `DcfPriceLineageReview` dataclasses with explicit aggregate counts, capped evidence rows, and `top_n`. +- [ ] Implement `review_dcf_price_lineage(saved, proposed, prices, *, rights_registry, top_n=20)` as a pure function. +- [ ] Normalize ticker text without mutating inputs; identify only false-to-true `dcf_ready` changes. +- [ ] Select valid rows using parsed `date`, numeric positive `close`, maximum date, and exactly one row at that date. +- [ ] Evaluate exact `source`, `source_ref`, and `retrieved_at`; call `commercial_eligibility` with the complete source string; require explicit registered `prices` support. +- [ ] Return `no_dcf_promotions`, `price_lineage_review_complete`, or `price_lineage_review_required` without changing either readiness frame. +- [ ] Run `python3 -m pytest tests/test_dcf_price_lineage.py -q` and require the initial success case to pass. + +## Task 2: Fail-closed edge cases and independent counts + +**Files:** + +- Modify `tests/test_dcf_price_lineage.py` +- Modify `src/dcf_price_lineage.py` + +- [ ] Add failing tests for a missing price row, invalid date, nonpositive close, and duplicate latest-date rows. +- [ ] Add failing tests for independently missing `source`, `source_ref`, and `retrieved_at`. +- [ ] Add cases for an unknown exact source, a composite exact source, unverified commercial rights, and an approved source whose registry omits `prices`. +- [ ] Assert a missing/ambiguous latest row yields rights `not_evaluated_missing_evidence` or `not_evaluated_ambiguous_evidence` rather than evaluating guessed data. +- [ ] Assert `TOP_N=1` caps evidence rows while all aggregate counts retain the full promotion population. +- [ ] Assert `top_n < 1` raises `ValueError`. +- [ ] Implement explicit blockers: `missing_latest_price_row`, `ambiguous_latest_price_row`, `missing_provenance:`, `commercial_rights:`, and `registered_price_scope_incomplete`. +- [ ] Count usable latest rows, ambiguous rows, lineage completeness, approved rights, registered price scope, exact sources, and rights statuses independently. +- [ ] Run `python3 -m pytest tests/test_dcf_price_lineage.py -q` and require all pure-contract tests to pass. + +## Task 3: Integrate the audit into the one no-write preview + +**Files:** + +- Modify `tests/test_readiness_preview.py` +- Modify `src/readiness_preview.py` +- Modify `Makefile` only if its help text needs clarification + +- [ ] Extend the integrated preview fixture with a proposed DCF promotion and a canonical `data/prices.csv` row carrying complete test provenance. +- [ ] Assert `build_ticker_readiness_report(..., write_outputs=False)` remains the only readiness build. +- [ ] Assert the attached price review is independent from `promotion_review` and contains the expected complete result. +- [ ] Add a missing-price-file integration case that produces `price_lineage_review_required` without writing a file. +- [ ] Require rendered output under `DCF Price Lineage Review` to show technical promotions, latest-row selection, lineage, rights, registered price scope, exact sources, rights statuses, and capped evidence rows. +- [ ] Require wording that file origin, observation date, and adapter availability are not provider provenance; the audit changes no readiness state and authorizes no rebuild. +- [ ] Run `python3 -m pytest tests/test_readiness_preview.py tests/test_dcf_price_lineage.py -q` and confirm the integration assertions fail before implementation. +- [ ] Add `dcf_price_lineage_review: DcfPriceLineageReview | None` to `ReadinessImpactPreview`. +- [ ] Load `data/prices.csv` read-only after a saved snapshot exists, normalize columns, and call `review_dcf_price_lineage(...)` with the existing saved/proposed frames and rights registry. +- [ ] Render the independent review after `Promotion Evidence Review`; retain all existing final no-write and research-only boundaries. +- [ ] Run the focused tests and require them to pass. + +## Task 4: Prove no-write behavior on current repository data + +**Files:** + +- Modify `tests/test_readiness_preview.py` only if the integration manifest needs additional coverage + +- [ ] Capture a byte-level manifest for tracked and untracked repository files before the command. +- [ ] Run `PYTHONDONTWRITEBYTECODE=1 make readiness-preview TOP_N=20`. +- [ ] Capture the same manifest afterward and assert it is identical. +- [ ] Record the live aggregate result without calling it current readiness or rebuild approval. +- [ ] Confirm no source provider is inferred for rows whose canonical evidence is absent. +- [ ] Run `git status --short` and verify no CSV, JSON, report, sample-report, screenshot, timing, cache, or bytecode artifact changed. + +## Task 5: Durable methodology and continuation contract + +**Files:** + +- Modify `ROADMAP.md` +- Modify `docs/METHODOLOGY.md` +- Modify `docs/PROVENANCE_CONTRACT.md` +- Modify `docs/DATA_STRATEGY.md` +- Modify `docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md` +- Modify `tests/test_public_v1_release_docs.py` if contract assertions need expansion + +- [ ] Record the exact latest-price selection rule and required row-level `source`, `source_ref`, and `retrieved_at` contract. +- [ ] Record that `as_of_date`, observation date, local file label, file modification time, adapter presence, and refresh warning text do not substitute for row-level provenance. +- [ ] Record independent technical DCF, price selection, lineage, rights, registered field scope, freshness, reviewer, and rebuild states. +- [ ] Add the implementation lineage anchor and current live no-write result to the continuation prompt without turning proposed counts into current product claims. +- [ ] Set the next executable local lane based on the new evidence; do not authorize canonical migration, source-rights edits, or `make readiness` without reviewed proof and explicit write approval. +- [ ] Run `python3 -m pytest tests/test_dcf_price_lineage.py tests/test_readiness_preview.py tests/test_public_v1_release_docs.py -q`. +- [ ] Run `git diff --check` and `make diff-hygiene-summary`. + +## Task 6: Full verification, exact staging, commit, push, and PR evidence + +- [ ] Run `python3 -m pytest tests -q`. +- [ ] Run `make dashboard-smoke`. +- [ ] Run the repository's six Personal Research render smoke targets. +- [ ] Run `make public-wording-check`. +- [ ] Run `make public-check`. +- [ ] Run `make commercial-beta-check`. +- [ ] Run `make commercial-beta-release-check`. +- [ ] Run `make pilot-readiness-check TOP_N=10` and preserve the truthful stale-readiness blocker. +- [ ] Run `make diff-hygiene-summary` and `git diff --check`. +- [ ] Review `git status --short`, `git diff --stat`, and exact changed paths. +- [ ] Stage only the intentional source, test, documentation, and Make paths; never use `git add -A`. +- [ ] Run `make staged-hygiene-check` and `git diff --cached --check`. +- [ ] Commit one coherent implementation/documentation slice. +- [ ] Push only `codex/personal-research-mode-mvp` and verify 0/0 alignment. +- [ ] Add a concise evidence comment to draft PR #113 with live counts, checks, boundaries, external-dependency classification, and exact next step. +- [ ] Verify PR #113 remains open and draft; do not merge or deploy. diff --git a/docs/superpowers/plans/2026-07-19-eps-split-basis-enforcement.md b/docs/superpowers/plans/2026-07-19-eps-split-basis-enforcement.md new file mode 100644 index 00000000..30e24536 --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-eps-split-basis-enforcement.md @@ -0,0 +1,14 @@ +# EPS Split-Basis Enforcement Implementation Plan + +1. Add failing quarterly-trend, focused-cohort, and backtest tests for the + Companyfacts unverified split-basis sentinel. +2. Centralize the sentinel constant in the earnings evidence contract. +3. Filter sentinel EPS observations from Business Trend values/comparisons and + retain an explicit partial or blocked reason without affecting Revenue. +4. Withhold sentinel target/prior-year EPS outcomes from backtest metrics and + exclude events with no comparable target metric. +5. Update methodology, provenance, roadmap, continuation contract, and relevant + UI/coverage contracts. +6. Run focused/full tests and every required product, release, pilot, and hygiene + check; stage exact files, commit, push, update draft PR #113, and verify hosted + CI on the exact pushed head. diff --git a/docs/superpowers/plans/2026-07-19-pr-engineering-legitimacy.md b/docs/superpowers/plans/2026-07-19-pr-engineering-legitimacy.md new file mode 100644 index 00000000..4ebc13a1 --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-pr-engineering-legitimacy.md @@ -0,0 +1,76 @@ +# PR Engineering Legitimacy 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:** Add one minimal pull-request-only GitHub Actions gate that reproduces the approved local engineering checks for PR #113 without data writes or external product claims. + +**Architecture:** A single least-privilege workflow checks out the PR revision, installs the existing Python requirements, and runs six explicit read-only commands. A focused repository contract test prevents future expansion into schedules, secrets, readiness generation, deployment, or artifact upload. + +**Tech Stack:** GitHub Actions YAML, Python 3.12, pytest, GNU Make, Markdown. + +## Global Constraints + +- Trigger only on pull requests targeting `main`. +- Grant only `contents: read` permission. +- Do not use providers, secrets, schedules, deployment, hosted probes, readiness generation, or artifact uploads. +- Do not generate or stage CSV, JSON, report, sample-report, screenshot, timing, readiness, or canonical-data churn. +- Never use `git add -A`; stage exact intentional paths only. +- Push only to `codex/personal-research-mode-mvp`; keep PR #113 open and draft. + +--- + +### Task 1: Add the minimal PR engineering gate + +**Files:** +- Create: `.github/workflows/commercial-research-beta.yml` +- Create: `tests/test_github_actions_workflow.py` +- Modify: `ROADMAP.md` +- Modify: `docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md` + +**Interfaces:** +- Consumes: the existing `pyproject.toml` package contract and Make targets `dashboard-smoke`, `research-dashboard-render-smoke`, `public-wording-check`, and `diff-hygiene-summary`. +- Produces: one GitHub pull-request status check named `Commercial Research Beta / local-engineering-gate`. + +- [ ] **Step 1: Write the failing workflow contract test** + +Create `tests/test_github_actions_workflow.py` with assertions that the workflow exists, targets pull requests to `main`, uses `contents: read`, selects Python 3.12, installs the checked-in package plus `pytest`, contains every required command, and contains none of the prohibited trigger or action strings. + +- [ ] **Step 2: Run the focused test and verify the intended red state** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests/test_github_actions_workflow.py -q +``` + +Expected: fail because `.github/workflows/commercial-research-beta.yml` does not exist. + +- [ ] **Step 3: Add the minimal workflow** + +Create `.github/workflows/commercial-research-beta.yml` with `pull_request` as its only trigger, `contents: read`, one Ubuntu job, Python 3.12, editable installation of the checked-in package plus `pytest`, and the six approved commands in order. Do not add caching, uploads, provider access, readiness, deployment, or schedules. + +- [ ] **Step 4: Run the focused test and verify green** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests/test_github_actions_workflow.py -q +``` + +Expected: `1 passed`. + +- [ ] **Step 5: Update stage documentation** + +Record the locally implemented CI contract in `ROADMAP.md` and the continuation prompt. State that independent hosted CI evidence is pending until GitHub completes the workflow and that human review remains separately unproven. + +- [ ] **Step 6: Run the complete required verification** + +Run the focused test, full pytest suite, dashboard and research render smokes, public wording and public checks, commercial beta and release checks, pilot readiness, diff hygiene, and whitespace checks. The pilot may truthfully remain blocked on stale readiness. + +- [ ] **Step 7: Stage exact files and verify the package** + +Stage only the workflow, focused test, design, plan, roadmap, and continuation contract. Run `make staged-hygiene-check` and `git diff --cached --check`. + +- [ ] **Step 8: Commit, push, and update the draft PR** + +Commit the coherent slice, push only the current feature branch, update PR #113 with the new scope and verification, and verify it remains open and draft. Do not claim the hosted check passed until GitHub directly reports success. diff --git a/docs/superpowers/plans/2026-07-19-prospective-price-lineage-preservation.md b/docs/superpowers/plans/2026-07-19-prospective-price-lineage-preservation.md new file mode 100644 index 00000000..6ea83762 --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-prospective-price-lineage-preservation.md @@ -0,0 +1,108 @@ +# Prospective Price Lineage Preservation Implementation Plan + +> **For Codex:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Let one prospectively reviewed price row preserve an explicit durable source reference and retrieval timestamp through the existing normalize, validate, preview, and apply contracts without coupling technical OHLCV validity to commercial evidence. + +**Architecture:** Widen the staged/canonical optional price metadata contract in `src.price_import_normalizer` and `src.data_update`. Normalization accepts explicit metadata only and never invents it. Validation normalizes retrieval timestamps and attaches an independent lineage summary to the existing technical result. Preview and later reviewed apply inherit the retained columns through the existing merge path. + +**Tech Stack:** Python 3, pandas, argparse, pytest, Make. + +**Constraints:** Do not run repository `price-normalize`, `price-apply`, `make readiness`, or any source refresh; use only temporary test fixtures; do not create or stage CSV, JSON, reports, sample reports, screenshots, timings, caches, or canonical data; do not generate defaults for `source_ref` or `retrieved_at`; do not make lineage completeness equivalent to rights approval, registered `prices` scope, reviewer approval, freshness, readiness, or apply authorization. + +## Task 1: Normalizer preserves explicit lineage metadata + +**Files:** + +- Modify `tests/test_price_import_normalizer.py` +- Modify `src/price_import_normalizer.py` + +- [ ] Add a failing test that passes `source_ref="https://example.test/prices/NVDA/2026-01-02"` and `retrieved_at="2026-01-03T23:00:00Z"` into `normalize_price_imports(...)` and asserts both exact fields exist in the staged temporary CSV. +- [ ] Add a test that omits both arguments and asserts both columns are present but blank; normalization time, file time, observation date, and `as_of_date` must not appear in either field. +- [ ] Run `python3 -m pytest tests/test_price_import_normalizer.py -q` and confirm the new signature/columns fail before implementation. +- [ ] Add `source_ref` and `retrieved_at` to `STAGED_PRICE_COLUMNS` after `source`. +- [ ] Pass explicit values through `_normalize_one_file(...)` and `normalize_price_imports(...)`; default both to `None`/blank. +- [ ] Add CLI `--source-ref` and `--retrieved-at` arguments and pass them unchanged into normalization. +- [ ] Preserve existing date/ticker/duplicate/invalid-row behavior and existing output ordering. +- [ ] Run the focused normalizer tests and require them to pass. + +## Task 2: Validation reports independent lineage completeness + +**Files:** + +- Modify `tests/test_data_update.py` +- Modify `src/data_update.py` + +- [ ] Extend the temporary import fixture with `source_ref` and `retrieved_at` values for valid rows. +- [ ] Add assertions that `validate_price_imports(...)` retains both fields in `valid_frame` and returns `lineage_status="lineage_complete"`, complete row count 2, review-required row count 0, and no missing lineage fields. +- [ ] Add a separate technically valid fixture with missing source reference and invalid retrieval timestamp; require technical status to remain valid or valid-with-warnings while lineage is `lineage_review_required`. +- [ ] Run the focused validation tests and confirm the summary assertions fail before implementation. +- [ ] Add `source_ref` and `retrieved_at` to optional/output price import columns. +- [ ] Normalize nonblank retrieval timestamps to UTC ISO-8601 and blank invalid values without inventing current time. +- [ ] Compute lineage completeness over retained valid rows using exact nonblank `source`, nonblank `source_ref`, and parseable `retrieved_at`. +- [ ] Return `lineage_status`, `lineage_complete_rows`, `lineage_review_required_rows`, and sorted `lineage_missing_fields` for valid, invalid, and missing-file paths. +- [ ] Add a concise warning when otherwise valid rows require lineage review; do not change the technical row count or reject the row. +- [ ] Run focused data-update tests and require them to pass. + +## Task 3: Preview and temporary apply preserve lineage + +**Files:** + +- Modify `tests/test_data_update.py` +- Modify `src/data_update.py` only if the existing merge logic needs correction + +- [ ] Assert `preview_price_import_merge(...)` returns the same lineage summary as validation and still reports existing new/updated/unchanged counts. +- [ ] In the existing temporary apply test, assert updated and new NVDA rows preserve exact `source_ref` and normalized `retrieved_at` values. +- [ ] Assert the unrelated MSFT canonical row remains present and its missing new fields remain empty. +- [ ] Run `python3 -m pytest tests/test_data_update.py -q` and confirm any preservation gap fails before correction. +- [ ] If needed, widen `PRICE_IMPORT_OUTPUT_COLUMNS` and retain optional columns through canonical reindex/merge; do not change merge keys or backup/no-delete behavior. +- [ ] Run focused normalizer/data-update tests and require them to pass. + +## Task 4: Safe CLI and Make routing + +**Files:** + +- Modify `Makefile` +- Modify `tests/test_launchers.py` +- Modify public or operator docs only where the reviewed command contract is described + +- [ ] Add failing launcher assertions that `price-normalize` conditionally passes quoted `--source-ref "$(SOURCE_REF)"` and `--retrieved-at "$(RETRIEVED_AT)"` only when supplied. +- [ ] Update Make help with one reviewed example containing `SOURCE`, `SOURCE_REF`, and `RETRIEVED_AT`; keep the default example valid for local research. +- [ ] Keep user-supplied values quoted and never echo credentials; the fields are evidence identifiers/timestamps, not secrets. +- [ ] Run focused launcher tests and `make public-wording-check`. + +## Task 5: Durable contracts and current next step + +**Files:** + +- Modify `ROADMAP.md` +- Modify `docs/METHODOLOGY.md` +- Modify `docs/PROVENANCE_CONTRACT.md` +- Modify `docs/DATA_STRATEGY.md` +- Modify `docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md` +- Modify documentation contract tests as needed + +- [ ] Record that the staged path can now preserve prospective lineage but current canonical history remains unchanged and unproven. +- [ ] Record that missing lineage does not invalidate technical local-research rows, while complete lineage still does not establish rights, registered field scope, reviewer approval, or apply authorization. +- [ ] Add the design/plan lineage anchor and exact next external unblock condition to the continuation prompt. +- [ ] Keep the current 146-row DCF audit result unchanged; no new canonical row exists from this code slice. +- [ ] Run focused documentation/launcher/normalizer/data-update tests, `git diff --check`, and `make diff-hygiene-summary`. + +## Task 6: Full verification, exact staging, commit, push, and PR update + +- [ ] Run `python3 -m pytest tests -q`. +- [ ] Run `make dashboard-smoke`. +- [ ] Run `make research-dashboard-render-smoke`. +- [ ] Run `make public-wording-check`. +- [ ] Run `make public-check`. +- [ ] Run `make commercial-beta-check`. +- [ ] Run `make commercial-beta-release-check`. +- [ ] Run `make pilot-readiness-check TOP_N=10` and preserve the stale-readiness blocker. +- [ ] Run `make diff-hygiene-summary` and `git diff --check`. +- [ ] Verify no repository price-normalize/apply or generated-data command ran and no generated artifact changed. +- [ ] Stage only the exact source, test, Make, and documentation paths; never use `git add -A`. +- [ ] Run `make staged-hygiene-check` and `git diff --cached --check`. +- [ ] Commit one coherent implementation/documentation slice. +- [ ] Push only `codex/personal-research-mode-mvp` and verify 0/0 alignment. +- [ ] Update draft PR #113 with verified behavior, test counts, artifact boundary, remaining rights/source requirement, and next executable step. +- [ ] Verify PR #113 remains open and draft; do not merge or deploy. diff --git a/docs/superpowers/plans/2026-07-19-staged-price-rights-scope-review.md b/docs/superpowers/plans/2026-07-19-staged-price-rights-scope-review.md new file mode 100644 index 00000000..138837b8 --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-staged-price-rights-scope-review.md @@ -0,0 +1,66 @@ +# Staged Price Rights And Scope Review Implementation Plan + +> **For Codex:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add independent exact-source commercial-rights and registered `prices` scope review to staged price validation and preview without changing technical OHLCV validity or authorizing apply. + +**Architecture:** Reuse `SourceRights` and `commercial_eligibility` from the checked-in fail-closed registry. Evaluate only technically valid retained rows after normalization, aggregate exact-source decisions, and attach the result to existing validation/preview summaries. Allow explicit registry injection for deterministic tests; production uses the checked-in registry. + +**Constraints:** Do not edit `config/source_rights.yml`; do not infer providers or split composite source strings; do not run repository normalize/apply/readiness/source commands; do not create or stage generated artifacts; do not make rights/scope review change valid-row counts, technical status, lineage state, merge counts, readiness, or apply authorization. + +## Task 1: Specify fail-closed rights and scope states with tests + +**Files:** + +- Modify `tests/test_data_update.py` + +- [ ] Add an injected approved source with `prices` and require approved/complete row counts and exact-source review detail. +- [ ] Add or extend a `yfinance` fixture and require `commercial_rights_unverified` with registered price scope complete. +- [ ] Add unknown and blank source cases and require `unknown_source`, incomplete price scope, and no alias inference. +- [ ] Add a mixed batch and require deterministic mixed states/counts. +- [ ] Assert technically invalid rows do not enter rights/scope counts. +- [ ] Run focused tests and confirm the new summary assertions fail before implementation. + +## Task 2: Implement pure staged-source evidence review + +**Files:** + +- Modify `src/data_update.py` + +- [ ] Import the existing immutable source-rights types and evaluator. +- [ ] Add a pure helper that reviews technically valid rows by exact trimmed source ID. +- [ ] Report independent aggregate states, row counts, status counts, and deterministic distinct-source details. +- [ ] Keep missing/blank source fail-closed and require exact `prices` membership in `supported_fields`. +- [ ] Add concise review-required warnings without changing technical validity. +- [ ] Make `validate_price_imports(...)` accept optional registry injection and default to the checked-in registry. +- [ ] Pass the same registry through `preview_price_import_merge(...)`; preserve all merge results. +- [ ] Run focused data-update tests and require them to pass. + +## Task 3: Durable contracts + +**Files:** + +- Modify `ROADMAP.md` +- Modify `docs/METHODOLOGY.md` +- Modify `docs/PROVENANCE_CONTRACT.md` +- Modify `docs/DATA_STRATEGY.md` +- Modify `docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md` +- Modify documentation contract tests only if required + +- [ ] Record the independent staged rights/scope review and its exact non-activation boundary. +- [ ] Preserve the current 146-row canonical price-lineage audit and external dependency classification. +- [ ] Add the design/plan anchor and next external unblock condition to the continuation prompt. +- [ ] Run focused documentation and public wording checks. + +## Task 4: Full verification and delivery + +- [ ] Run focused changed-module tests. +- [ ] Run `python3 -m pytest tests -q`. +- [ ] Run dashboard smoke and all six research render routes. +- [ ] Run public wording/check, commercial beta/check release, pilot readiness, hygiene, and whitespace checks. +- [ ] Verify readiness remains stale and no repository data command or generated artifact ran. +- [ ] Stage exact product/code/docs/test paths only; never use `git add -A`. +- [ ] Run staged hygiene and cached whitespace checks. +- [ ] Commit one coherent implementation slice and push only `codex/personal-research-mode-mvp`. +- [ ] Update draft PR #113 and verify it remains open and draft. +- [ ] Re-audit remaining safe local tasks; keep the overall goal active while any applicable gate remains unproven. diff --git a/docs/superpowers/plans/2026-07-20-canonical-quarterly-fail-closed.md b/docs/superpowers/plans/2026-07-20-canonical-quarterly-fail-closed.md new file mode 100644 index 00000000..a92b9e2d --- /dev/null +++ b/docs/superpowers/plans/2026-07-20-canonical-quarterly-fail-closed.md @@ -0,0 +1,14 @@ +# Canonical Quarterly Ledger Fail-Closed Plan + +1. Add failing loader-to-packet and dashboard/cohort regression tests for a CSV + containing one accepted and one rejected row. +2. Extend the quarterly trend packet with immutable canonical rejection + evidence and a load-result-aware fail-closed builder. +3. Route both dashboard canonical consumers through that boundary and suppress + accepted-subset cohort derivation when rejection evidence exists. +4. Show row-numbered rejection reasons only under Advanced quarterly evidence. +5. Update methodology, provenance, roadmap, continuation contract, and focused + public/dashboard contract tests. +6. Run focused tests, the full suite, all product/release/pilot gates, PR-range + and generated-artifact hygiene, exact staging checks, commit, push, update + draft PR #113, and verify hosted CI at the exact head. diff --git a/docs/superpowers/plans/2026-07-20-company-workbench-cash-generation-portability.md b/docs/superpowers/plans/2026-07-20-company-workbench-cash-generation-portability.md new file mode 100644 index 00000000..624abcaa --- /dev/null +++ b/docs/superpowers/plans/2026-07-20-company-workbench-cash-generation-portability.md @@ -0,0 +1,572 @@ +# Company Workbench Cash-Generation Portability 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:** Add AMD Q1 FY2026 as the second immutable, explicit, preview-only Company Workbench quarterly cash-generation filing while preserving the existing NVIDIA route and every no-activation boundary. + +**Architecture:** Replace the NVIDIA-only loader constants with a frozen filing specification and a read-only two-entry ticker registry. Keep the loader signature, SEC fetcher, extractor, source-rights review, acceptance contract, preview composition, query gate, and presentation contract unchanged; only the registry may select exact reviewed filing identity. Prove AMD through fixtures, explicit route rendering, one bounded live no-write check, documentation contracts, full local gates, and exact-head GitHub CI. + +**Tech Stack:** Python 3.12, frozen dataclasses, `types.MappingProxyType`, pytest, Streamlit AppTest render smoke, existing SEC Companyfacts/submissions/primary-filing clients, Make release gates, GitHub Actions. + +## Global Constraints + +- Research-only; no investment advice, recommendation, broker integration, order routing, auto-trading, direct buy/sell instruction, or post-earnings price prediction. +- The registry contains exactly `NVDA` and `AMD`; do not add a broad company loop or accept caller-supplied CIK, accession, filing, dates, fiscal period, or cutoff. +- Both previews retain `production_activation=false`, `readiness_promotions=()`, `persistence=false`, complete withholding, and Advanced-only technical lineage. +- Keep Revenue, EPS, operating margin, free cash flow, FCF margin, valuation, catalysts, outcomes, consensus, backtesting, and calibration readiness independent. +- Do not run `make readiness`, refresh broad coverage, persist a source row, or generate/stage CSV, JSON, report, sample-report, screenshot, timing, readiness, canonical-data, or manual-review churn. +- Q4 requires explicit SEC-filed three-month Q4 table evidence and is never derived; this slice uses Q1 only. +- Stage exact intentional paths only; never use `git add -A`. +- Push only `codex/personal-research-mode-mvp`; keep PR #113 open and draft; do not merge or deploy. + +--- + +### Task 1: Immutable two-company filing registry and bounded loader + +**Files:** +- Modify: `tests/test_company_workbench_cash_generation_preview_loader.py` +- Modify: `src/company_workbench_cash_generation_preview_loader.py` + +**Interfaces:** +- Consumes: `fetch_sec_quarterly_pilot_payloads(...)`, `extract_sec_quarterly_cash_generation(...)`, `preview_sec_quarterly_cash_generation(...)`, and `compose_company_workbench_cash_generation_preview(...)`. +- Produces: frozen `CashGenerationPreviewFiling`, read-only `CASH_GENERATION_PREVIEW_FILINGS`, and the unchanged `load_company_workbench_cash_generation_preview(ticker, *, user_agent=None, fetcher=None, retrieved_at=None)` signature. + +- [ ] **Step 1: Add failing registry and AMD loader tests** + +Replace the NVIDIA constant imports with the registry import and add exact AMD fixtures and assertions: + +```python +from src.company_workbench_cash_generation_preview_loader import ( + CASH_GENERATION_PREVIEW_FILINGS, + load_company_workbench_cash_generation_preview, +) + +AMD_START = "2025-12-28" +AMD_END = "2026-03-28" +AMD_ACCESSION = "0000002488-26-000076" + + +def _amd_fact(value: float) -> dict[str, object]: + return { + "start": AMD_START, + "end": AMD_END, + "val": value, + "accn": AMD_ACCESSION, + "fy": 2026, + "fp": "Q1", + "form": "10-Q", + "filed": "2026-05-06", + } + + +def _amd_companyfacts() -> dict[str, object]: + return { + "cik": 2488, + "entityName": "ADVANCED MICRO DEVICES INC", + "facts": {"us-gaap": { + "RevenueFromContractWithCustomerExcludingAssessedTax": { + "units": {"USD": [_amd_fact(10_253_000_000)]} + }, + "OperatingIncomeLoss": {"units": {"USD": [_amd_fact(1_476_000_000)]}}, + "NetCashProvidedByUsedInOperatingActivities": { + "units": {"USD": [_amd_fact(2_955_000_000)]} + }, + "PaymentsToAcquirePropertyPlantAndEquipment": { + "units": {"USD": [_amd_fact(389_000_000)]} + }, + }}, + } + + +def _amd_submissions() -> dict[str, object]: + return {"cik": "0000002488", "filings": {"recent": { + "accessionNumber": [AMD_ACCESSION], + "filingDate": ["2026-05-06"], + "acceptanceDateTime": ["2026-05-05T18:06:27.000-04:00"], + "form": ["10-Q"], + "primaryDocument": ["amd-20260328.htm"], + }}} + + +def _amd_filing() -> str: + return f""" + + {AMD_START} + {AMD_END} + + + + + +
Net revenue10,253
Operating income1,476
Net cash provided by operating activities2,955
Purchases of property and equipment(389)
+ """ + + +def _amd_fetcher(calls: list[tuple[str, str]]): + def fetch(url: str, user_agent: str) -> bytes: + calls.append((url, user_agent)) + if "companyfacts" in url: + return json.dumps(_amd_companyfacts()).encode("utf-8") + if "submissions" in url: + return json.dumps(_amd_submissions()).encode("utf-8") + return _amd_filing().encode("utf-8") + return fetch + + +def test_registry_contains_only_two_exact_reviewed_filings(): + assert tuple(CASH_GENERATION_PREVIEW_FILINGS) == ("NVDA", "AMD") + nvda = CASH_GENERATION_PREVIEW_FILINGS["NVDA"] + amd = CASH_GENERATION_PREVIEW_FILINGS["AMD"] + assert (nvda.cik, nvda.accession, nvda.primary_document) == ( + "0001045810", "0001045810-26-000052", "nvda-20260426.htm" + ) + assert (amd.cik, amd.fiscal_period, amd.period_start, amd.period_end) == ( + "0000002488", "2026-Q1", "2025-12-28", "2026-03-28" + ) + assert (amd.accession, amd.primary_document, amd.as_of) == ( + "0000002488-26-000076", "amd-20260328.htm", "2026-07-20T23:59:59-04:00" + ) + + +def test_amd_loader_uses_exact_reviewed_identity_and_composes_in_memory(): + calls: list[tuple[str, str]] = [] + result = load_company_workbench_cash_generation_preview( + "AMD", + user_agent="Researcher research@example.com", + fetcher=_amd_fetcher(calls), + retrieved_at="2026-07-20T23:00:00+00:00", + ) + assert [url for url, _agent in calls] == [ + "https://data.sec.gov/api/xbrl/companyfacts/CIK0000002488.json", + "https://data.sec.gov/submissions/CIK0000002488.json", + "https://www.sec.gov/Archives/edgar/data/2488/000000248826000076/amd-20260328.htm", + ] + assert result.status == "accepted_for_review" + assert result.fiscal_period == "2026-Q1" + assert result.free_cash_flow.value == 2_566_000_000 + assert result.operating_margin.status == "preview_available" + assert result.fcf_margin.status == "preview_available" + assert result.production_activation is False + assert result.readiness_promotions == () + assert result.persistence is False +``` + +Strengthen the unsupported-ticker test with: + +```python +assert result.fiscal_period == "" +assert result.accession == "" +assert result.source_url == "" +assert result.cutoff == "" +``` + +Add the unsigned AMD filing test: + +```python +def test_unsigned_amd_capex_withholds_complete_preview(): + calls: list[tuple[str, str]] = [] + base_fetcher = _amd_fetcher(calls) + + def unsigned_fetch(url: str, user_agent: str) -> bytes: + payload = base_fetcher(url, user_agent) + if url.endswith("amd-20260328.htm"): + return payload.replace(b"(", b"").replace( + b")", b"" + ) + return payload + + result = load_company_workbench_cash_generation_preview( + "AMD", + user_agent="Researcher research@example.com", + fetcher=unsigned_fetch, + retrieved_at="2026-07-20T23:00:00+00:00", + ) + assert result.status == "withheld" + assert result.operating_margin.value is None + assert result.free_cash_flow.value is None + assert result.fcf_margin.value is None + assert result.components == () +``` + +- [ ] **Step 2: Run the focused loader tests and verify the expected failure** + +Run: + +```bash +python3 -m pytest tests/test_company_workbench_cash_generation_preview_loader.py -q +``` + +Expected: FAIL during import because `CASH_GENERATION_PREVIEW_FILINGS` does not exist. + +- [ ] **Step 3: Implement the immutable registry and registry-driven loader** + +Replace the NVIDIA-only constants and field substitutions with: + +```python +from dataclasses import dataclass +from types import MappingProxyType +from typing import Mapping + + +@dataclass(frozen=True) +class CashGenerationPreviewFiling: + ticker: str + cik: str + fiscal_period: str + period_start: str + period_end: str + accession: str + primary_document: str + as_of: str + + +CASH_GENERATION_PREVIEW_FILINGS: Mapping[str, CashGenerationPreviewFiling] = ( + MappingProxyType({ + "NVDA": CashGenerationPreviewFiling( + ticker="NVDA", + cik="0001045810", + fiscal_period="2027-Q1", + period_start="2026-01-26", + period_end="2026-04-26", + accession="0001045810-26-000052", + primary_document="nvda-20260426.htm", + as_of="2026-07-20T23:59:59-04:00", + ), + "AMD": CashGenerationPreviewFiling( + ticker="AMD", + cik="0000002488", + fiscal_period="2026-Q1", + period_start="2025-12-28", + period_end="2026-03-28", + accession="0000002488-26-000076", + primary_document="amd-20260328.htm", + as_of="2026-07-20T23:59:59-04:00", + ), + }) +) +``` + +At the beginning of the loader, resolve `filing = CASH_GENERATION_PREVIEW_FILINGS.get(symbol)`. When absent, call `blocked_company_workbench_cash_generation_preview` with only `symbol` and the unsupported blocker. When present, replace every previous `PREVIEW_*` argument with the matching `filing` property. In the exception path, retain only that selected filing's `fiscal_period`, `as_of`, and `accession`. + +- [ ] **Step 4: Run focused cash-generation tests** + +Run: + +```bash +python3 -m pytest tests/test_company_workbench_cash_generation_preview_loader.py tests/test_company_workbench_cash_generation_preview.py tests/test_sec_quarterly_cash_generation_pilot.py tests/test_quarterly_cash_generation_adapter.py tests/test_quarterly_cash_generation.py -q +``` + +Expected: PASS with no live network access. + +- [ ] **Step 5: Verify the loader has no write or broad-source surface** + +Run: + +```bash +make diff-hygiene-summary +git diff --check +``` + +Expected: only the loader and its focused test are changed; no generated artifact is reported. + +- [ ] **Step 6: Commit the loader slice** + +```bash +git add src/company_workbench_cash_generation_preview_loader.py tests/test_company_workbench_cash_generation_preview_loader.py +make staged-hygiene-check +git diff --cached --check +git commit -m "Add bounded AMD cash preview filing" +``` + +### Task 2: Explicit AMD Workbench route proof + +**Files:** +- Modify: `tests/test_dashboard_render_smoke.py` +- Modify: `tests/test_research_mode_dashboard_contract.py` + +**Interfaces:** +- Consumes: the unchanged dashboard query gate and loader import, plus `CompanyWorkbenchCashGenerationPreview` presentation contract. +- Produces: runtime-contract evidence that explicit AMD preview works and normal AMD navigation never loads preview evidence. + +- [ ] **Step 1: Add an explicit AMD render-smoke test** + +Add an accepted AMD preview fixture using: + +```python +preview = CompanyWorkbenchCashGenerationPreview( + ticker="AMD", + fiscal_period="2026-Q1", + status="accepted_for_review", + message="Accepted SEC evidence supports a cash-generation review preview.", + operating_margin=CashGenerationPreviewMetric( + "operating_margin", "preview_available", 1_476_000_000 / 10_253_000_000, + "2026-Q1", (), "" + ), + free_cash_flow=CashGenerationPreviewMetric( + "free_cash_flow", "preview_available", 2_566_000_000, "2026-Q1", (), "" + ), + fcf_margin=CashGenerationPreviewMetric( + "fcf_margin", "preview_available", 2_566_000_000 / 10_253_000_000, + "2026-Q1", (), "" + ), + blockers=(), + withheld_metrics=(), + accession="0000002488-26-000076", + source_url="https://www.sec.gov/Archives/edgar/data/2488/000000248826000076/amd-20260328.htm", + accepted_at="2026-05-05T22:06:27+00:00", + cutoff="2026-07-21T03:59:59+00:00", + capex_sign_evidence="explicit_filed_table_outflow", + components=(), +) +``` + +Use route parameters `ticker=AMD`, `open=1`, and `cash_preview=1`. Require `Cash-generation review preview`, `not production evidence`, `14.4%`, `2,566,000,000`, and `25.0%`. Patch only the external loader boundary and assert no exception, missing marker, forbidden marker, or expanded Advanced section. + +- [ ] **Step 2: Add a normal AMD route non-invocation test** + +Add the normal-route guard: + +```python +def test_normal_amd_company_workbench_route_never_loads_cash_preview(): + from src.dashboard_render_smoke import DashboardRenderRoute, render_public_routes + + route = DashboardRenderRoute( + name="Normal AMD Company Workbench", + query_params=( + ("mode", "research"), + ("page", "company-workbench"), + ("ticker", "AMD"), + ("open", "1"), + ), + required_markers=("Company Workbench", "Business Trend", "Research-only"), + ) + with patch( + "src.company_workbench_cash_generation_preview_loader." + "load_company_workbench_cash_generation_preview", + side_effect=AssertionError("normal AMD Workbench must not load cash preview"), + ): + result = render_public_routes(Path("."), routes=(route,))[0] + assert result.exceptions == () + assert result.missing_markers == () +``` + +- [ ] **Step 3: Strengthen the static route contract** + +In `test_company_workbench_loads_cash_preview_only_for_explicit_flag`, keep: + +```python +assert 'company_workbench_cash_preview_requested(st.query_params.get("cash_preview"))' in source +assert "load_company_workbench_cash_generation_preview(ticker)" in source +``` + +In the default-navigation test, add: + +```python +assert "ticker=NVDA&open=1&cash_preview=1" not in source +assert "ticker=AMD&open=1&cash_preview=1" not in source +``` + +- [ ] **Step 4: Run the focused dashboard contracts** + +Run: + +```bash +python3 -m pytest tests/test_dashboard_render_smoke.py tests/test_research_mode_dashboard_contract.py tests/test_research_workspace.py -q +make research-dashboard-render-smoke +``` + +Expected: PASS; all standard and Advanced Evidence routes remain unchanged, and the AMD explicit route renders only through the patched loader boundary. + +- [ ] **Step 5: Commit the route-proof slice** + +```bash +git add tests/test_dashboard_render_smoke.py tests/test_research_mode_dashboard_contract.py +make staged-hygiene-check +git diff --cached --check +git commit -m "Prove explicit AMD cash preview route" +``` + +### Task 3: Documentation truth and continuation contract + +**Files:** +- Modify: `tests/test_public_v1_release_docs.py` +- Modify: `ROADMAP.md` +- Modify: `docs/PERSONAL_RESEARCH_MODE.md` +- Modify: `docs/METHODOLOGY.md` +- Modify: `docs/PROVENANCE_CONTRACT.md` +- Modify: `docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md` + +**Interfaces:** +- Consumes: the verified two-entry registry, AMD live evidence, focused tests, and unchanged product boundaries. +- Produces: durable product-stage and next-lane truth for reviewers and later continuations. + +- [ ] **Step 1: Add a failing documentation contract** + +Add: + +```python +def test_two_company_cash_preview_docs_preserve_bounded_portability_boundary(): + methodology = _read("docs/METHODOLOGY.md") + provenance = _read("docs/PROVENANCE_CONTRACT.md") + personal = _read("docs/PERSONAL_RESEARCH_MODE.md") + roadmap = _read("ROADMAP.md") + prompt = _read("docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md") + + for text in (methodology, provenance, personal, roadmap, prompt): + assert "AMD Q1 FY2026" in text + assert "0000002488-26-000076" in text + assert "bounded two-company portability" in text.lower() + assert "cash_preview=1" in personal + assert "production_activation=false" in provenance + assert "readiness_promotions=()" in provenance + assert "does not prove broad company coverage" in roadmap.lower() + assert "do not add a third company" in prompt.lower() +``` + +- [ ] **Step 2: Run the documentation test and verify failure** + +Run: + +```bash +python3 -m pytest tests/test_public_v1_release_docs.py -q +``` + +Expected: FAIL because the AMD portability evidence is not yet documented. + +- [ ] **Step 3: Update the six truth documents** + +Add this product-stage statement to `ROADMAP.md`: + +```markdown +**Implemented locally and live-source verified:** AMD Q1 FY2026 accession `0000002488-26-000076` now joins NVIDIA in one immutable, shared loader and explicit `cash_preview=1` Company Workbench path. The AMD filing supplied Revenue USD 10.253B, operating income USD 1.476B, cash from operations USD 2.955B, capital expenditures USD -0.389B, and `explicit_filed_table_outflow` evidence before free cash flow USD 2.566B was displayed. This is bounded two-company portability; it does not prove broad company coverage, arbitrary-filing support, historical depth, Q4 support, production activation, current readiness, hosting, reviewer validation, calibration, demand, or product-market fit. +``` + +Add this route statement to `docs/PERSONAL_RESEARCH_MODE.md`: + +```markdown +AMD Q1 FY2026 accession `0000002488-26-000076` is the second exact filing available through the explicit `cash_preview=1` route. NVIDIA and AMD now provide bounded two-company portability through the same immutable loader, complete-withholding contract, preview-only cards, and Advanced lineage. Normal Company Workbench routes remain canonical and never load the preview. +``` + +Add this method statement to `docs/METHODOLOGY.md`: + +```markdown +The AMD Q1 FY2026 result proves bounded two-company portability for the same exact-filing method: one immutable identity, three official SEC endpoints, compatible quarterly components, timezone-aware acceptance, explicit filed-table capex outflow, exact rights scope, and complete withholding. It does not prove broad company coverage, and no third company is inferred from cohort membership. +``` + +Add this evidence statement to `docs/PROVENANCE_CONTRACT.md`: + +```markdown +AMD Q1 FY2026 accession `0000002488-26-000076` uses the same bounded two-company portability contract as NVIDIA. Both results retain `production_activation=false`, `readiness_promotions=()`, `persistence=false`, exact component references, and no canonical write. Unsupported tickers fail before fetch and expose no configured filing identity. +``` + +In `docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md`, add a lineage navigation anchor using the already committed design SHA, which every implementation commit must descend from: + +```markdown +- Bounded two-company cash-preview design anchor: commit `11ac530ae` or a later verified descendant. +``` + +Replace the quarterly portability classification with: + +```markdown +- Quarterly cash-generation portability: `bounded_two_company_preview_implemented`; NVIDIA Q1 FY2027 and AMD Q1 FY2026 use one immutable exact-filing loader and explicit preview-only Workbench path. This proves bounded two-company portability only. Do not add a third company, run broad coverage, infer filing identity, persist observations, or promote readiness without a separately justified exact-source design and review. +``` + +Keep all normal-route, complete-withholding, Advanced-only, no-persistence, stale-readiness, Q4, EPS, consensus, calibration, hosted, reviewer, demand, and market-validation boundaries unchanged. + +- [ ] **Step 4: Run documentation, wording, and whitespace checks** + +Run: + +```bash +python3 -m pytest tests/test_public_v1_release_docs.py -q +make public-wording-check +git diff --check +``` + +Expected: PASS. + +- [ ] **Step 5: Commit documentation truth** + +```bash +git add ROADMAP.md docs/PERSONAL_RESEARCH_MODE.md docs/METHODOLOGY.md docs/PROVENANCE_CONTRACT.md docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md tests/test_public_v1_release_docs.py +make staged-hygiene-check +git diff --cached --check +git commit -m "Document bounded cash preview portability" +``` + +### Task 4: Live proof, complete verification, push, and PR update + +**Files:** +- No new repository files. +- Update draft PR #113 through GitHub after all local evidence passes. + +**Interfaces:** +- Consumes: Tasks 1-3 and configured `SEC_USER_AGENT`. +- Produces: current live no-write AMD proof, complete local release evidence, aligned remote branch, updated draft PR, and exact-head hosted CI evidence. + +- [ ] **Step 1: Run one bounded live loader proof** + +Run: + +```bash +python3 -c 'from src.company_workbench_cash_generation_preview_loader import load_company_workbench_cash_generation_preview as load; result = load("AMD"); print(result.status, result.fiscal_period, result.free_cash_flow.value, result.production_activation, result.readiness_promotions, result.persistence); assert result.status == "accepted_for_review"; assert result.free_cash_flow.value == 2566000000; assert result.production_activation is False; assert result.readiness_promotions == (); assert result.persistence is False' +``` + +Expected: `accepted_for_review 2026-Q1 2566000000.0 False () False`. Immediately run `git status --short` and require no artifact change. + +- [ ] **Step 2: Run focused and full verification** + +Run: + +```bash +python3 -m pytest tests/test_company_workbench_cash_generation_preview_loader.py tests/test_company_workbench_cash_generation_preview.py tests/test_sec_quarterly_cash_generation_pilot.py tests/test_quarterly_cash_generation_adapter.py tests/test_quarterly_cash_generation.py tests/test_dashboard_render_smoke.py tests/test_research_mode_dashboard_contract.py tests/test_research_workspace.py tests/test_public_v1_release_docs.py -q +python3 -m pytest tests -q +make dashboard-smoke +make research-dashboard-render-smoke +make public-wording-check +make public-check +make commercial-beta-check +make commercial-beta-release-check +make pilot-readiness-check TOP_N=10 +make diff-hygiene-summary +make pr-range-hygiene-check BASE_SHA=origin/main HEAD_SHA=HEAD +git diff --check +``` + +Expected: all code and release gates pass; pilot readiness remains truthfully blocked by stale saved readiness; PR-range hygiene reports zero generated CSV/JSON and manual-review churn. + +- [ ] **Step 3: Verify repository package before push** + +Run: + +```bash +git status --short --branch +git log -8 --oneline --decorate +git rev-list --left-right --count HEAD...origin/codex/personal-research-mode-mvp +``` + +Expected: clean working tree and a finite intentional ahead count containing only the design, plan, loader, route-proof, and documentation commits. + +- [ ] **Step 4: Push only the approved feature branch** + +```bash +git push origin codex/personal-research-mode-mvp +``` + +- [ ] **Step 5: Update draft PR #113** + +Update the PR body with the exact new HEAD, AMD identity and values, bounded two-company claim, full test count, local gate results, generated-artifact exclusion, live no-write evidence, remaining external dependencies, and exact next maturity lane. Keep it open and draft; do not merge. + +- [ ] **Step 6: Verify exact-head GitHub CI** + +Run: + +```bash +gh pr view 113 --json isDraft,state,mergeable,headRefOid,statusCheckRollup,url +``` + +Wait until the `local-engineering-gate` for the exact pushed `headRefOid` completes successfully. Do not reuse prior-head CI. + +- [ ] **Step 7: Final completion audit for this slice** + +Map each design completion criterion to current code, tests, live result, documentation, Git status, PR state, range hygiene, and exact-head CI. Report this portability slice complete only if all ten criteria are directly proven. Keep the overall commercial-maturity goal active because point-in-time consensus, hosting, external reviewers, calibration, broader evidence depth, and operated-platform controls remain incomplete. diff --git a/docs/superpowers/plans/2026-07-20-company-workbench-cash-generation-preview.md b/docs/superpowers/plans/2026-07-20-company-workbench-cash-generation-preview.md new file mode 100644 index 00000000..c3b59c4b --- /dev/null +++ b/docs/superpowers/plans/2026-07-20-company-workbench-cash-generation-preview.md @@ -0,0 +1,407 @@ +# Company Workbench Cash-Generation Activation Preview 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:** Build an explicit, read-only Company Workbench route that shows one accepted NVIDIA SEC quarterly cash-generation packet as a clearly non-production review preview while leaving the ordinary Workbench and every readiness state unchanged. + +**Architecture:** Add a pure activation-preview composer that converts only an `accepted_for_review` SEC pilot result into an immutable, fail-closed Workbench view model. Add a separate bounded live loader for the exact NVIDIA filing and reviewed cutoff, then pass the result into the existing Business Trend renderer only when `cash_preview=1`; raw lineage stays under Advanced and normal Workbench rendering never fetches preview data. + +**Tech Stack:** Python 3.12, frozen dataclasses, the existing SEC Companyfacts/submissions/inline-XBRL parser, existing quarterly trend composition, Streamlit, pytest, and Make-based repository gates. + +## Global Constraints + +- Research-only; no investment advice, broker integration, order routing, auto-trading, direct buy/sell instructions, or post-earnings price prediction. +- Consume only `SecQuarterlyPilotPreview.status == "accepted_for_review"` with an accepted adapter result. +- Keep `production_activation=False`, `readiness_promotions=()`, and `persistence=False` immutable. +- Accept only the `cash_preview=1` route flag; add no arbitrary ticker, accession, filing, or cutoff input. +- Use NVIDIA Q1 FY2027 accession `0001045810-26-000052`, document `nvda-20260426.htm`, and cutoff `2026-07-20T23:59:59-04:00`. +- Withhold all three preview metrics when evidence is missing, rejected, ambiguous, mismatched, post-cutoff, incompatible, incomplete, or has invalid capex-sign proof. +- Leave the ordinary Company Workbench canonical packet and all readiness states unchanged. +- EPS split basis remains unverified without explicit proof; Q4 requires an explicit filed three-month quarter. +- Generate and stage no CSV, JSON, report, sample-report, screenshot, timing, canonical-data, readiness, cache, or manual-review artifact. +- Never run `make readiness`, readiness writers, broad refreshes, canonical writers, screenshot regeneration, consensus apply, production activation, deployment, or PR merge. +- Stage exact files only; never use `git add -A`. Push only `codex/personal-research-mode-mvp`; keep PR #113 draft. + +## File Structure + +- Create `src/company_workbench_cash_generation_preview.py` for immutable types, query parsing, blocked construction, and pure composition. +- Create `src/company_workbench_cash_generation_preview_loader.py` for exact in-memory NVIDIA loading. +- Modify `src/research_workspace.py` for primary preview cards and Advanced lineage rows. +- Modify `src/dashboard.py` for explicit route gating and optional Business Trend rendering. +- Create `tests/test_company_workbench_cash_generation_preview.py` and `tests/test_company_workbench_cash_generation_preview_loader.py`. +- Modify `tests/test_research_workspace.py`, `tests/test_research_mode_dashboard_contract.py`, and `tests/test_dashboard_render_smoke.py`. +- Modify `ROADMAP.md`, `docs/PERSONAL_RESEARCH_MODE.md`, `docs/METHODOLOGY.md`, `docs/PROVENANCE.md`, `docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md`, and `tests/test_public_v1_release_docs.py`. + +--- + +### Task 1: Pure Activation-Preview Contract + +**Files:** +- Create: `src/company_workbench_cash_generation_preview.py` +- Create: `tests/test_company_workbench_cash_generation_preview.py` + +**Interfaces:** +- Consumes: `SecQuarterlyPilotPreview`, `build_quarterly_trend_packet(...)`, and `parse_utc_timestamp(...)`. +- Produces: `CashGenerationPreviewMetric`, `CashGenerationPreviewComponent`, `CompanyWorkbenchCashGenerationPreview`, `company_workbench_cash_preview_requested(value: object) -> bool`, `blocked_company_workbench_cash_generation_preview(...)`, and `compose_company_workbench_cash_generation_preview(...)`. + +- [ ] **Step 1: Write the failing accepted-result test** + +Construct all evidence in memory. The fixture must contain Revenue `81_615_000_000`, operating income `53_536_000_000`, CFO `50_344_000_000`, capex `-1_757_000_000`, exact SEC lineage, accepted time `2026-05-20T20:35:52+00:00`, `accepted_for_review`, false activation, and empty promotions. + +```python +AS_OF = "2026-07-20T23:59:59-04:00" + +def test_accepted_sec_packet_composes_complete_non_activation_preview(): + result = compose_company_workbench_cash_generation_preview( + _accepted_sec_preview(), selected_ticker="NVDA", as_of=AS_OF + ) + assert result.status == "accepted_for_review" + assert result.operating_margin.value == pytest.approx(53_536_000_000 / 81_615_000_000) + assert result.free_cash_flow.value == 48_587_000_000 + assert result.fcf_margin.value == pytest.approx(48_587_000_000 / 81_615_000_000) + assert result.production_activation is False + assert result.readiness_promotions == () + assert result.persistence is False + assert result.accession == "0001045810-26-000052" + assert result.capex_sign_evidence == "explicit_filed_table_outflow" + assert result.blockers == () +``` + +- [ ] **Step 2: Verify the test fails before implementation** + +Run `python3 -m pytest tests/test_company_workbench_cash_generation_preview.py::test_accepted_sec_packet_composes_complete_non_activation_preview -q`. + +Expected: collection fails because the module does not exist. + +- [ ] **Step 3: Add immutable types and strict query parsing** + +Implement these exact public shapes: + +```python +@dataclass(frozen=True) +class CashGenerationPreviewMetric: + metric: str + status: str + value: float | None + fiscal_period: str + source_refs: tuple[str, ...] + withheld_reason: str + +@dataclass(frozen=True) +class CashGenerationPreviewComponent: + metric: str + value: float + currency: str + fiscal_period: str + source_ref: str + published_at: str + retrieved_at: str + accounting_basis: str + duration_basis: str + q4_evidence_state: str + +@dataclass(frozen=True) +class CompanyWorkbenchCashGenerationPreview: + ticker: str + fiscal_period: str + status: str + message: str + operating_margin: CashGenerationPreviewMetric + free_cash_flow: CashGenerationPreviewMetric + fcf_margin: CashGenerationPreviewMetric + blockers: tuple[str, ...] + withheld_metrics: tuple[str, ...] + accession: str + source_url: str + accepted_at: str + cutoff: str + capex_sign_evidence: str + components: tuple[CashGenerationPreviewComponent, ...] + production_activation: bool = False + readiness_promotions: tuple[str, ...] = () + persistence: bool = False + +def company_workbench_cash_preview_requested(value: object) -> bool: + if isinstance(value, (list, tuple)): + value = value[0] if value else "" + return str(value or "").strip() == "1" +``` + +- [ ] **Step 4: Implement the pure composer** + +Implement: + +```python +def compose_company_workbench_cash_generation_preview( + pilot: SecQuarterlyPilotPreview, + *, + selected_ticker: str, + as_of: str, +) -> CompanyWorkbenchCashGenerationPreview: +``` + +Validate timezone-aware cutoff/acceptance timestamps, both acceptance statuses, false activation flags, empty promotions, selected/extracted ticker identity, no extraction blockers, exact metadata, `sec_companyfacts` source identity, accepted observation count, acceptance before cutoff, all observations before cutoff, explicit Q4 evidence, and `explicit_filed_table_outflow`. Compose through `build_quarterly_trend_packet(..., business_observations=...)`. Expose success only when all three metrics have values for the extraction period. Any blocker returns three withheld metrics and `components=()`. + +- [ ] **Step 5: Verify the success test passes** + +Run the Step 2 command. Expected: `1 passed`. + +- [ ] **Step 6: Add fail-closed and query tests** + +Parameterize pilot-blocked, missing/blocked acceptance, mixed ticker, extraction blockers, activation true, promotions non-empty, missing accession/source/accepted time, post-cutoff time, wrong capex proof, incomplete components, ambiguous revisions, incompatible definitions, and Q4 without explicit proof. Every result must be `withheld`, carry the expected stable blocker, have three `None` values, and expose no components. Prove only scalar/list/tuple `"1"` enables the route; `None`, `""`, `"0"`, `"true"`, `"yes"`, and `"1,other"` do not. + +- [ ] **Step 7: Run and commit the pure contract** + +```bash +python3 -m pytest tests/test_company_workbench_cash_generation_preview.py -q +git add -- src/company_workbench_cash_generation_preview.py tests/test_company_workbench_cash_generation_preview.py +make staged-hygiene-check +git diff --cached --check +git commit -m "Add Workbench cash preview contract" +``` + +Expected: all focused tests and hygiene checks pass. + +--- + +### Task 2: Exact In-Memory NVIDIA Loader + +**Files:** +- Create: `src/company_workbench_cash_generation_preview_loader.py` +- Create: `tests/test_company_workbench_cash_generation_preview_loader.py` + +**Interfaces:** +- Consumes: the existing exact SEC fetch/extract/accept helpers and Task 1 composer. +- Produces: exact identity constants and `load_company_workbench_cash_generation_preview(ticker: str, *, user_agent: str | None = None, fetcher=None, retrieved_at: str | None = None)`. + +- [ ] **Step 1: Write the failing bounded-loader test** + +Monkeypatch fetch, extraction, acceptance, and rights loading. Assert fetch receives only CIK `0001045810`, accession `0001045810-26-000052`, and `nvda-20260426.htm`; extraction receives fiscal period `2027-Q1`, dates `2026-01-26` through `2026-04-26`, and cutoff `2026-07-20T23:59:59-04:00`. Assert the returned result is accepted and non-persistent. + +- [ ] **Step 2: Verify the loader test fails** + +Run `python3 -m pytest tests/test_company_workbench_cash_generation_preview_loader.py::test_loader_uses_only_reviewed_nvidia_identity_and_composes_in_memory -q`. + +Expected: collection fails because the loader module does not exist. + +- [ ] **Step 3: Implement the bounded loader** + +Define: + +```python +PREVIEW_TICKER = "NVDA" +PREVIEW_CIK = "0001045810" +PREVIEW_FISCAL_PERIOD = "2027-Q1" +PREVIEW_PERIOD_START = "2026-01-26" +PREVIEW_PERIOD_END = "2026-04-26" +PREVIEW_ACCESSION = "0001045810-26-000052" +PREVIEW_PRIMARY_DOCUMENT = "nvda-20260426.htm" +PREVIEW_AS_OF = "2026-07-20T23:59:59-04:00" +``` + +Return `unsupported_preview_ticker:` before fetching non-NVDA. Otherwise fetch the exact three endpoints in memory, extract using the fixed identity and cutoff, assess with the source-rights registry, and compose. Catch `SECUserAgentError`, `SecQuarterlyPreviewFetchError`, `TypeError`, and `ValueError` as `preview_load_blocked:` without secrets or raw payloads. Add no paths, cache, argparse, output, apply, readiness, refresh, or fallback surface. + +- [ ] **Step 4: Add blocked and surface tests** + +Prove non-NVDA never invokes fetch; missing SEC user agent, fetch error, malformed extraction, and blocked acceptance withhold all values. Inspect the function signature and module source to reject output/apply/refresh/readiness/accession/CIK/filing/cutoff parameters and `Path`, `open(`, `to_csv`, `to_json`, alternate providers, or fixture fallback. + +- [ ] **Step 5: Run and commit the loader** + +```bash +python3 -m pytest tests/test_company_workbench_cash_generation_preview_loader.py -q +git add -- src/company_workbench_cash_generation_preview_loader.py tests/test_company_workbench_cash_generation_preview_loader.py +make staged-hygiene-check +git diff --cached --check +git commit -m "Load bounded Workbench cash preview" +``` + +Expected: all tests pass without network or file writes. + +--- + +### Task 3: Answer-First Workbench Rendering + +**Files:** +- Modify: `src/research_workspace.py` +- Modify: `src/dashboard.py` +- Modify: `tests/test_research_workspace.py` +- Modify: `tests/test_research_mode_dashboard_contract.py` +- Modify: `tests/test_dashboard_render_smoke.py` + +**Interfaces:** +- Consumes: Task 1 view model/query helper and Task 2 loader. +- Produces: `cash_generation_preview_cards(preview)`, `cash_generation_preview_rows(preview)`, and optional `cash_generation_preview` on `render_single_stock_report(...)`. + +- [ ] **Step 1: Write failing presentation tests** + +Assert accepted cards appear in this order: boundary, Operating Margin, Free Cash Flow, FCF Margin. Require title `Cash-generation review preview — not production evidence`, one-decimal margin percentages, comma-formatted FCF, `preview_available` rather than `ready`, blank commands, and non-activation badges. Require accession, timestamps, capex sign, definitions, values, and source refs only in Advanced rows. Withheld input must show three `Withheld` cards and no component rows. + +- [ ] **Step 2: Verify presentation tests fail** + +Run `python3 -m pytest tests/test_research_workspace.py -k cash_generation_preview -q`. + +Expected: missing helper imports or attributes. + +- [ ] **Step 3: Implement presentation helpers** + +In `src/research_workspace.py`, build four answer-first cards without lineage identifiers. Build Advanced rows for accession, source URL, accepted time, cutoff, capex sign, non-activation fields, and each component's value, definition, timestamps, and source ref. Do not add commands, advice, scores, recommendations, or readiness claims. + +- [ ] **Step 4: Write failing dashboard route tests** + +Require `render_company_workbench` to call the strict query helper before the loader and pass `None` otherwise. Require the ordinary `load_dashboard_quarterly_trend(ticker)` call to remain independent. Within Business Trend, require canonical cards first, preview cards second, then collapsed `Advanced: cash-generation preview evidence` rows. Require no preview flag in normal navigation or Discover links. + +- [ ] **Step 5: Verify dashboard tests fail** + +Run `python3 -m pytest tests/test_research_mode_dashboard_contract.py -k "cash_preview or cash_generation" -q`. + +Expected: failures because the route is not integrated. + +- [ ] **Step 6: Integrate the explicit route** + +Add `cash_generation_preview: CompanyWorkbenchCashGenerationPreview | None = None` to `render_single_stock_report(...)`. After ordinary `quarterly_trend_cards(trend_packet)`, render preview cards only when supplied, followed by a collapsed Advanced preview-evidence dataframe and non-activation caption. In `render_company_workbench`, use exactly: + +```python +cash_generation_preview = ( + load_company_workbench_cash_generation_preview(ticker) + if company_workbench_cash_preview_requested(st.query_params.get("cash_preview")) + else None +) +``` + +Pass it separately while retaining `quarterly_trend_packet=load_dashboard_quarterly_trend(ticker)`. Do not change other routes. + +- [ ] **Step 7: Add explicit-route render smoke** + +Keep the normal Workbench smoke route with no loader call. Add an explicit preview route whose loader is patched to a complete in-memory view model and require `Cash-generation review preview`, `not production evidence`, `Operating Margin`, `Free Cash Flow`, and `FCF Margin`. No test may contact SEC or write an artifact. + +- [ ] **Step 8: Run focused rendering verification** + +```bash +python3 -m pytest tests/test_research_workspace.py tests/test_research_mode_dashboard_contract.py tests/test_dashboard_render_smoke.py -q +make dashboard-smoke +make research-dashboard-render-smoke +git status --short +``` + +Expected: all tests and smokes pass; status lists only intentional source/test changes. + +- [ ] **Step 9: Commit the rendering slice** + +```bash +git add -- src/research_workspace.py src/dashboard.py tests/test_research_workspace.py tests/test_research_mode_dashboard_contract.py tests/test_dashboard_render_smoke.py +make staged-hygiene-check +git diff --cached --check +git commit -m "Render explicit Workbench cash preview" +``` + +--- + +### Task 4: Documentation and Roadmap Truth + +**Files:** +- Modify: `ROADMAP.md` +- Modify: `docs/PERSONAL_RESEARCH_MODE.md` +- Modify: `docs/METHODOLOGY.md` +- Modify: `docs/PROVENANCE.md` +- Modify: `docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md` +- Modify: `tests/test_public_v1_release_docs.py` + +**Interfaces:** +- Consumes: verified Tasks 1–3 behavior. +- Produces: durable capability, boundary, maturity, and next-stage evidence. + +- [ ] **Step 1: Write failing documentation tests** + +Require the guide to contain `cash_preview=1`, `Cash-generation review preview`, and `not production evidence`; provenance to contain `production_activation=false`, `readiness_promotions=()`, and `no canonical persistence`; roadmap to say `one explicit user-flow composition` and `does not prove a second company`; methodology to state complete withholding on any required-metric failure and Advanced-only technical lineage; continuation prompt to retain the NVIDIA stop rule and identify a bounded second-company proof as the next assessment. + +- [ ] **Step 2: Verify documentation tests fail** + +Run `python3 -m pytest tests/test_public_v1_release_docs.py -k "cash_generation or quarterly" -q`. + +Expected: failure on new preview wording. + +- [ ] **Step 3: Update exact documentation** + +Document the explicit local route, fixed cutoff, accepted-only composer, unchanged normal Workbench, all-or-nothing preview metrics, Advanced lineage, false activation, empty promotions, no persistence/readiness/artifacts, and no proof of another company, historical depth, Q4, hosting, reviewer adoption, calibration, demand, or product-market fit. Retain exact unblock conditions for point-in-time consensus, calibration, hosting/identity/operations, external review, and market evidence. Set the continuation prompt's expected HEAD only after the verified documentation commit. + +- [ ] **Step 4: Run and commit documentation** + +```bash +python3 -m pytest tests/test_public_v1_release_docs.py -q +make public-wording-check +git diff --check +git add -- ROADMAP.md docs/PERSONAL_RESEARCH_MODE.md docs/METHODOLOGY.md docs/PROVENANCE.md docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md tests/test_public_v1_release_docs.py +make staged-hygiene-check +git diff --cached --check +git commit -m "Document Workbench cash preview evidence" +``` + +Expected: documentation and wording checks pass with zero generated files staged. + +--- + +### Task 5: Full Verification, GitHub Sync, and Next-Stage Assessment + +**Files:** +- Modify only if verification reveals an in-scope defect: files already named above. +- External update: draft PR #113 body only; no merge or deployment. + +**Interfaces:** +- Consumes: all committed preview changes. +- Produces: complete local gate evidence, clean range hygiene, exact-head CI, updated draft PR, and next-stage classification. + +- [ ] **Step 1: Run focused and full tests** + +```bash +python3 -m pytest tests/test_company_workbench_cash_generation_preview.py tests/test_company_workbench_cash_generation_preview_loader.py tests/test_research_workspace.py tests/test_research_mode_dashboard_contract.py tests/test_dashboard_render_smoke.py tests/test_public_v1_release_docs.py -q +python3 -m pytest tests -q +``` + +Expected: all tests pass; no new warning is accepted without review. + +- [ ] **Step 2: Run every required product and hygiene gate** + +```bash +make dashboard-smoke +make research-dashboard-render-smoke +make public-wording-check +make public-check +make commercial-beta-check +make commercial-beta-release-check +make pilot-readiness-check TOP_N=10 +make diff-hygiene-summary +make pr-range-hygiene-check BASE_SHA=origin/main HEAD_SHA=HEAD +git diff --check +``` + +Expected: all engineering gates pass; pilot readiness may remain truthfully blocked on stale saved readiness; hygiene reports zero generated churn. + +- [ ] **Step 3: Verify scope and push only the feature branch** + +```bash +git status --short --branch +git log -8 --oneline --decorate +git diff origin/codex/personal-research-mode-mvp...HEAD --stat +make diff-hygiene-summary +git push origin codex/personal-research-mode-mvp +``` + +Expected: the clean branch advances with only intentional commits. + +- [ ] **Step 4: Update and verify draft PR #113** + +Update the PR body with route behavior, accepted-only/non-activation/no-persistence boundaries, tests, full gates, zero generated churn, exact head, and remaining stale-readiness, consensus, calibration, hosting, reviewer, portability, and market gates. Verify with: + +```bash +gh pr view 113 --json state,isDraft,mergeable,headRefName,headRefOid,statusCheckRollup,url +``` + +Expected: open, draft, correct feature branch/head, no merge. Wait for `local-engineering-gate` success at the exact pushed SHA; do not reuse earlier CI evidence. + +- [ ] **Step 5: Assess the bounded next maturity slice** + +Classify a second-company SEC portability proof as the next local lane only if one exact non-Q4 official filing can test a materially different parser structure without broad discovery or refresh. Otherwise record `external_source_selection_required` and stop broad source loops. Retain `permitted_dataset_required` for point-in-time consensus, `calibration_cohort_required` for probabilities, `hosted_account_identity_operations_required` for hosted beta, and `external_reviewer_and_demand_evidence_required` for market maturity. + +- [ ] **Step 6: Complete the requirement-by-requirement handoff audit** + +Verify source, tests, rendered behavior, Git history, PR state, and hygiene evidence against every design requirement. Report repository/PR status, product stage, roadmap item, changes, tests, commits/push, excluded artifacts, external dependencies, remaining gaps, exact next step, review safety, and whether the overall goal remains active. Do not call the overall product complete merely because this local slice passes. diff --git a/docs/superpowers/plans/2026-07-20-filed-q4-split-proof-correction.md b/docs/superpowers/plans/2026-07-20-filed-q4-split-proof-correction.md new file mode 100644 index 00000000..5899fd85 --- /dev/null +++ b/docs/superpowers/plans/2026-07-20-filed-q4-split-proof-correction.md @@ -0,0 +1,13 @@ +# Filed-Q4 Split-Proof Correction Plan + +1. Replace the prior positive no-proof assertion with failing missing/malformed + split-proof regressions. +2. Add a distinct primary-proof unverified sentinel and conservative shared + basis predicate. +3. Route consensus unverified handling through the same predicate. +4. Add an integration test proving five unverified filed-Q4-derived EPS rows + keep EPS withheld while Revenue remains ready. +5. Update methodology, provenance, roadmap, continuation contract, and tests. +6. Run focused/full tests and every product/release/pilot/range/staged hygiene + gate; commit, push, update draft PR #113, and require independent re-audit + plus exact-head hosted CI. diff --git a/docs/superpowers/plans/2026-07-20-optional-evidence-commercial-gates.md b/docs/superpowers/plans/2026-07-20-optional-evidence-commercial-gates.md new file mode 100644 index 00000000..6e09b6b7 --- /dev/null +++ b/docs/superpowers/plans/2026-07-20-optional-evidence-commercial-gates.md @@ -0,0 +1,14 @@ +# Optional Evidence Commercial Gates Plan + +1. Add failing tests for approved-rights/wrong-scope and exact-scope passage in + valuation, catalyst, and outcome builders. +2. Add explicit Commercial Research parameters and exact-source lane-scope + reviews without changing research-mode behavior. +3. Carry blocker counts/reasons in packet state and keep unsupported rows out of + commercially supported/reviewed output. +4. Route dashboard loaders through Commercial Research composition and keep + evidence details under existing Advanced surfaces. +5. Update methodology, provenance, roadmap, continuation contract, and tests. +6. Run focused/full tests and every required product, release, pilot, range, + whitespace, generated-artifact, and staged-hygiene check; commit, push, + update draft PR #113, and verify exact-head hosted CI. diff --git a/docs/superpowers/plans/2026-07-20-pr-range-engineering-gate.md b/docs/superpowers/plans/2026-07-20-pr-range-engineering-gate.md new file mode 100644 index 00000000..89f52a3e --- /dev/null +++ b/docs/superpowers/plans/2026-07-20-pr-range-engineering-gate.md @@ -0,0 +1,13 @@ +# Pull-Request Range Engineering Gate Plan + +1. Add failing workflow and hygiene tests for full history, explicit event SHAs, + range commands, and a generated file committed between two temporary SHAs. +2. Add commit-resolution and range-status loading to the existing read-only + hygiene script, with a generated-churn fail result. +3. Add a Make target accepting explicit `BASE_SHA` and `HEAD_SHA`. +4. Update the workflow to check out exact head with full history and run both + hygiene and whitespace against `BASE...HEAD`. +5. Update roadmap, methodology/provenance where relevant, continuation contract, + and focused launcher/workflow contracts. +6. Run focused/full tests and all product/release/pilot/local/range/staged hygiene + checks; commit, push, update draft PR #113, and require exact-head hosted CI. diff --git a/docs/superpowers/plans/2026-07-20-price-lineage-apply-integrity.md b/docs/superpowers/plans/2026-07-20-price-lineage-apply-integrity.md new file mode 100644 index 00000000..cb32aeec --- /dev/null +++ b/docs/superpowers/plans/2026-07-20-price-lineage-apply-integrity.md @@ -0,0 +1,19 @@ +# Price Lineage and Apply Integrity Implementation Plan + +1. Add failing unit tests for timezone awareness, observation availability, + review cutoff, normalized UTC output, and stable blocker vocabulary. +2. Add failing normalization, staged validation/preview, DCF-lineage, and + commercial-apply integration tests using the same temporal cases. +3. Add a shared daily-price temporal review module and route every consumer + through it without changing source-rights or field-scope decisions. +4. Make explicit normalization retrieval require the exact review cutoff and + fail before writing on invalid temporal evidence. +5. Refactor staged preview/apply so apply carries the one validated frame rather + than reading the staged CSV twice. +6. Replace direct canonical writes with a flushed, fsynced, same-directory + temporary file and atomic replace; retain the optional reviewed backup. +7. Update Make targets, methodology, provenance, operator docs, ROADMAP, and the + continuation contract without running readiness or writing repository data. +8. Run focused/full tests and all required product, commercial, pilot, PR-range, + whitespace, generated-artifact, and staged-hygiene checks; commit, push, + update draft PR #113, and verify hosted CI on the exact head. diff --git a/docs/superpowers/plans/2026-07-20-sec-quarterly-cash-generation-pilot.md b/docs/superpowers/plans/2026-07-20-sec-quarterly-cash-generation-pilot.md new file mode 100644 index 00000000..bb756e1d --- /dev/null +++ b/docs/superpowers/plans/2026-07-20-sec-quarterly-cash-generation-pilot.md @@ -0,0 +1,620 @@ +# SEC Quarterly Cash-Generation Pilot 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:** Prove one real NVIDIA quarter can pass the existing quarterly cash-generation adapter review from exact SEC Companyfacts, submissions, and filed-table evidence without persisting data or changing readiness. + +**Architecture:** A pure parser selects one exact accession and three-month context from already-retrieved SEC payloads, matches each structured fact to its inline XBRL filing fact, and requires explicit filed-table outflow presentation before making capex negative. A separate read-only client/CLI fetches only the three exact SEC endpoints, renders a human review summary, and composes the existing acceptance contract; source field scope and production activation remain independent. + +**Tech Stack:** Python 3.12 standard library (`dataclasses`, `html.parser`, `json`, `urllib`), existing immutable earnings/cash-generation/source-rights contracts, pytest, YAML source registry, Make read-only operator target. + +## Global Constraints + +- Research-only; no investment advice, broker integration, order routing, auto-trading, buy/sell instruction, or post-earnings price prediction. +- The pilot is one exact NVIDIA Q1 FY2027 evidence path, not broad coverage. +- Use `sec_companyfacts` as the exact registered source ID; do not split, alias, or compose source IDs. +- Require accession `0001045810-26-000052`, period `2027-Q1`, start `2026-01-26`, end `2026-04-26`, and exact SEC submissions acceptance time for the first live proof. +- Companyfacts supplies fact identity and magnitude; the exact filed inline XBRL table must independently prove the capex outflow presentation. +- Never infer capex sign from concept name, taxonomy balance, history, or an unsigned Companyfacts magnitude. +- Never derive Q4 from annual or nine-month values; the initial live pilot is Q1 only. +- Never substitute filing-date midnight or retrieval time for publication time. +- `accepted_for_review` never means production activation, readiness promotion, source-wide coverage, reviewer validation, or market validation. +- `production_activation` remains `False`; `readiness_promotions` remains `()`. +- Do not run `make readiness`, broad refresh, apply, or generated report commands. +- Do not create or modify CSV, JSON, report, sample-report, screenshot, timing, canonical-data, or manual-review artifacts. +- Unit tests use minimal in-memory SEC-shaped fixtures and make no network request. +- Stage exact intentional paths only; never use `git add -A`. +- Push only `codex/personal-research-mode-mvp`; keep PR #113 draft; do not merge or deploy. + +--- + +### Task 1: Pure Exact-Accession SEC Parser + +**Files:** +- Create: `src/sec_quarterly_cash_generation_pilot.py` +- Create: `tests/test_sec_quarterly_cash_generation_pilot.py` + +**Interfaces:** +- Consumes: `ticker: str`, `cik: str`, `fiscal_period: str`, `period_start_date: str`, `period_end_date: str`, `accession: str`, in-memory Companyfacts/submissions/filing HTML payloads, `retrieved_at: str`, `as_of: str`, and an explicit source-rights registry. +- Produces: frozen `SecQuarterlyPilotExtraction`, frozen `SecQuarterlyPilotPreview`, `extract_sec_quarterly_cash_generation(...)`, and `preview_sec_quarterly_cash_generation(...)`. + +- [ ] **Step 1: Write the first failing exact-quarter test** + +Create minimal Companyfacts, submissions, and inline-XBRL fixtures in `tests/test_sec_quarterly_cash_generation_pilot.py`. Use the real concept names and NVIDIA-shaped values while labeling the payload as a test fixture: + +```python +def test_exact_q1_payload_builds_source_backed_components_and_revenue(): + result = extract_sec_quarterly_cash_generation( + ticker="NVDA", + cik="0001045810", + fiscal_period="2027-Q1", + period_start_date="2026-01-26", + period_end_date="2026-04-26", + accession="0001045810-26-000052", + companyfacts_payload=_companyfacts_fixture(), + submissions_payload=_submissions_fixture(), + filing_html=_filing_fixture(capex_outflow=True), + retrieved_at="2026-07-20T15:00:00+00:00", + as_of="2026-07-20T15:00:00+00:00", + ) + + assert result.blockers == () + assert result.accepted_at == "2026-05-20T20:35:52+00:00" + assert [row.metric for row in result.observations] == [ + "operating_income", + "cash_from_operations", + "capital_expenditures", + ] + assert [row.value for row in result.observations] == [ + 53_536_000_000.0, + 50_344_000_000.0, + -1_757_000_000.0, + ] + assert result.revenue_actuals[0].revenue_actual == 81_615_000_000.0 + assert result.capex_sign_evidence == "explicit_filed_table_outflow" + assert result.source_url.endswith("/nvda-20260426.htm") +``` + +Fixture facts must use `form="10-Q"`, `fy=2027`, `fp="Q1"`, unit `USD`, the exact accession/start/end dates, and unique inline fact IDs. The submissions fixture must expose `acceptanceDateTime="2026-05-20T20:35:52.000Z"` for the same accession. + +- [ ] **Step 2: Run the exact-quarter test and verify RED** + +Run: + +```bash +python3 -m pytest tests/test_sec_quarterly_cash_generation_pilot.py::test_exact_q1_payload_builds_source_backed_components_and_revenue -q +``` + +Expected: test collection fails because `src.sec_quarterly_cash_generation_pilot` does not exist. + +- [ ] **Step 3: Add frozen result types and exact concept maps** + +Create `src/sec_quarterly_cash_generation_pilot.py` with these public shapes: + +```python +@dataclass(frozen=True) +class SecQuarterlyPilotExtraction: + ticker: str + cik: str + fiscal_period: str + period_start_date: str + period_end_date: str + accession: str + filing_date: str + accepted_at: str + source_url: str + observations: tuple[QuarterlyBusinessObservation, ...] + revenue_actuals: tuple[QuarterlyActual, ...] + capex_sign_evidence: str + blockers: tuple[str, ...] + + +@dataclass(frozen=True) +class SecQuarterlyPilotPreview: + extraction: SecQuarterlyPilotExtraction + acceptance: QuarterlyAdapterAcceptance | None + status: str + blockers: tuple[str, ...] + production_activation: bool = False + readiness_promotions: tuple[str, ...] = () +``` + +Use these ordered concept maps: + +```python +REVENUE_CONCEPTS = ( + "RevenueFromContractWithCustomerExcludingAssessedTax", + "Revenues", + "SalesRevenueNet", + "SalesRevenueGoodsNet", +) +OPERATING_INCOME_CONCEPTS = ("OperatingIncomeLoss",) +CASH_FROM_OPERATIONS_CONCEPTS = ( + "NetCashProvidedByUsedInOperatingActivities", + "NetCashProvidedByUsedInOperatingActivitiesContinuingOperations", +) +CAPEX_CONCEPTS = ( + "PaymentsToAcquireProductiveAssets", + "PaymentsToAcquirePropertyPlantAndEquipment", +) +``` + +- [ ] **Step 4: Implement exact Companyfacts and submissions selection** + +Add helpers that: + +1. inspect only `facts.us-gaap..units.USD`; +2. retain rows matching exact accession, start, end, `10-Q`/`10-Q/A`, fiscal year, and fiscal-quarter code; +3. choose the first ordered concept containing one unique exact value; +4. return `:fact_missing` or `:fact_ambiguous` rather than guessing; +5. locate the exact accession in aligned `filings.recent` arrays; +6. require matching form, filing date, primary document, and timezone-aware `acceptanceDateTime`; +7. block `acceptance_after_cutoff` when the SEC acceptance time exceeds `as_of`. + +Use `parse_utc_timestamp` for both `accepted_at` and cutoff. Normalize the SEC `Z` time through that existing function. Do not synthesize a time from `filingDate`. + +- [ ] **Step 5: Implement inline XBRL context and row parsing** + +Use only `html.parser.HTMLParser`. Track: + +- each `xbrli:context` ID and its `xbrli:startdate`/`xbrli:enddate`; +- each table row's ordered text tokens; +- every `ix:nonfraction` fact's `name`, `contextref`, `scale`, `id`, and displayed magnitude; and +- the nearest non-empty text token before and after each fact. + +An inline fact matches only when concept, exact context start/end, absolute scaled magnitude, and primary filing all match. For capex, require the immediately surrounding displayed tokens to be `(` and `)`. Return `capital_expenditures:explicit_outflow_evidence_missing` if magnitude exists but that presentation proof does not. + +Construct source references as `f"{source_url}#{inline_fact_id}"`. Create three `QuarterlyBusinessObservation` values with source `sec_companyfacts`, currency `USD`, scale `1.0`, accounting basis `reported`, duration basis `three_months`, exact acceptance time as `published_at`, exact retrieval time, and `q4_evidence_state="not_q4"`. Create one Revenue-only `QuarterlyActual` with the same definition and source lineage. + +- [ ] **Step 6: Run the exact-quarter test and verify GREEN** + +Run the Step 2 command. Expected: 1 passed. + +- [ ] **Step 7: Add failing sign, identity, ambiguity, and temporal tests** + +Add parameterized tests asserting these exact blockers: + +```python +@pytest.mark.parametrize( + ("mutation", "blocker"), + [ + ("missing_parentheses", "capital_expenditures:explicit_outflow_evidence_missing"), + ("wrong_accession", "operating_income:fact_missing"), + ("wrong_context", "capital_expenditures:inline_fact_missing"), + ("wrong_magnitude", "capital_expenditures:inline_fact_missing"), + ("ytd_context", "operating_income:fact_missing"), + ("duplicate_value", "cash_from_operations:fact_ambiguous"), + ("missing_acceptance", "submissions:accession_missing"), + ("naive_acceptance", "submissions:acceptance_time_invalid"), + ("post_cutoff", "acceptance_after_cutoff"), + ], +) +def test_extraction_failures_are_deterministic(mutation, blocker): + result = _extract_with_mutation(mutation) + assert blocker in result.blockers + assert result.observations == () + assert result.revenue_actuals == () +``` + +Add a Q4/YTD test that requests `2027-Q4` with a nine-month fact and asserts `q4_explicit_three_month_filing_required`. Add an unsupported currency test that asserts `:usd_fact_missing`. Add a filing primary-document mismatch test that asserts `filing:primary_document_mismatch`. + +- [ ] **Step 8: Run the failure tests and verify RED** + +Run: + +```bash +python3 -m pytest tests/test_sec_quarterly_cash_generation_pilot.py -q +``` + +Expected: new cases fail until blocker aggregation and fail-closed empty outputs are implemented. + +- [ ] **Step 9: Complete deterministic fail-closed extraction** + +Aggregate safely knowable blockers in insertion order with `tuple(dict.fromkeys(blockers))`. If any blocker exists, return empty observations and Revenue actuals, `capex_sign_evidence="blocked"`, and no partially usable values. Reject Q4 unless an exact three-month Companyfacts context and matching filed-table context exist; never difference annual or YTD values. + +- [ ] **Step 10: Add and satisfy preview-composition tests** + +Add: + +```python +def test_preview_composes_existing_acceptance_without_activation(): + preview = preview_sec_quarterly_cash_generation( + extraction=_successful_extraction(), + rights_registry=_sec_rights_fixture(include_cash_fields=True), + as_of="2026-07-20T15:00:00+00:00", + ) + + assert preview.status == "accepted_for_review" + assert preview.blockers == () + assert preview.acceptance is not None + assert preview.acceptance.accepted_observation_count == 3 + assert preview.production_activation is False + assert preview.readiness_promotions == () + + +def test_extraction_or_rights_blocker_never_returns_accepted_preview(): + extraction_blocked = preview_sec_quarterly_cash_generation( + extraction=_blocked_extraction(), + rights_registry=_sec_rights_fixture(include_cash_fields=True), + ) + rights_blocked = preview_sec_quarterly_cash_generation( + extraction=_successful_extraction(), + rights_registry=_sec_rights_fixture(include_cash_fields=False), + ) + assert extraction_blocked.acceptance is None + assert extraction_blocked.status == "blocked" + assert rights_blocked.status == "blocked" + assert "source_fields_missing:" in " ".join(rights_blocked.blockers) +``` + +Run: + +```bash +python3 -m pytest tests/test_sec_quarterly_cash_generation_pilot.py tests/test_quarterly_cash_generation_adapter.py tests/test_quarterly_cash_generation.py -q +``` + +Expected: all focused contract tests pass. + +- [ ] **Step 11: Commit the pure parser slice** + +```bash +git add -- src/sec_quarterly_cash_generation_pilot.py tests/test_sec_quarterly_cash_generation_pilot.py +make staged-hygiene-check +git diff --cached --check +git commit -m "Parse exact SEC quarterly cash evidence" +git push origin codex/personal-research-mode-mvp +``` + +--- + +### Task 2: Explicit SEC Field-Scope Review + +**Files:** +- Modify: `config/source_rights.yml:12-16` +- Modify: `tests/test_quarterly_cash_generation_adapter.py` +- Modify: `tests/test_sec_quarterly_cash_generation_pilot.py` + +**Interfaces:** +- Consumes: existing exact-source `commercial_eligibility` and `REQUIRED_SOURCE_FIELDS` contracts. +- Produces: explicit checked-in `sec_companyfacts` support for `operating_income`, `cash_from_operations`, and `capital_expenditures`; no change to another source or to commercial-use status. + +- [ ] **Step 1: Change the existing blocker test to require exact SEC acceptance** + +Replace `test_checked_in_sec_rights_do_not_silently_claim_component_support` with: + +```python +def test_checked_in_sec_rights_explicitly_support_reviewed_cash_components(): + rows = [ + _observation( + metric=row.metric, + value=row.value, + source="sec_companyfacts", + source_ref=f"https://www.sec.gov/Archives/edgar/data/1/example.htm#{row.metric}", + ) + for row in _complete_rows() + ] + + result = _assess( + rows, + source_id="sec_companyfacts", + rights_registry=load_source_rights_registry(), + ) + + assert result.status == "accepted_for_review" + assert result.rights_status == "approved" + assert result.production_activation is False + assert result.readiness_promotions == () +``` + +- [ ] **Step 2: Run the changed test and verify RED** + +Run: + +```bash +python3 -m pytest tests/test_quarterly_cash_generation_adapter.py::test_checked_in_sec_rights_explicitly_support_reviewed_cash_components -q +``` + +Expected: FAIL with the three existing `source_fields_missing` values. + +- [ ] **Step 3: Extend only the exact SEC supported-field list** + +Add these literal entries under `sec_companyfacts.supported_fields`: + +```yaml + - operating_income + - cash_from_operations + - capital_expenditures +``` + +Do not change `commercial_use`, redistribution, storage, attribution, authentication, rate limits, fallback priority, or any other source. + +- [ ] **Step 4: Prove scope independence and verify GREEN** + +Add a test that builds a separate approved registry missing `capital_expenditures` and asserts acceptance remains blocked with `source_fields_missing:capital_expenditures`. Run: + +```bash +python3 -m pytest tests/test_quarterly_cash_generation_adapter.py tests/test_commercial_source_rights.py tests/test_sec_quarterly_cash_generation_pilot.py -q +``` + +Expected: all focused rights and pilot tests pass. + +- [ ] **Step 5: Commit the field-scope decision** + +```bash +git add -- config/source_rights.yml tests/test_quarterly_cash_generation_adapter.py tests/test_sec_quarterly_cash_generation_pilot.py +make staged-hygiene-check +git diff --cached --check +git commit -m "Review SEC cash generation field scope" +git push origin codex/personal-research-mode-mvp +``` + +--- + +### Task 3: Read-Only Live Preview Command + +**Files:** +- Create: `src/sec_quarterly_cash_generation_preview.py` +- Create: `tests/test_sec_quarterly_cash_generation_preview.py` +- Modify: `Makefile` + +**Interfaces:** +- Consumes: Task 1 parser/preview, checked-in rights registry, exact SEC Companyfacts URL, exact SEC submissions URL, and exact accession primary-document URL. +- Produces: `fetch_sec_quarterly_pilot_payloads(...)`, `render_sec_quarterly_pilot_preview(...)`, `main(argv=None) -> int`, and read-only Make target `sec-quarterly-cash-preview`. + +- [ ] **Step 1: Write failing client and renderer tests** + +Create `tests/test_sec_quarterly_cash_generation_preview.py` with an injected fetcher that records URLs and returns in-memory bytes. Assert: + +```python +def test_client_fetches_only_three_exact_sec_endpoints_without_cache(tmp_path, monkeypatch): + seen = [] + payloads = fetch_sec_quarterly_pilot_payloads( + cik="0001045810", + accession="0001045810-26-000052", + primary_document="nvda-20260426.htm", + user_agent="Research Test test@example.com", + fetcher=_recording_fetcher(seen), + ) + assert seen == [ + "https://data.sec.gov/api/xbrl/companyfacts/CIK0001045810.json", + "https://data.sec.gov/submissions/CIK0001045810.json", + "https://www.sec.gov/Archives/edgar/data/1045810/000104581026000052/nvda-20260426.htm", + ] + assert not list(tmp_path.iterdir()) + assert set(payloads) == {"companyfacts", "submissions", "filing_html"} + + +def test_renderer_is_human_readable_and_keeps_non_activation_visible(): + text = render_sec_quarterly_pilot_preview(_accepted_preview()) + assert "status: accepted_for_review" in text + assert "NVIDIA Q1 FY2027" in text + assert "capex sign evidence: explicit_filed_table_outflow" in text + assert "production activation: false" in text + assert "readiness promotions: none" in text + assert "generated artifacts: none" in text + assert not text.lstrip().startswith("{") +``` + +- [ ] **Step 2: Run the preview tests and verify RED** + +Run: + +```bash +python3 -m pytest tests/test_sec_quarterly_cash_generation_preview.py -q +``` + +Expected: collection fails because the preview module does not exist. + +- [ ] **Step 3: Implement the no-cache SEC client** + +Use `urllib.request.Request`/`urlopen`, 30-second timeout, `User-Agent`, `Accept`, and no filesystem API. Require a non-blank explicit argument or `SEC_USER_AGENT`; otherwise raise the existing `SECUserAgentError`. Decode Companyfacts and submissions through `json.loads`; keep filing HTML as text. Convert HTTP, URL, decode, and JSON failures into one stable `SecQuarterlyPreviewFetchError` naming only the failed endpoint class, not credentials. + +- [ ] **Step 4: Implement deterministic rendering and CLI arguments** + +The CLI must require or default to these exact pilot values: + +```text +--ticker NVDA +--cik 0001045810 +--fiscal-period 2027-Q1 +--period-start 2026-01-26 +--period-end 2026-04-26 +--accession 0001045810-26-000052 +--primary-document nvda-20260426.htm +--as-of +``` + +The command loads `config/source_rights.yml`, fetches the exact three endpoints, parses and assesses them, prints only the human renderer, and returns `0` for `accepted_for_review` or `2` for blocked/fetch-error states. It must expose no output-file, JSON, apply, activation, refresh, readiness, or fallback argument. + +- [ ] **Step 5: Add the read-only Make target and prove its surface** + +Add: + +```make +.PHONY: sec-quarterly-cash-preview +sec-quarterly-cash-preview: + @python3 -m src.sec_quarterly_cash_generation_preview \ + --ticker "$(or $(TICKER),NVDA)" \ + --cik "$(or $(CIK),0001045810)" \ + --fiscal-period "$(or $(FISCAL_PERIOD),2027-Q1)" \ + --period-start "$(or $(PERIOD_START),2026-01-26)" \ + --period-end "$(or $(PERIOD_END),2026-04-26)" \ + --accession "$(or $(ACCESSION),0001045810-26-000052)" \ + --primary-document "$(or $(PRIMARY_DOCUMENT),nvda-20260426.htm)" \ + --as-of "$(AS_OF)" +``` + +Add a test requiring `AS_OF` and scanning the module/target for the absence of `write_text`, `open(`, `Path(`, `--output`, `make readiness`, `apply`, cache paths, and provider fallbacks. + +- [ ] **Step 6: Verify preview behavior with injected payloads** + +Run: + +```bash +python3 -m pytest tests/test_sec_quarterly_cash_generation_preview.py tests/test_sec_quarterly_cash_generation_pilot.py tests/test_sec_companyfacts.py -q +``` + +Expected: all preview, parser, and SEC provider regression tests pass without network access. + +- [ ] **Step 7: Run the one permitted live NVIDIA preview** + +Run only after confirming `SEC_USER_AGENT` is configured: + +```bash +make sec-quarterly-cash-preview AS_OF=2026-07-20T23:59:59-04:00 +``` + +Expected evidence: + +```text +status: accepted_for_review +accession: 0001045810-26-000052 +accepted at: 2026-05-20T20:35:52+00:00 +operating income: 53536000000.0 USD +cash from operations: 50344000000.0 USD +capital expenditures: -1757000000.0 USD +capex sign evidence: explicit_filed_table_outflow +production activation: false +readiness promotions: none +generated artifacts: none +``` + +Immediately run `git status --short` and `make diff-hygiene-summary`. Any newly written generated artifact is a failure; do not stage it. + +- [ ] **Step 8: Commit the live-preview capability** + +```bash +git add -- src/sec_quarterly_cash_generation_preview.py tests/test_sec_quarterly_cash_generation_preview.py Makefile +make staged-hygiene-check +git diff --cached --check +git commit -m "Add read-only SEC cash generation preview" +git push origin codex/personal-research-mode-mvp +``` + +--- + +### Task 4: Documentation, Roadmap, Continuation, And PR Evidence + +**Files:** +- Modify: `docs/METHODOLOGY.md` +- Modify: `docs/PROVENANCE_CONTRACT.md` +- Modify: `docs/DATA_STRATEGY.md` +- Modify: `docs/PERSONAL_RESEARCH_MODE.md` +- Modify: `ROADMAP.md` +- Modify: `docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md` +- Modify: `tests/test_public_v1_release_docs.py` + +**Interfaces:** +- Consumes: verified parser, field-scope, command, and live-preview evidence from Tasks 1–3. +- Produces: truthful public/internal boundaries, next-stage instructions, and draft PR #113 evidence. + +- [ ] **Step 1: Write the failing documentation contract test** + +Add a test asserting all of these literal distinctions: + +```python +def test_sec_cash_generation_pilot_docs_preserve_review_boundary(): + methodology = _read("docs/METHODOLOGY.md") + provenance = _read("docs/PROVENANCE_CONTRACT.md") + strategy = _read("docs/DATA_STRATEGY.md") + personal = _read("docs/PERSONAL_RESEARCH_MODE.md") + roadmap = _read("ROADMAP.md") + prompt = _read("docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md") + + assert "explicit_filed_table_outflow" in methodology + assert "acceptanceDateTime" in provenance + assert "sec_companyfacts" in strategy + assert "accepted_for_review is not production activation" in personal + assert "NVIDIA Q1 FY2027" in roadmap + assert "does not activate Company Workbench" in roadmap + assert "sec-quarterly-cash-preview" in prompt + assert "do not repeat the NVIDIA pilot" in prompt +``` + +- [ ] **Step 2: Run the documentation test and verify RED** + +Run: + +```bash +python3 -m pytest tests/test_public_v1_release_docs.py::test_sec_cash_generation_pilot_docs_preserve_review_boundary -q +``` + +Expected: failure because the pilot evidence and continuation boundary are not documented. + +- [ ] **Step 3: Update methodology, provenance, and source strategy** + +Document the exact-quarter/accession selection, exact submissions acceptance timestamp, inline-XBRL fact/context/magnitude match, filed-table parentheses requirement, negative capex only after explicit outflow proof, and existing FCF formula. State that the source-rights edit is a field-scope review under the existing SEC record, not a new legal opinion or entitlement. + +- [ ] **Step 4: Update user guidance and ROADMAP** + +Record NVIDIA Q1 FY2027 as one official-source adapter preview accepted for review. State exactly that it does not activate Company Workbench, rebuild readiness, prove other companies/quarters, supply point-in-time consensus, calibrate probabilities, establish hosted reliability, replace an independent reviewer, or validate product-market fit. + +Move the quarterly adapter roadmap state from `external_source_and_review_required` to `one_company_source_preview_accepted_for_review`; retain separate `production_activation_required` and `broader_coverage_unproven` gates. + +- [ ] **Step 5: Update the persistent continuation prompt** + +Set expected HEAD to the latest verified descendant. Add the completed pilot evidence and the stop rule `do not repeat the NVIDIA pilot unless its source filing changes or a regression is suspected`. Set the exact next executable stage to a separately designed Company Workbench activation preview or a second-company portability proof; do not authorize either automatically, generated writes, readiness rebuild, broad refresh, consensus fabrication, or hosted deployment. + +- [ ] **Step 6: Run documentation and focused product checks** + +Run: + +```bash +python3 -m pytest tests/test_public_v1_release_docs.py tests/test_sec_quarterly_cash_generation_pilot.py tests/test_sec_quarterly_cash_generation_preview.py tests/test_quarterly_cash_generation_adapter.py tests/test_quarterly_cash_generation.py tests/test_sec_companyfacts.py -q +make public-wording-check +git diff --check +``` + +Expected: all checks pass. + +- [ ] **Step 7: Run the complete non-writing verification matrix** + +Run in this order: + +```bash +python3 -m pytest tests -q +make dashboard-smoke +make research-dashboard-render-smoke +make public-wording-check +make public-check +make commercial-beta-check +make commercial-beta-release-check +make pilot-readiness-check TOP_N=10 +make diff-hygiene-summary +make pr-range-hygiene-check BASE_SHA=origin/main HEAD_SHA=HEAD +git diff --check +``` + +Do not run `make readiness`. If a gate writes an unexpected generated artifact, stop, classify it, and keep it unstaged. + +- [ ] **Step 8: Stage exact documentation and commit** + +```bash +git add -- docs/METHODOLOGY.md docs/PROVENANCE_CONTRACT.md docs/DATA_STRATEGY.md docs/PERSONAL_RESEARCH_MODE.md ROADMAP.md docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md tests/test_public_v1_release_docs.py +make staged-hygiene-check +git diff --cached --check +git commit -m "Document SEC cash generation pilot evidence" +git push origin codex/personal-research-mode-mvp +``` + +- [ ] **Step 9: Update draft PR #113 without changing draft state** + +Confirm exact-head PR state first: + +```bash +gh pr view 113 --json number,state,isDraft,mergeable,headRefName,headRefOid,statusCheckRollup,url +``` + +Update the PR body with a concise section naming the three source endpoints, NVIDIA accession/period, exact accepted values, capex-sign evidence, focused/full check results, zero generated artifacts, and remaining activation/coverage/consensus/calibration/hosted/reviewer/market gates. Keep `isDraft=true`; do not merge. + +- [ ] **Step 10: Final repository truth check** + +Run: + +```bash +git status --short --branch +git rev-list --left-right --count origin/codex/personal-research-mode-mvp...HEAD +gh pr view 113 --json state,isDraft,mergeable,headRefOid,statusCheckRollup,url +``` + +Expected: clean worktree, `0 0` branch alignment, PR open/draft, exact head equal to local HEAD, and no generated artifacts staged. diff --git a/docs/superpowers/plans/2026-07-21-company-workbench-answer-first-handoff.md b/docs/superpowers/plans/2026-07-21-company-workbench-answer-first-handoff.md new file mode 100644 index 00000000..233a0e03 --- /dev/null +++ b/docs/superpowers/plans/2026-07-21-company-workbench-answer-first-handoff.md @@ -0,0 +1,464 @@ +# Company Workbench Answer-First Handoff 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:** Put the truthful selected-company answer and ticker-specific Data Health handoff inside the first `390x844` Company Workbench viewport without changing readiness, evidence, or report content. + +**Architecture:** Extend the existing research workspace header with an opt-in compact form used only by Company Workbench. Create a Streamlit answer placeholder directly after that header and pass it to `render_single_stock_report`, whose existing fast and final summary paths will render through one target-aware helper while all other callers keep the current direct-render behavior. + +**Tech Stack:** Python 3, Streamlit, pandas, pytest, HTML/CSS contracts, local browser QA, GitHub draft PR checks. + +## Global Constraints + +- Research-only; no investment advice or direct buy/sell instruction. +- No broker integration, order routing, auto-trading, or post-earnings price prediction. +- Candidate context cannot alter deterministic forecasts or become trusted evidence. +- Actuals, consensus, Revenue, EPS, operating margin, free cash flow, FCF margin, valuation, peer, catalyst, outcome, backtest, and calibration readiness remain independent. +- Missing evidence remains withheld. +- EPS split basis remains unverified without explicit proof. +- Q4 requires explicit SEC-filed three-month table evidence. +- Synthetic fixtures remain test-only. +- Empty valuation, catalyst, outcome, consensus, and field-proof ledgers remain empty. +- Do not run `make readiness` or introduce generated CSV, JSON, report, sample-report, screenshot, timing, or canonical-data artifacts. +- Keep PR #113 open and draft; push only `codex/personal-research-mode-mvp`; do not merge or deploy publicly. +- Stage exact intentional files only; never use `git add -A`. + +--- + +## File Map + +- `src/research_workspace.py`: builds the semantic Personal Research workspace header HTML and owns the opt-in compact-header contract. +- `src/dashboard.py`: owns header styling, Company Workbench route order, and fast/final selected-answer rendering. +- `src/public_performance_gate.py`: defines the route markers used by the non-writing performance contract. +- `tests/test_research_workspace.py`: verifies full and compact header semantics. +- `tests/test_research_mode_dashboard_contract.py`: verifies answer-slot placement, report wiring, compact route scope, and navigation order. +- `tests/test_dashboard_helpers.py`: verifies both fast and final summaries use the target-aware renderer without duplicate direct output. +- `tests/test_dashboard_render_smoke.py`: verifies one final selected answer and the ticker-preserving Data Health handoff in a rendered Workbench run. +- `tests/test_public_performance_gate.py`: verifies the Workbench gate uses the retained semantic page identity rather than the removed redundant heading. +- `src/dashboard_render_smoke.py`: updates the rendered-route marker to the retained `Company Workbench` heading if the smoke contract depends on `Selected Company`. +- `src/browser_qa_evidence.py` and `tests/test_browser_qa_evidence.py`: update only static Workbench first-view wording that refers to the removed heading; do not claim screenshot evidence from these contracts. +- `ROADMAP.md`: records the verified answer-first usability slice and preserves external blockers. +- `docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md`: advances exact-HEAD continuation truth and next executable lane. +- `docs/superpowers/specs/2026-07-21-company-workbench-answer-first-handoff-design.md`: changes the design status from pending review to approved and records implementation evidence after verification. + +## Execution Note + +Live desktop verification exposed one inaccurate design assumption: Personal Research mode did not load the Public-mode CSS that supplied the selected answer's multi-column layout. The unstyled answer remained semantic but rendered as a block. A test-first correction adds an explicit `research` summary class and scoped Personal Research desktop/phone styles in `src/dashboard.py`; it does not load the Public shell, change other routes, or alter answer data. The final phone layout is shorter than the unstyled baseline and keeps the Data Health link, stop condition, Review path, and lane-coverage control inside the first `390x844` viewport. + +### Task 1: Compact Workbench Header Contract + +**Files:** +- Modify: `src/research_workspace.py:632-656` +- Modify: `src/dashboard.py:34190-34335` +- Test: `tests/test_research_workspace.py:665-690` +- Test: `tests/test_research_mode_dashboard_contract.py:398-435` + +**Interfaces:** +- Consumes: `research_workspace_header_html(page_title, *, ticker, profile_label, freshness, primary_action)`. +- Produces: `research_workspace_header_html(..., compact: bool = False) -> str` and `render_research_workspace_header(..., compact: bool = False) -> None`. + +- [ ] **Step 1: Add the failing compact-header unit contract** + +```python +def test_compact_research_workspace_header_keeps_identity_scope_and_boundary_without_duplicate_meta(): + rendered = research_workspace_header_html( + "Company Workbench", + ticker="NVDA", + profile_label="Local Research", + freshness="Current through 2026-07-16", + primary_action="Review source-backed sections", + compact=True, + ) + + assert "research-workspace-header compact" in rendered + assert "

Company Workbench

" in rendered + assert "NVDA" in rendered + assert "Local Research" in rendered + assert "Research-only" in rendered + assert "investment advice" in rendered + assert "Current through 2026-07-16" not in rendered + assert "Review source-backed sections" not in rendered + assert "research-workspace-meta" not in rendered +``` + +- [ ] **Step 2: Run the focused header tests and verify the new test fails** + +Run: `python3 -m pytest tests/test_research_workspace.py -q` + +Expected: FAIL because `research_workspace_header_html` does not accept `compact`. + +- [ ] **Step 3: Implement the opt-in compact HTML contract** + +Add `compact: bool = False`. Build the opening class as `research-workspace-header compact` only when requested, and make the existing `
...
` an empty string in compact mode. Preserve the current output byte-for-byte for the default full header except for local expression assembly. + +```python +def research_workspace_header_html( + page_title: str, + *, + ticker: str = "", + profile_label: str, + freshness: str, + primary_action: str, + compact: bool = False, +) -> str: + scope = str(ticker or "Focused research scope").strip().upper() if ticker else "Focused research scope" + header_class = "research-workspace-header compact" if compact else "research-workspace-header" + meta_html = "" if compact else ( + "
" + f"
Freshness
{html.escape(str(freshness or 'Check saved readiness'))}
" + f"
Next action
{html.escape(str(primary_action or 'Review source-backed evidence'))}
" + "
" + ) + return ( + f"
" + "
" + "Personal research mode" + f"

{html.escape(str(page_title or 'Research Desk'))}

" + f"

{html.escape(scope)} · {html.escape(str(profile_label or 'Local research'))}

" + "
" + f"{meta_html}" + "

Research-only. Not investment advice; no trade instruction is produced.

" + "
" + ) +``` + +- [ ] **Step 4: Wire the dashboard wrapper and compact CSS without changing other routes** + +Add `compact: bool = False` to `render_research_workspace_header`, pass it to the pure helper, and add only these scoped rules to the existing style block: + +```css +.research-workspace-header.compact { padding: .72rem .9rem; margin-bottom: .55rem; } +.research-workspace-header.compact .research-workspace-boundary { margin-top: .35rem; } +``` + +The Company Workbench call will opt in during Task 2. Research Desk, Discover, Monitor, Data Health, and Proof History keep the default. + +- [ ] **Step 5: Run focused tests and verify they pass** + +Run: `python3 -m pytest tests/test_research_workspace.py tests/test_research_mode_dashboard_contract.py -q` + +Expected: PASS for the compact helper and all unchanged full-header contracts. + +### Task 2: Anchored Selected-Answer Renderer + +**Files:** +- Modify: `src/dashboard.py:30178-30420` +- Test: `tests/test_dashboard_helpers.py:30195-30245` +- Test: `tests/test_dashboard_helpers.py:30625-30650` + +**Interfaces:** +- Consumes: an optional Streamlit DeltaGenerator compatible with `.markdown(body, unsafe_allow_html=True)`. +- Produces: `render_single_stock_public_summary(frame: pd.DataFrame, *, research_mode: bool, selected_answer_target=None) -> None` and `render_single_stock_report(..., selected_answer_target=None) -> None`. + +- [ ] **Step 1: Add failing source contracts for fast and final target use** + +Update the existing direct-route summary tests to require: + +```python +assert "selected_answer_target=None" in report_signature +assert "render_single_stock_public_summary(" in report_chunk +assert "selected_answer_target=selected_answer_target" in report_chunk +assert report_chunk.count("single_stock_public_summary_html(") == 0 +``` + +Add an isolated helper contract using a fake target and monkeypatched `st.markdown`: + +```python +def test_single_stock_public_summary_uses_selected_target_when_supplied(monkeypatch): + calls = [] + target = SimpleNamespace(markdown=lambda body, **kwargs: calls.append(("target", body, kwargs))) + monkeypatch.setattr(dashboard.st, "markdown", lambda body, **kwargs: calls.append(("global", body, kwargs))) + + dashboard.render_single_stock_public_summary( + dashboard.single_stock_one_answer_frame({"ticker": "NVDA", "status": "partial"}), + research_mode=True, + selected_answer_target=target, + ) + + assert [kind for kind, _, _ in calls] == ["target"] + assert "mode=research&page=data-health&ticker=NVDA" in calls[0][1] +``` + +- [ ] **Step 2: Run the focused helper tests and verify the new contracts fail** + +Run: `python3 -m pytest tests/test_dashboard_helpers.py -q -k 'fast_public_single_stock or public_summary_uses_selected_target or simplified_review_sections'` + +Expected: FAIL because the target-aware helper and report parameter do not exist. + +- [ ] **Step 3: Add the target-aware renderer and optional report argument** + +Place this helper immediately after `single_stock_public_summary_html`: + +```python +def render_single_stock_public_summary( + frame: pd.DataFrame, + *, + research_mode: bool, + selected_answer_target=None, +) -> None: + rendered = single_stock_public_summary_html( + frame, + target_mode=RESEARCH_MODE if research_mode else "public", + ) + target = selected_answer_target if selected_answer_target is not None else st + target.markdown(rendered, unsafe_allow_html=True) +``` + +Add `selected_answer_target=None` to `render_single_stock_report`. Replace both the fast and final `st.markdown(single_stock_public_summary_html(...))` branches with: + +```python +render_single_stock_public_summary( + fast_answer_frame, + research_mode=research_mode, + selected_answer_target=selected_answer_target, +) +``` + +and: + +```python +render_single_stock_public_summary( + single_answer_frame, + research_mode=research_mode, + selected_answer_target=selected_answer_target, +) +``` + +- [ ] **Step 4: Run focused helper tests and verify they pass** + +Run: `python3 -m pytest tests/test_dashboard_helpers.py -q -k 'fast_public_single_stock or public_summary_uses_selected_target or simplified_review_sections'` + +Expected: PASS; public/operator callers without a target still render through `st.markdown`. + +### Task 3: Company Workbench Answer-First Route + +**Files:** +- Modify: `src/dashboard.py:34404-34448` +- Test: `tests/test_research_mode_dashboard_contract.py:138-155` +- Test: `tests/test_research_mode_dashboard_contract.py:417-435` +- Test: `tests/test_dashboard_render_smoke.py` + +**Interfaces:** +- Consumes: `render_research_workspace_header(..., compact=True)` and `render_single_stock_report(..., selected_answer_target=selected_answer_target)`. +- Produces: one `st.empty()` answer slot declared before both collapsed controls; no redundant `Selected Company` heading. + +- [ ] **Step 1: Replace the obsolete order tests with failing answer-first contracts** + +```python +def test_company_workbench_anchors_answer_before_collapsed_navigation_and_passes_target_to_report(): + source = dashboard.Path(dashboard.__file__).read_text(encoding="utf-8") + start = source.index("def render_company_workbench(") + end = source.index("\ndef main()", start) + workbench = source[start:end] + + header = workbench.index("render_research_workspace_header(") + target = workbench.index("selected_answer_target = st.empty()", header) + review = workbench.index('with st.expander("Review path", expanded=False):', target) + coverage = workbench.index('with st.expander("Advanced: selected-company lane coverage", expanded=False):', review) + report = workbench.index("render_single_stock_report(", coverage) + + assert header < target < review < coverage < report + assert "compact=True" in workbench[header:target] + assert "selected_answer_target=selected_answer_target" in workbench[report:] + assert 'st.markdown("### Selected Company")' not in workbench +``` + +Update the render smoke to assert the generated HTML contains exactly one `aria-label='Selected ticker answer'` or its double-quoted equivalent and the ticker-specific `?mode=research&page=data-health&ticker=AVGO` handoff. + +- [ ] **Step 2: Run route and smoke tests and verify the new order contract fails** + +Run: `python3 -m pytest tests/test_research_mode_dashboard_contract.py tests/test_dashboard_render_smoke.py -q` + +Expected: FAIL because the route has no answer placeholder, still includes the redundant heading, and does not pass a target. + +- [ ] **Step 3: Implement the approved Workbench placement** + +Change only the Workbench route: + +```python +render_research_workspace_header( + "Company Workbench", + context, + ticker=ticker, + primary_action="Review usable evidence, then record what remains uncertain", + compact=True, +) +selected_answer_target = st.empty() +section_names = [section["title"] for section in company_workbench_section_contract()] +with st.expander("Review path", expanded=False): + st.caption(" -> ".join(section_names[:-1])) +with st.expander("Advanced: selected-company lane coverage", expanded=False): + ... +render_single_stock_report( + ..., + selected_answer_target=selected_answer_target, +) +``` + +Do not move or alter `What Changed`, Business Trend, Valuation, Forward View, withheld states, Advanced evidence, or report data loading. + +- [ ] **Step 4: Run focused route, workspace, helper, and smoke tests** + +Run: `python3 -m pytest tests/test_research_workspace.py tests/test_research_mode_dashboard_contract.py tests/test_dashboard_helpers.py tests/test_dashboard_render_smoke.py -q` + +Expected: PASS. + +### Task 4: Performance And Browser-QA Contract Alignment + +**Files:** +- Modify: `src/public_performance_gate.py:135-155` +- Modify: `tests/test_public_performance_gate.py:35-80` +- Modify if required: `src/dashboard_render_smoke.py:95-115` +- Modify if required: `src/browser_qa_evidence.py:290-360` +- Modify if required: `tests/test_browser_qa_evidence.py` + +**Interfaces:** +- Consumes: retained semantic marker `Company Workbench` and selected-answer ARIA label. +- Produces: non-writing gates that no longer depend on the removed `Selected Company` heading. + +- [ ] **Step 1: Add failing marker expectations** + +Update the Workbench performance contract expectation to: + +```python +assert RESEARCH_ROUTE_SPECS[2].first_useful_marker == "Company Workbench" +assert RESEARCH_ROUTE_SPECS[2].full_markers[0] == "Company Workbench" +assert "Selected Company" not in RESEARCH_ROUTE_SPECS[2].full_markers +``` + +Update render-smoke/browser-QA static marker expectations only where they require the removed heading; retain all readiness and Advanced lane-coverage markers. + +- [ ] **Step 2: Run performance and browser-QA contract tests and verify they fail before source updates** + +Run: `python3 -m pytest tests/test_public_performance_gate.py tests/test_browser_qa_evidence.py -q` + +Expected: FAIL on obsolete `Selected Company` route markers. + +- [ ] **Step 3: Replace only obsolete markers in source contracts** + +In `RESEARCH_ROUTE_SPECS`, set the Workbench first-useful marker and first full marker to `Company Workbench`. Make the same narrow change in render-smoke or browser-QA contracts only if focused failures prove they rely on the removed heading. + +- [ ] **Step 4: Run focused gate tests and verify they pass** + +Run: `python3 -m pytest tests/test_public_performance_gate.py tests/test_browser_qa_evidence.py -q` + +Expected: PASS without generating timing or screenshot artifacts. + +### Task 5: Live Responsive Acceptance And Documentation + +**Files:** +- Modify: `ROADMAP.md` +- Modify: `docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md` +- Modify: `docs/superpowers/specs/2026-07-21-company-workbench-answer-first-handoff-design.md` + +**Interfaces:** +- Consumes: locally running Streamlit dashboard and current AVGO route. +- Produces: measured desktop and `390x844` acceptance evidence stored only under `/tmp`, plus current roadmap/continuation truth. + +- [ ] **Step 1: Run the dashboard locally without refreshing data** + +Run the existing dashboard launch command documented by the repository, using the current saved local artifacts only. Do not run readiness, broad refresh, source import, report generation, or timing artifact commands. + +Expected: the Company Workbench route opens at `/?mode=research&page=company-workbench&ticker=AVGO&open=1`. + +- [ ] **Step 2: Measure phone acceptance in the configured in-app browser** + +At `390x844`, record DOM bounding boxes for: + +```text +[aria-label="Selected ticker answer"] +the "Open Data Health" link inside that answer +the "Review path" details summary +the "Advanced: selected-company lane coverage" details summary +``` + +Expected: + +```text +selected_answer.top < review_path.top < lane_coverage.top +data_health_link.bottom <= 844 +document.documentElement.scrollWidth <= document.documentElement.clientWidth +exactly one selected-ticker answer +zero browser console errors +``` + +Save screenshots only under `/tmp/stock-research-workflow-audit-2026-07-22/`; never stage them. + +- [ ] **Step 3: Measure desktop acceptance** + +At `1280x720`, verify the selected-answer cards retain their existing multi-column layout, the Data Health handoff is visible, both navigation expanders follow the answer, and there is no horizontal overflow or browser error. + +- [ ] **Step 4: Make at most one scoped CSS correction if measurement fails** + +If the phone Data Health link extends below `844`, adjust only `.research-workspace-header.compact` padding/margin or the compact boundary margin. Do not hide the global profile strip, research boundary, withheld state, or Data Health handoff. Re-run focused tests and both viewport measurements after the correction. + +- [ ] **Step 5: Update product truth** + +In `ROADMAP.md`, record the measured answer-first Workbench improvement as locally verified while leaving consensus, calibration, source-rights, hosted preview, external reviewer, and operating gates incomplete. In the continuation prompt, record the new verified HEAD only after commit/push, keep readiness stale, forbid generated artifact churn, and set the exact next safe executable lane. Change the design status to `Approved and implemented` only after all acceptance checks pass. + +### Task 6: Full Verification, Exact Commit, Draft PR, And Exact-Head CI + +**Files:** +- Stage only the intentional code, test, plan, specification, roadmap, and continuation-prompt files changed by Tasks 1-5. +- Exclude all pre-existing generated CSV/report changes and all `/tmp` browser evidence. + +**Interfaces:** +- Consumes: verified local implementation and documentation. +- Produces: one coherent commit on `codex/personal-research-mode-mvp`, pushed draft PR #113 update, and exact-head CI evidence. + +- [ ] **Step 1: Run focused and full local verification** + +Run: + +```bash +python3 -m pytest tests/test_research_workspace.py tests/test_research_mode_dashboard_contract.py tests/test_dashboard_helpers.py tests/test_dashboard_render_smoke.py tests/test_public_performance_gate.py tests/test_browser_qa_evidence.py -q +python3 -m pytest tests -q +make dashboard-smoke +make public-wording-check +make public-check +make commercial-beta-release-check +make pilot-readiness-check TOP_N=10 +make pr-range-hygiene-check +make diff-hygiene-summary +git diff --check +``` + +Expected: all commands pass. `pilot-readiness-check` must remain truthfully blocked/stale if current evidence is stale; that expected product state must not be converted into a false pass by rebuilding generated artifacts. + +- [ ] **Step 2: Review the exact diff and generated-artifact boundary** + +Run: + +```bash +git diff -- src/research_workspace.py src/dashboard.py src/public_performance_gate.py src/dashboard_render_smoke.py src/browser_qa_evidence.py tests ROADMAP.md docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md docs/superpowers/specs/2026-07-21-company-workbench-answer-first-handoff-design.md docs/superpowers/plans/2026-07-21-company-workbench-answer-first-handoff.md +git status --short +``` + +Expected: intentional product/test/docs changes plus exactly the pre-existing 18 unstaged generated CSV/report files; no generated file is staged. + +- [ ] **Step 3: Stage exact intentional files and run staged hygiene** + +Use `git add` with the explicit paths that actually changed. Never use a wildcard or `git add -A`. + +Run: `make staged-hygiene-check` + +Expected: PASS and staged diff contains no CSV, JSON, report, sample-report, screenshot, timing, or canonical-data artifact. + +- [ ] **Step 4: Commit and push the coherent slice** + +Run: + +```bash +git commit -m "Improve Workbench answer-first handoff" +git push origin codex/personal-research-mode-mvp +``` + +Expected: local HEAD equals `origin/codex/personal-research-mode-mvp`; generated working-tree changes remain unstaged. + +- [ ] **Step 5: Update draft PR #113 and wait for exact-head CI** + +Keep the PR draft. Add a concise evidence comment covering the approved design, responsive measurements, tests, generated-artifact exclusion, and external blockers. Wait for GitHub Actions on the exact pushed SHA and report the run URL and conclusion. Do not merge or deploy. + +- [ ] **Step 6: Final audit** + +Verify branch, HEAD, upstream divergence, staged area, PR draft/mergeability, exact-head CI, and the generated-artifact exclusion. Report the branch safe for draft review only if there are no unresolved Critical or Important engineering findings and every local acceptance check passed. diff --git a/docs/superpowers/plans/2026-07-21-company-workbench-task-arbitration.md b/docs/superpowers/plans/2026-07-21-company-workbench-task-arbitration.md new file mode 100644 index 00000000..f428e2e4 --- /dev/null +++ b/docs/superpowers/plans/2026-07-21-company-workbench-task-arbitration.md @@ -0,0 +1,348 @@ +# Company Workbench Task Arbitration 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:** Give Company Workbench one deterministic next research task while retaining lane-specific blockers as non-competing context. + +**Architecture:** Add a pure arbitration helper to `src/research_workspace.py` that combines the ticker-scoped change answer with the existing ordered Research Conclusion cards. Keep `src/dashboard.py` as a thin composer, and change only presentation labels in the existing conclusion and Forward View card builders. + +**Tech Stack:** Python 3.12, Streamlit composition helpers, pytest. + +## Global Constraints + +- Do not change readiness, evidence, source rights, forecasts, scenarios, providers, or data files. +- Unresolved source-backed change outranks the ordered Research Conclusion priority. +- Missing inputs return a neutral wait condition and never invent evidence. +- Technical evidence remains under Advanced. +- No investment advice, buy/sell wording, post-earnings price prediction, probability, broker action, or data mutation. +- Do not create or stage generated CSV, JSON, report, sample-report, screenshot, timing, readiness, canonical-data, or manual-review churn. + +## Final-review contract clarification + +- `company_change_answer` carries an explicit boolean `source_backed_eligible`, set only when `event.evidence_status == "source_backed"`; the arbiter never infers eligibility from routing state, text, source references, or mapping shape. +- `company_change_answer` independently carries `change_context_kind` with exactly `none`, `snapshot_only`, or `source_backed`. Dashboard badges map from this discriminator: empty queue to `no queued change`, snapshot-only context to `snapshot evidence only`, and source-backed context to `source-backed change`. +- Eligible `open`, `still_blocked`, and `intentionally_deferred` items preserve `review_now`, `wait_for_evidence`, and `monitor` routing respectively. Blocked and deferred items preserve their non-empty wait condition, with the existing queue fallbacks when absent. +- Snapshot-only items remain in change context without a source-backed badge and cannot outrank Research Conclusion. +- Malformed arbitration inputs fail closed to the exact neutral wait task as a whole; no later card is selected after an earlier malformed card. +- The focused normal AVGO AppTest render must have zero exceptions, a scoped no-queued-change Evidence Change card without a snapshot-only badge, exactly one `ONE NEXT TASK` marker whose card title is `Add peer mappings`, one `FORWARD-VIEW LANE UNBLOCK`, and no retired uppercase `NEXT RESEARCH TASK` kicker. + +--- + +### Task 1: Pure authoritative task arbitration + +**Files:** +- Modify: `tests/test_research_workspace.py` +- Modify: `src/research_workspace.py` + +**Interfaces:** +- Consumes: `company_change_answer: Mapping[str, object]` and `conclusion_cards: Iterable[Mapping[str, object]]`. +- Produces: `company_next_research_task(change_answer, conclusion_cards) -> dict[str, object]` with `title`, `body`, `state`, and `badges`; state is one of the existing research-routing states. + +- [ ] **Step 1: Write failing arbitration tests** + +Add the import and three tests: + +```python +from src.research_workspace import company_next_research_task + + +def test_company_next_research_task_prioritizes_unresolved_source_change(): + task = company_next_research_task( + {"state": "review_now", "next_task": "Review the filed evidence."}, + [{"title": "Add peer mappings", "body": "Peer context is partial.", "state": "wait_for_evidence", "badges": ["peers"]}], + ) + assert task == { + "title": "Review the filed evidence.", + "body": "Complete this source-backed evidence review before starting another research task.", + "state": "review_now", + "badges": ["source-backed change", "research-only"], + } + + +def test_company_next_research_task_uses_ordered_conclusion_priority_without_change(): + task = company_next_research_task( + {"state": "monitor", "next_task": "Continue the current review or wait."}, + [{"title": "Add peer mappings", "body": "Peer context is partial.", "badges": ["peers"]}], + ) + assert task["title"] == "Add peer mappings" + assert task["body"] == "Peer context is partial." + assert task["state"] == "wait_for_evidence" + assert task["badges"] == ["peers", "research-only"] + + +def test_company_next_research_task_fails_closed_to_neutral_wait(): + task = company_next_research_task({}, []) + assert task["title"] == "Wait for reviewed evidence or choose another company" + assert task["state"] == "wait_for_evidence" + assert task["badges"] == ["monitor", "research-only"] +``` + +- [ ] **Step 2: Run the tests and verify the intended failure** + +Run: + +```bash +python3 -m pytest \ + tests/test_research_workspace.py::test_company_next_research_task_prioritizes_unresolved_source_change \ + tests/test_research_workspace.py::test_company_next_research_task_uses_ordered_conclusion_priority_without_change \ + tests/test_research_workspace.py::test_company_next_research_task_fails_closed_to_neutral_wait -q +``` + +Expected: collection fails because `company_next_research_task` does not exist. + +- [ ] **Step 3: Implement the pure helper** + +Import `Iterable` and `Mapping` from `collections.abc`, then add: + +```python +def company_next_research_task( + change_answer: Mapping[str, object] | None, + conclusion_cards: Iterable[Mapping[str, object]] | None, +) -> dict[str, object]: + change = dict(change_answer or {}) + if str(change.get("state") or "").strip() == "review_now": + title = str(change.get("next_task") or "").strip() + if title: + return { + "title": title, + "body": "Complete this source-backed evidence review before starting another research task.", + "state": "review_now", + "badges": ["source-backed change", "research-only"], + } + + for raw_card in tuple(conclusion_cards or ()): + card = dict(raw_card or {}) + title = str(card.get("title") or "").strip() + if not title: + continue + badges = [str(value).strip() for value in tuple(card.get("badges") or ()) if str(value).strip()] + return { + "title": title, + "body": str(card.get("body") or "").strip(), + "state": ( + str(card.get("state") or "").strip() + if str(card.get("state") or "").strip() in RESEARCH_ROUTING_STATES + else "wait_for_evidence" + ), + "badges": list(dict.fromkeys([*badges, "research-only"])), + } + + return { + "title": "Wait for reviewed evidence or choose another company", + "body": "No source-backed change or executable company task is available. Do not infer one from missing data.", + "state": "wait_for_evidence", + "badges": ["monitor", "research-only"], + } +``` + +- [ ] **Step 4: Run the focused helper tests** + +Run the Step 2 command again. + +Expected: 3 passed. + +--- + +### Task 2: Remove competing next-task labels + +**Files:** +- Modify: `tests/test_dashboard_helpers.py` +- Modify: `tests/test_forward_view.py` +- Modify: `src/dashboard.py` +- Modify: `src/forward_view.py` + +**Interfaces:** +- Consumes: `stock_report_next_step_cards(...)` and `forward_view_cards(packet)`. +- Produces: first conclusion card kicker `RESEARCH PRIORITY` with explicit existing routing state; final Forward View card kicker `FORWARD-VIEW LANE UNBLOCK`. + +- [ ] **Step 1: Tighten presentation tests** + +Extend `test_stock_report_next_step_cards_route_to_fundamentals_then_peers_then_review` after each representative call: + +```python +assert cards[0]["kicker"] == "RESEARCH PRIORITY" +assert cards[0]["state"] == "wait_for_evidence" +assert all(card["kicker"] != "NEXT STEP" for card in cards) +``` + +Change `test_forward_view_rows_keep_technical_details_separate_and_research_only` to: + +```python +assert cards[-1]["kicker"] == "FORWARD-VIEW LANE UNBLOCK" +assert all(card["kicker"] != "NEXT RESEARCH TASK" for card in cards) +``` + +- [ ] **Step 2: Run the tests and verify two label failures** + +Run: + +```bash +python3 -m pytest \ + tests/test_dashboard_helpers.py::test_stock_report_next_step_cards_route_to_fundamentals_then_peers_then_review \ + tests/test_forward_view.py::test_forward_view_rows_keep_technical_details_separate_and_research_only -q +``` + +Expected: both tests fail on the old kickers. + +- [ ] **Step 3: Change only the two presentation labels** + +In every primary card branch inside `stock_report_next_step_cards`, replace: + +```python +"kicker": "NEXT STEP", +``` + +with: + +```python +"kicker": "RESEARCH PRIORITY", +``` + +Set `"state": "wait_for_evidence"` on the fix-price, stage/review-fundamentals, and add/review-peer branches. Set `"state": "review_now"` on the ETF/context-review and full-report-review branches. Do not introduce a new routing state. + +In the appended routing card inside `forward_view_cards`, replace: + +```python +"kicker": "NEXT RESEARCH TASK", +``` + +with: + +```python +"kicker": "FORWARD-VIEW LANE UNBLOCK", +``` + +- [ ] **Step 4: Run the focused presentation tests** + +Run the Step 2 command again. + +Expected: 2 passed. + +--- + +### Task 3: Compose one Workbench task and update contracts + +**Files:** +- Modify: `tests/test_research_mode_dashboard_contract.py` +- Modify: `src/dashboard.py` +- Modify: `ROADMAP.md` +- Modify: `docs/PERSONAL_RESEARCH_MODE.md` +- Modify: `docs/DASHBOARD_QA.md` +- Modify: `docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md` + +**Interfaces:** +- Consumes: `company_next_research_task(change_answer, conclusion_cards)`. +- Produces: one `ONE NEXT TASK` card in Company Workbench, with Research Conclusion and Forward View no longer claiming separate next tasks. + +- [ ] **Step 1: Add a source contract test for thin composition** + +Add to `tests/test_research_mode_dashboard_contract.py`: + +```python +def test_company_workbench_uses_one_authoritative_task_arbitration(): + source = Path("src/dashboard.py").read_text(encoding="utf-8") + assert "company_next_research_task(" in source + assert '"kicker": "ONE NEXT TASK"' in source + assert '"title": str(authoritative_task["title"])' in source + assert source.count('"kicker": "ONE NEXT TASK"') == 1 +``` + +- [ ] **Step 2: Run the test and verify failure** + +Run: + +```bash +python3 -m pytest tests/test_research_mode_dashboard_contract.py::test_company_workbench_uses_one_authoritative_task_arbitration -q +``` + +Expected: fail because the dashboard does not call the helper. + +- [ ] **Step 3: Wire the helper into Company Workbench** + +Import `company_next_research_task` from `src.research_workspace`. In the Research Conclusion block, compute the conclusion cards once, render them, then compute the authoritative task: + +```python +conclusion_cards = stock_report_next_step_cards( + report_payload, + coverage if provider is not None and ticker else None, + peer_summary if provider is not None and ticker else None, +) +render_signal_cards(conclusion_cards, show_commands=False, variant="queue") +change_answer = company_change_answer(ticker, research_review_items) +authoritative_task = company_next_research_task(change_answer, conclusion_cards) +``` + +Render the existing `ONE NEXT TASK` card from the authoritative result: + +```python +{ + "kicker": "ONE NEXT TASK", + "title": str(authoritative_task["title"]), + "body": str(authoritative_task["body"]), + "badges": list(authoritative_task["badges"]), + "state": str(authoritative_task["state"]), + "command": "", +} +``` + +- [ ] **Step 4: Update product truth documentation** + +Add concise current-state notes stating: + +- Company Workbench now arbitrates one overall task. +- unresolved source-backed change wins; +- otherwise the existing ordered Research Conclusion priority wins; +- Forward View guidance is lane-specific, not a competing overall task; +- readiness and evidence states remain independent and unchanged. + +Update ROADMAP and the continuation prompt only with this verified local capability. Do not change any external dependency classification. + +- [ ] **Step 5: Run focused module and documentation tests** + +Run: + +```bash +python3 -m pytest \ + tests/test_research_workspace.py \ + tests/test_dashboard_helpers.py \ + tests/test_forward_view.py \ + tests/test_research_mode_dashboard_contract.py \ + tests/test_public_v1_release_docs.py -q +``` + +Expected: all pass. + +- [ ] **Step 6: Run the full verification matrix** + +Run: + +```bash +python3 -m pytest tests -q +make dashboard-smoke +make research-dashboard-render-smoke +make public-wording-check +make commercial-beta-check +make public-check +make commercial-beta-release-check +make pilot-readiness-check TOP_N=10 +make diff-hygiene-summary +make pr-range-hygiene-check +git diff --check +``` + +Expected: all commands exit 0; pilot readiness may remain truthfully blocked only by its documented external/stale gates; no generated artifact appears. + +- [ ] **Step 7: Stage exact files and run staged hygiene** + +Stage only the two spec/plan files and exact intentional code, test, and documentation paths. Never use `git add -A`. Then run: + +```bash +make staged-hygiene-check +git diff --cached --check +``` + +Expected: both pass with zero generated or manual-review candidates. + +- [ ] **Step 8: Commit, push, and update draft PR #113** + +Commit the coherent verified slice, push only `codex/personal-research-mode-mvp`, add a concise verification update to PR #113, and keep it draft. Do not merge or deploy. diff --git a/docs/superpowers/plans/2026-07-21-proof-readiness-conflict-diagnosis.md b/docs/superpowers/plans/2026-07-21-proof-readiness-conflict-diagnosis.md new file mode 100644 index 00000000..da367cdd --- /dev/null +++ b/docs/superpowers/plans/2026-07-21-proof-readiness-conflict-diagnosis.md @@ -0,0 +1,978 @@ +# Proof-Readiness Conflict Diagnosis 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 proof-readiness reconciliation require explicit ticker-level historical change evidence and separately diagnose authoritative current blockers without inferring an unknowable historical cause. + +**Architecture:** Extend the existing read-only reconciliation module rather than introducing another command. Historical applicability is derived only from structured batch fields, current blocker diagnosis is derived only from saved current inputs, and the two axes are carried through immutable rows, summary counts, CLI/JSON output, and Advanced Proof History cards. + +**Tech Stack:** Python 3.12, dataclasses, pandas, pytest, Streamlit AppTest, Make, GitHub Actions. + +## Global Constraints + +- Current saved readiness remains authoritative. +- Only a valid supporting proof whose normalized `changed_tickers` explicitly contains the ticker is ticker-level supporting evidence. +- Do not parse narrative proof fields to infer source identity, rights, field scope, provenance, payload truth, or historical cause. +- Missing, malformed, placeholder, ambiguous, or scope-only proof fails closed. +- Preserve independent readiness for fundamentals, DCF, share count, price, peer mapping, and peer valuation inputs. +- Keep detailed diagnosis in the existing CLI/JSON contract and Advanced Proof History; do not change Research Desk, Discover, Company Workbench, or Monitor. +- Do not rebuild readiness or write canonical data, proof history, CSV, JSON, reports, screenshots, timing evidence, or bytecode artifacts. +- Keep the existing 18 generated-file modifications unstaged and uncommitted. +- Stage exact files only; never use `git add -A`. +- Keep PR #113 open and draft; push only `codex/personal-research-mode-mvp`. +- Preserve all research-only, no-investment-advice, no-trading, explicit-Q4, EPS split-basis, synthetic-fixture, candidate-context, consensus, and calibration boundaries. + +--- + +## File Structure + +- Modify `src/proof_readiness_reconciliation.py`: structured applicability, blocker diagnosis, immutable row/summary fields, loader, JSON, and text rendering. +- Modify `tests/test_proof_readiness_reconciliation.py`: red-green coverage for applicability, blocker diagnosis, output, input isolation, and read-only behavior. +- Modify `src/dashboard.py`: Advanced Proof History summaries only. +- Modify `tests/test_dashboard_helpers.py`: reconciliation-card contract and fixture updates. +- Modify `tests/test_dashboard_render_smoke.py` only if an existing constructor or render marker requires an explicit update. +- Modify `ROADMAP.md`: implemented result and current-snapshot counts. +- Modify `docs/OPERATOR_GUIDE.md`: two-axis interpretation and safe next-review boundary. +- Modify `docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md`: continuation rule and next maturity stage. +- Modify `tests/test_public_v1_release_docs.py`: durable documentation contract. + +--- + +### Task 1: Ticker-Level Applicability And Current Blocker Model + +**Files:** +- Modify: `tests/test_proof_readiness_reconciliation.py` +- Modify: `src/proof_readiness_reconciliation.py` + +**Interfaces:** +- Consumes: `ReviewedBatchProof.changed_tickers`, current ticker readiness, DCF readiness, peer readiness, and canonical fundamentals frames. +- Produces: new immutable row fields `proof_applicability`, `current_blocker_code`, `current_blocker_fields`, `current_blocker_detail`, `next_safe_review`, `historical_payload_status`, and `historical_evidence_limit`. +- Produces: new summary tuples `proof_applicability_counts` and `current_blocker_counts`. +- Preserves: existing state names, ordering, current-readiness authority, and lane independence. + +- [ ] **Step 1: Extend test helpers without changing production code** + +Update `_proof` so tests can independently set batch scope and explicit changed tickers: + +```python +def _proof( + *, + tickers: str = "ARCT", + changed_tickers: str | None = None, + lane: str = "fundamentals", + outcome: str = "auto_supported", + review_date: str = "2026-06-26", + batch_id: str = "RB-1", +) -> ReviewedBatchProof: + return ReviewedBatchProof( + batch_id=batch_id, + review_date=review_date, + reviewer="reviewer", + lane=lane, + scope="one reviewed scope", + tickers=tickers, + command_run="read-only fixture command", + validation_result="passed", + preview_result="reviewed", + apply_result="applied", + pre_run_readiness_snapshot="before", + post_run_readiness_snapshot="after", + changed_readiness_counts="one lane changed", + changed_tickers=tickers if changed_tickers is None else changed_tickers, + source_files="reviewed source", + generated_artifacts_reviewed="excluded", + final_outcome=outcome, + notes="fixture proof", + ) +``` + +Add a canonical fundamentals helper and pass it through `_summary`: + +```python +def _fundamentals(**rows: dict[str, str]) -> pd.DataFrame: + return pd.DataFrame([{"ticker": ticker, **values} for ticker, values in rows.items()]) + + +def _summary(*, proofs, ticker, dcf=None, peer=None, fundamentals=None): + return build_proof_readiness_reconciliation( + proofs=proofs, + ticker_readiness=ticker, + dcf_readiness=dcf if dcf is not None else pd.DataFrame(), + peer_readiness=peer if peer is not None else pd.DataFrame(), + fundamentals=fundamentals if fundamentals is not None else pd.DataFrame(), + ) +``` + +- [ ] **Step 2: Write failing applicability tests** + +Add these tests: + +```python +def test_scope_only_support_is_not_ticker_level_support(): + summary = _summary( + proofs=[_proof(tickers="ARCT,ARDX", changed_tickers="ARDX")], + ticker=_ticker_readiness( + ARCT={"fundamentals_ready": "False"}, + ARDX={"fundamentals_ready": "False"}, + ), + ) + + arct = _row(summary, "ARCT", "fundamentals") + ardx = _row(summary, "ARDX", "fundamentals") + + assert arct.proof_applicability == "scope_only_not_supported" + assert arct.state == "currently_blocked_with_non_supporting_history" + assert ardx.proof_applicability == "explicit_ticker_change" + assert ardx.state == "historical_supported_currently_blocked" + assert dict(summary.conflict_counts_by_lane) == {"fundamentals": 1} + + +@pytest.mark.parametrize( + "changed_tickers", + ["", "-", "none", "n/a", "not available", "unknown", "3289 changed tickers"], +) +def test_placeholder_changed_tickers_cannot_support(changed_tickers): + summary = _summary( + proofs=[_proof(changed_tickers=changed_tickers)], + ticker=_ticker_readiness(ARCT={"fundamentals_ready": "False"}), + ) + + row = _row(summary, "ARCT", "fundamentals") + assert row.proof_applicability == "missing_ticker_change_detail" + assert row.state == "currently_blocked_with_non_supporting_history" + + +def test_latest_non_supporting_proof_does_not_fall_back_to_older_explicit_support(): + summary = _summary( + proofs=[ + _proof(batch_id="RB-OLD", review_date="2026-06-25", changed_tickers="ARCT"), + _proof(batch_id="RB-NEW", review_date="2026-06-26", changed_tickers="-"), + ], + ticker=_ticker_readiness(ARCT={"fundamentals_ready": "False"}), + ) + + row = _row(summary, "ARCT", "fundamentals") + assert row.latest_batch_id == "RB-NEW" + assert row.proof_applicability == "missing_ticker_change_detail" + assert row.state == "currently_blocked_with_non_supporting_history" +``` + +Import `pytest` at the top of the test file. + +- [ ] **Step 3: Run the applicability tests and confirm RED** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest \ + tests/test_proof_readiness_reconciliation.py::test_scope_only_support_is_not_ticker_level_support \ + tests/test_proof_readiness_reconciliation.py::test_placeholder_changed_tickers_cannot_support \ + tests/test_proof_readiness_reconciliation.py::test_latest_non_supporting_proof_does_not_fall_back_to_older_explicit_support \ + -q +``` + +Expected: failures because the row has no `proof_applicability` field and `_summary` passes an unsupported `fundamentals` argument. + +- [ ] **Step 4: Write failing current-blocker tests** + +Add: + +```python +def test_missing_current_canonical_fundamentals_row_is_diagnosed_without_historical_cause_inference(): + summary = _summary( + proofs=[_proof()], + ticker=_ticker_readiness(ARCT={"fundamentals_ready": "False"}), + dcf=_dcf_readiness( + ARCT={ + "missing_dcf_fields": "free_cash_flow, shares_outstanding, revenue, fcf_margin, price" + } + ), + fundamentals=_fundamentals(ARDX={"source": "sec_companyfacts"}), + ) + + row = _row(summary, "ARCT", "fundamentals") + assert row.current_blocker_code == "current_canonical_row_missing" + assert row.current_blocker_fields == ( + "free_cash_flow", + "shares_outstanding", + "revenue", + "fcf_margin", + ) + assert row.historical_payload_status == "structured_payload_not_recorded" + assert "cannot distinguish" in row.historical_evidence_limit.lower() + assert "yfinance" not in row.current_blocker_detail.lower() + + +def test_incomplete_current_canonical_fundamentals_row_reports_exact_current_fields(): + summary = _summary( + proofs=[_proof()], + ticker=_ticker_readiness(ARCT={"fundamentals_ready": "False"}), + dcf=_dcf_readiness( + ARCT={"missing_dcf_fields": "free_cash_flow, revenue, fcf_margin, price"} + ), + fundamentals=_fundamentals( + ARCT={"shares_outstanding": "100", "source": "sec_companyfacts"} + ), + ) + + row = _row(summary, "ARCT", "fundamentals") + assert row.current_blocker_code == "current_required_fields_missing" + assert row.current_blocker_fields == ("free_cash_flow", "revenue", "fcf_margin") + assert "price" not in row.current_blocker_fields + + +def test_share_count_diagnosis_reports_only_shares_outstanding(): + summary = _summary( + proofs=[_proof(lane="share_count")], + ticker=_ticker_readiness(ARCT={}), + dcf=_dcf_readiness( + ARCT={ + "has_shares_outstanding": "False", + "missing_dcf_fields": "free_cash_flow, shares_outstanding, revenue, fcf_margin, price", + } + ), + ) + + row = _row(summary, "ARCT", "share_count") + assert row.current_blocker_code == "current_required_fields_missing" + assert row.current_blocker_fields == ("shares_outstanding",) + + +def test_price_and_peer_blockers_remain_independent(): + summary = _summary( + proofs=[ + _proof(lane="price_history", batch_id="RB-PRICE"), + _proof(lane="peer_mapping", batch_id="RB-PEER"), + _proof(lane="peer_valuation_inputs", batch_id="RB-PEER-VAL"), + ], + ticker=_ticker_readiness( + ARCT={"price_ready": "False", "peer_ready": "False"} + ), + peer=_peer_readiness(ARCT={"peer_valuation_ready": "False"}), + ) + + assert _row(summary, "ARCT", "price").current_blocker_code == "current_price_missing" + assert _row(summary, "ARCT", "peer_mapping").current_blocker_code == "current_peer_mapping_missing" + assert ( + _row(summary, "ARCT", "peer_valuation_inputs").current_blocker_code + == "current_peer_valuation_inputs_missing" + ) +``` + +Add an unavailable-input isolation test: + +```python +def test_missing_canonical_fundamentals_input_affects_only_dependent_diagnosis(): + summary = _summary( + proofs=[ + _proof(lane="fundamentals", batch_id="RB-FUND"), + _proof(lane="price", batch_id="RB-PRICE"), + _proof(lane="peer_mapping", batch_id="RB-PEER"), + ], + ticker=_ticker_readiness( + ARCT={ + "fundamentals_ready": "False", + "price_ready": "False", + "peer_ready": "False", + } + ), + dcf=_dcf_readiness( + ARCT={"missing_dcf_fields": "free_cash_flow, shares_outstanding, revenue, fcf_margin"} + ), + peer=_peer_readiness(ARCT={"peer_valuation_ready": "False"}), + fundamentals=pd.DataFrame(), + ) + + assert ( + _row(summary, "ARCT", "fundamentals").current_blocker_code + == "current_readiness_input_unavailable" + ) + assert _row(summary, "ARCT", "price").current_blocker_code == "current_price_missing" + assert _row(summary, "ARCT", "peer_mapping").current_blocker_code == "current_peer_mapping_missing" +``` + +- [ ] **Step 5: Run the blocker tests and confirm RED** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests/test_proof_readiness_reconciliation.py \ + -k 'canonical or share_count_diagnosis or price_and_peer_blockers' -q +``` + +Expected: failures because the new blocker and evidence-limitation fields do not exist. + +- [ ] **Step 6: Add immutable contracts and constants** + +In `src/proof_readiness_reconciliation.py`, add: + +```python +PLACEHOLDER_TICKER_VALUES = frozenset({"-", "none", "n/a", "na", "not available", "unknown"}) +CANONICAL_DCF_FIELDS = ( + "free_cash_flow", + "shares_outstanding", + "revenue", + "fcf_margin", + "price", +) +FUNDAMENTALS_FIELDS = CANONICAL_DCF_FIELDS[:-1] +HISTORICAL_EVIDENCE_LIMIT = ( + "Historical batch proof cannot distinguish payload removal, readiness-contract change, " + "source-rights change, field-scope change, or another historical cause." +) +``` + +Extend `ProofReadinessReconciliationRow`: + +```python + proof_applicability: str + current_blocker_code: str + current_blocker_fields: tuple[str, ...] + current_blocker_detail: str + next_safe_review: str + historical_payload_status: str + historical_evidence_limit: str +``` + +Extend `ProofReadinessReconciliationSummary`: + +```python + proof_applicability_counts: tuple[tuple[str, int], ...] + current_blocker_counts: tuple[tuple[str, int], ...] +``` + +- [ ] **Step 7: Implement normalized explicit applicability** + +Replace `_proof_tickers` with a shared normalized-token helper that excludes placeholders and only retains current-universe tickers: + +```python +def _ticker_tokens(value: object, valid_tickers: set[str]) -> tuple[str, ...]: + tokens = (token.strip().upper() for token in re.split(r"[,;]", str(value or ""))) + return tuple( + dict.fromkeys( + token + for token in tokens + if token + and token.lower() not in PLACEHOLDER_TICKER_VALUES + and token in valid_tickers + ) + ) + + +def _proof_applicability( + latest_proof: _LatestProof | None, + *, + ticker: str, + valid_tickers: set[str], +) -> str: + if latest_proof is None: + return "no_applicable_proof" + if not latest_proof.review_date_valid: + return "malformed_review_date" + outcome = str(latest_proof.proof.final_outcome or "").strip().lower() + if outcome not in SUPPORTING_OUTCOMES: + return "non_supporting_outcome" + changed = _ticker_tokens(latest_proof.proof.changed_tickers, valid_tickers) + if ticker in changed: + return "explicit_ticker_change" + if not changed: + return "missing_ticker_change_detail" + return "scope_only_not_supported" +``` + +Use `_ticker_tokens` for scope matching. Set `supporting = proof_applicability == "explicit_ticker_change"` before calling `_reconciliation_state`. + +- [ ] **Step 8: Implement current blocker diagnosis** + +Add a frozen internal result: + +```python +@dataclass(frozen=True) +class _CurrentBlocker: + code: str + fields: tuple[str, ...] + detail: str + next_safe_review: str +``` + +Add helpers that: + +- parse `missing_dcf_fields` only into `CANONICAL_DCF_FIELDS` order; +- determine input availability from a nonempty normalized frame containing the required `ticker` and lane columns; +- return `current_readiness_input_unavailable` for fundamentals when the canonical fundamentals input is unavailable, rather than treating an unavailable file as an empty canonical universe; +- use the canonical fundamentals ticker set only to distinguish missing from incomplete payload; +- return `none` for current-ready lanes; +- return `current_readiness_input_unavailable` when the authoritative current field is `None`; +- return lane-specific codes exactly as specified in the design; +- never inspect proof notes, source files, or command text. + +Use this exact next-review copy: + +```python +NEXT_SAFE_REVIEW = { + "current_canonical_row_missing": ( + "Obtain and review a permitted source payload for the exact ticker before any import or readiness rebuild." + ), + "current_required_fields_missing": ( + "Review the named current fields through the existing source-review and preview-first workflow." + ), + "current_price_missing": ( + "Inspect the exact ticker's current price evidence without inferring a provider." + ), + "current_peer_mapping_missing": ( + "Review a source-backed relationship through the existing peer evidence contract." + ), + "current_peer_valuation_inputs_missing": ( + "Review current peer valuation inputs independently from mapping readiness." + ), + "current_readiness_input_unavailable": ( + "Restore or inspect the current saved input before drawing a conclusion." + ), + "none": "No current blocker is reported for this lane.", +} +``` + +For `scope_only_not_supported` or `missing_ticker_change_detail`, prefix the row's safe review with: + +```text +Review the proof row; do not reuse it as ticker-level support. +``` + +- [ ] **Step 9: Wire the canonical frame and summary counts** + +Change the builder signature: + +```python +def build_proof_readiness_reconciliation( + *, + proofs: Sequence[ReviewedBatchProof], + ticker_readiness: pd.DataFrame, + dcf_readiness: pd.DataFrame, + peer_readiness: pd.DataFrame, + fundamentals: pd.DataFrame, +) -> ProofReadinessReconciliationSummary: +``` + +In the loader, add: + +```python +fundamentals=_read_csv(data / "fundamentals.csv"), +``` + +Populate every new row field. For rows with an applicable proof, use: + +```python +historical_payload_status="structured_payload_not_recorded" +historical_evidence_limit=HISTORICAL_EVIDENCE_LIMIT +``` + +For rows with no proof, use empty strings for those two fields. + +Build deterministic summary counters: + +```python +proof_applicability_counts = Counter(row.proof_applicability for row in rows) +current_blocker_counts = Counter(row.current_blocker_code for row in rows) +``` + +Ensure the unavailable early return includes empty tuples for the new summary fields. + +- [ ] **Step 10: Run core tests and make them GREEN** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests/test_proof_readiness_reconciliation.py -q +``` + +Expected: all core reconciliation tests pass. + +- [ ] **Step 11: Commit the core model** + +Run: + +```bash +git add -- src/proof_readiness_reconciliation.py tests/test_proof_readiness_reconciliation.py +make staged-hygiene-check +git diff --cached --check +git commit -m "Diagnose proof readiness conflicts" +``` + +Expected: only the two named product/test files are committed; no generated artifacts are staged. + +--- + +### Task 2: CLI And JSON Two-Axis Evidence + +**Files:** +- Modify: `tests/test_proof_readiness_reconciliation.py` +- Modify: `src/proof_readiness_reconciliation.py` + +**Interfaces:** +- Consumes: Task 1 row and summary fields. +- Produces: stable JSON keys `proof_applicability_counts` and `current_blocker_counts`, plus new row fields through `asdict`. +- Produces: text sections `Proof applicability counts` and `Current blocker counts`. +- Preserves: current command name, ticker filtering, top-N behavior, existing payload keys, and read-only guarantee. + +- [ ] **Step 1: Write failing JSON and text tests** + +Add: + +```python +def test_payload_exposes_applicability_and_current_blocker_axes(): + summary = _summary( + proofs=[_proof(tickers="ARCT,ARDX", changed_tickers="ARDX")], + ticker=_ticker_readiness( + ARCT={"fundamentals_ready": "False"}, + ARDX={"fundamentals_ready": "False"}, + ), + dcf=_dcf_readiness( + ARCT={"missing_dcf_fields": "free_cash_flow, shares_outstanding, revenue, fcf_margin"}, + ARDX={"missing_dcf_fields": "free_cash_flow, shares_outstanding, revenue, fcf_margin"}, + ), + fundamentals=_fundamentals(), + ) + + payload = proof_readiness_reconciliation_payload(summary, top_n=20) + + assert payload["proof_applicability_counts"]["scope_only_not_supported"] == 1 + assert payload["proof_applicability_counts"]["explicit_ticker_change"] == 1 + assert payload["current_blocker_counts"]["current_canonical_row_missing"] == 2 + assert payload["rows"][0]["historical_payload_status"] == "structured_payload_not_recorded" + assert "historical cause" in payload["boundary"].lower() + + +def test_render_exposes_two_axes_without_claiming_historical_cause(): + summary = _summary( + proofs=[_proof()], + ticker=_ticker_readiness(ARCT={"fundamentals_ready": "False"}), + dcf=_dcf_readiness( + ARCT={"missing_dcf_fields": "free_cash_flow, shares_outstanding, revenue, fcf_margin"} + ), + fundamentals=_fundamentals(), + ) + + rendered = render_proof_readiness_reconciliation(summary, top_n=10) + + assert "Proof applicability counts:" in rendered + assert "Current blocker counts:" in rendered + assert "explicit_ticker_change" in rendered + assert "current_canonical_row_missing" in rendered + assert "Proof applicability | Current blocker | Next safe review" in rendered + assert "does not establish the historical cause" in rendered +``` + +- [ ] **Step 2: Run output tests and confirm RED** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests/test_proof_readiness_reconciliation.py \ + -k 'payload_exposes_applicability or render_exposes_two_axes' -q +``` + +Expected: failures because the new summary keys and rendered sections are absent. + +- [ ] **Step 3: Extend JSON payload** + +Add to `proof_readiness_reconciliation_payload`: + +```python +"proof_applicability_counts": dict(summary.proof_applicability_counts), +"current_blocker_counts": dict(summary.current_blocker_counts), +``` + +Replace the boundary with: + +```python +"Current saved readiness remains authoritative; reconciliation does not restore data, promote readiness, " +"or rewrite proof history. Current blocker diagnosis describes observable saved inputs and does not establish " +"the historical cause." +``` + +- [ ] **Step 4: Extend text rendering** + +After state counts, render both deterministic summary sections. Change the table header to: + +```text +Ticker | Lane | Current ready | Latest proof | Review date | Reconciliation state | Proof applicability | Current blocker | Next safe review +``` + +Render `current_blocker_fields` in parentheses after the blocker code when nonempty. Keep the existing current-readiness and research-only boundaries, then add: + +```text +Boundary: current blocker diagnosis describes observable saved inputs; it does not establish the historical cause, source rights, field scope, provenance, payload truth, or commercial use. +``` + +- [ ] **Step 5: Verify CLI, JSON, filtering, and filesystem immutability** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests/test_proof_readiness_reconciliation.py -q +make proof-readiness-reconciliation TOP_N=20 +make proof-readiness-reconciliation TICKERS=ARCT TOP_N=20 JSON=1 +git status --short +``` + +Expected: + +- focused tests pass; +- text and JSON expose both axes; +- current saved readiness remains authoritative; +- no file is created or modified by either command; +- only the pre-existing 18 generated modifications remain outside intentional product edits. + +- [ ] **Step 6: Commit CLI and JSON evidence** + +Run: + +```bash +git add -- src/proof_readiness_reconciliation.py tests/test_proof_readiness_reconciliation.py +make staged-hygiene-check +git diff --cached --check +git commit -m "Expose proof conflict diagnosis" +``` + +--- + +### Task 3: Advanced Proof History Diagnosis + +**Files:** +- Modify: `tests/test_dashboard_helpers.py` +- Modify: `src/dashboard.py` +- Inspect: `tests/test_dashboard_render_smoke.py`; modify it only when the focused RED run proves that an existing constructor or route marker must change + +**Interfaces:** +- Consumes: Task 1 summary rows and counts. +- Produces: answer-first global and selected-ticker evidence cards under Proof History. +- Preserves: empty card commands, raw-ledger collapse, primary route order, and current readiness authority. + +- [ ] **Step 1: Update the dashboard summary fixture** + +Extend `_proof_reconciliation_summary` row construction with: + +```python +proof_applicability="explicit_ticker_change", +current_blocker_code="current_canonical_row_missing" if conflict else "none", +current_blocker_fields=("free_cash_flow", "shares_outstanding", "revenue", "fcf_margin") if conflict else (), +current_blocker_detail="No current canonical fundamentals row is present." if conflict else "No current blocker.", +next_safe_review=( + "Obtain and review a permitted source payload for the exact ticker before any import or readiness rebuild." + if conflict + else "No current blocker is reported for this lane." +), +historical_payload_status="structured_payload_not_recorded", +historical_evidence_limit="Historical proof cannot distinguish the historical cause.", +``` + +Extend summary construction with: + +```python +proof_applicability_counts=(("explicit_ticker_change", 1),), +current_blocker_counts=(("current_canonical_row_missing" if conflict else "none", 1),), +``` + +- [ ] **Step 2: Write failing card tests** + +Extend the conflict-card test: + +```python +assert "current canonical row missing" in rendered +assert "observable current blocker" in rendered +assert "does not establish the historical cause" in rendered +assert "obtain and review a permitted source payload" in rendered +``` + +Add: + +```python +def test_proof_reconciliation_selected_card_does_not_infer_source_or_historical_cause(): + cards = dashboard.proof_readiness_reconciliation_cards( + _proof_reconciliation_summary(), + ticker="ARCT", + ) + selected = cards[1] + rendered = " ".join(str(value) for value in selected.values()).lower() + + assert "explicit ticker change" in rendered + assert "free cash flow" in rendered + assert "current canonical row missing" in rendered + assert "yfinance" not in rendered + assert "source rights changed" not in rendered + assert selected["command"] == "" +``` + +- [ ] **Step 3: Run dashboard tests and confirm RED** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests/test_dashboard_helpers.py \ + -k 'proof_reconciliation' -q +``` + +Expected: failures because the cards do not yet show blocker/applicability fields. + +- [ ] **Step 4: Implement concise Advanced cards** + +In `proof_readiness_reconciliation_cards`: + +- find the largest non-`none` current blocker count from `summary.current_blocker_counts`; +- render labels by replacing underscores with spaces; +- keep the global conflict count unchanged; +- state: `Observable current blockers describe saved inputs; they do not establish the historical cause.`; +- for the selected ticker, combine only that ticker's conflict rows; +- show canonical lanes, applicability labels, blocker labels/fields, and the first deterministic safe next review; +- keep every command empty. + +Use this global body shape: + +```python +body = ( + "Historical support is not current readiness. Current saved readiness remains authoritative. " + f"Largest observable current blocker: {largest_blocker_label} ({largest_blocker_count:,}). " + "Observable current blockers describe saved inputs; they do not establish the historical cause, " + "restore data, promote readiness, or rewrite proof history." +) +``` + +Use this selected-ticker body shape: + +```python +body = ( + f"Current blocked lane(s): {lanes}. Proof applicability: {applicability}. " + f"Current blocker(s): {blockers}. Next safe review: {next_safe_review} " + "Reconciliation itself does not unlock the lane." +) +``` + +Do not add technical cards to any primary research route. + +- [ ] **Step 5: Run focused dashboard and route verification** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest \ + tests/test_dashboard_helpers.py \ + tests/test_dashboard_render_smoke.py \ + tests/test_research_mode_dashboard_contract.py -q +make research-dashboard-render-smoke +``` + +Expected: focused dashboard tests pass and all six Research Mode routes render. + +- [ ] **Step 6: Commit the Advanced UI slice** + +Run: + +```bash +git add -- src/dashboard.py tests/test_dashboard_helpers.py +git add -- tests/test_dashboard_render_smoke.py # only if intentionally changed +make staged-hygiene-check +git diff --cached --check +git commit -m "Show current blockers in Proof History" +``` + +Expected: no screenshot or generated artifact is staged. + +--- + +### Task 4: Methodology, Roadmap, Continuation Contract, And Release Verification + +**Files:** +- Modify: `tests/test_public_v1_release_docs.py` +- Modify: `ROADMAP.md` +- Modify: `docs/OPERATOR_GUIDE.md` +- Modify: `docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md` + +**Interfaces:** +- Consumes: verified Task 1-3 behavior and current-snapshot output. +- Produces: durable operator and continuation rules without turning snapshot counts into permanent claims. +- Preserves: draft PR, generated-artifact exclusion, external dependency boundaries, and release-gate wording. + +- [ ] **Step 1: Write the failing documentation contract** + +Extend `test_proof_readiness_reconciliation_docs_keep_historical_proof_separate_from_current_state`: + +```python +for text in (roadmap, operator, prompt): + assert "explicit_ticker_change" in text + assert "current_canonical_row_missing" in text + assert "does not establish the historical cause" in text.lower() + +assert "changed_tickers" in operator +assert "structured per-ticker" in prompt.lower() +assert "future" in roadmap.lower() +``` + +- [ ] **Step 2: Run the documentation test and confirm RED** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest \ + tests/test_public_v1_release_docs.py::test_proof_readiness_reconciliation_docs_keep_historical_proof_separate_from_current_state -q +``` + +Expected: failure because the new two-axis terms are not documented. + +- [ ] **Step 3: Capture the verified current snapshot without writing artifacts** + +Run: + +```bash +make proof-readiness-reconciliation TOP_N=20 +make proof-readiness-reconciliation TOP_N=0 JSON=1 +``` + +Record only values directly emitted by the current command. Expected methodological change from the audited starting snapshot is that 44 scope-only rows no longer count as ticker-level supporting conflicts; do not state the exact resulting count until the command proves it. + +- [ ] **Step 4: Update ROADMAP.md** + +Add the next numbered implemented item stating: + +- `changed_tickers` is the only ticker-level support attribution; +- `proof_applicability` and `current_blocker_code` remain independent; +- current observable blockers do not establish historical cause; +- no canonical data, readiness, or history was rewritten; +- current-snapshot counts come from the verified command and are not durable coverage totals; +- a prospective structured per-ticker/per-field proof contract is the next evidence-integrity maturity stage. + +- [ ] **Step 5: Update operator and continuation guidance** + +In `docs/OPERATOR_GUIDE.md`, explain: + +```text +explicit_ticker_change means the latest supporting proof explicitly names the ticker in changed_tickers. Scope membership alone is not ticker-level support. +current_canonical_row_missing and other current blocker codes describe current saved inputs. They do not establish the historical cause. +``` + +In `docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md`, require future continuations to: + +- keep scope-only outcomes non-supporting at ticker level; +- route current blockers to the named safe review; +- never infer historical source, rights, scope, or cause from narrative proof; +- treat a structured per-ticker/per-field proof record as prospective future work, not a retroactive upgrade. + +- [ ] **Step 6: Run focused documentation and wording checks** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest \ + tests/test_public_v1_release_docs.py \ + tests/test_proof_readiness_reconciliation.py -q +make public-wording-check +git diff --check +``` + +Expected: all focused tests and wording/whitespace checks pass. + +- [ ] **Step 7: Run full local verification** + +Run each command and require exit zero: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests -q +make dashboard-smoke +make research-dashboard-render-smoke +make public-wording-check +make commercial-beta-check +make public-check +make commercial-beta-release-check +make pilot-readiness-check TOP_N=10 +make diff-hygiene-summary +git diff --check +``` + +Expected: + +- full tests pass; +- all six Research Mode routes render; +- public, commercial-beta, release, and pilot packaging gates report their truthful states; +- generated churn remains excluded; +- no completion claim exceeds local evidence. + +- [ ] **Step 8: Stage exact documentation/test files and verify** + +Run: + +```bash +git add -- \ + ROADMAP.md \ + docs/OPERATOR_GUIDE.md \ + docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md \ + tests/test_public_v1_release_docs.py +make staged-hygiene-check +git diff --cached --check +git diff --cached --name-only +``` + +Expected: exactly the four named files are staged and all generated files remain unstaged. + +- [ ] **Step 9: Commit documentation** + +Run: + +```bash +git commit -m "Document proof conflict diagnosis" +``` + +- [ ] **Step 10: Final local branch audit** + +Run: + +```bash +git status --short --branch +git log -6 --oneline --decorate +git rev-list --left-right --count origin/codex/personal-research-mode-mvp...HEAD +make diff-hygiene-summary +``` + +Expected: only the 18 pre-existing generated modifications remain; the branch is ahead only by intentional reviewed commits. + +- [ ] **Step 11: Push and update draft PR #113** + +Run: + +```bash +git push origin codex/personal-research-mode-mvp +gh pr view 113 --json number,state,isDraft,mergeable,headRefName,headRefOid,url,statusCheckRollup +``` + +Add a PR comment summarizing: + +- the false scope-level attribution defect; +- the two-axis contract; +- verified current-snapshot counts; +- focused/full verification; +- zero staged generated artifacts; +- unchanged external source, hosted, reviewer, consensus, calibration, and operating gates. + +Keep the PR draft. Do not merge or deploy. + +- [ ] **Step 12: Verify exact-head GitHub CI** + +Wait for the `Commercial Research Beta` workflow whose `headSha` equals the pushed HEAD. Require `local-engineering-gate` conclusion `SUCCESS` before reporting review safety. + +- [ ] **Step 13: Select the next safe maturity lane** + +Run the final read-only reconciliation and roadmap audit. The preferred next design subject is the prospective versioned per-ticker/per-field proof contract. Do not implement that new behavior without its own design approval. If a higher-value executable local gap is proven by current evidence, document the choice and its boundary instead. + +Report: + +1. repository and PR status; +2. product stage; +3. lane audited; +4. root cause and false-attribution count; +5. changes made; +6. tests and gates; +7. commits and push; +8. generated artifacts excluded; +9. external dependencies; +10. remaining maturity gaps; +11. exact next executable step; +12. review safety; +13. whether the overall goal remains active. diff --git a/docs/superpowers/plans/2026-07-21-proof-readiness-reconciliation.md b/docs/superpowers/plans/2026-07-21-proof-readiness-reconciliation.md new file mode 100644 index 00000000..e21bd523 --- /dev/null +++ b/docs/superpowers/plans/2026-07-21-proof-readiness-reconciliation.md @@ -0,0 +1,448 @@ +# Proof-Readiness Reconciliation 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 historical proof outcomes impossible to mistake for current saved readiness by adding a read-only, lane-independent reconciliation engine, CLI, and Advanced Proof History answer. + +**Architecture:** A new pure module consumes the append-only batch proof ledger and the three current readiness reports, maps only explicit proof lanes to explicit current fields, and emits immutable reconciliation rows plus a bounded summary. The CLI and dashboard consume that same result; neither writes data, alters readiness, or rewrites proof history. + +**Tech Stack:** Python 3.12, pandas, dataclasses, argparse, pytest, Streamlit, Make. + +## Global Constraints + +- Research-only; no investment advice, rankings, recommendations, broker integration, order routing, or auto-trading. +- Current saved readiness remains authoritative for current lane availability. +- Historical proof remains append-only and cannot promote current readiness. +- Actuals, consensus, Revenue, EPS, valuation, catalysts, outcomes, backtesting, calibration, source rights, field scope, price lineage, hosted operations, and external validation stay independent. +- Do not run `make readiness`, source refreshes, imports, applies, report generation, screenshot generation, or timing generation. +- Preserve the existing 18 generated readiness files as local, unstaged churn. +- Never use `git add -A`; stage only exact intentional files. +- Keep PR #113 open and draft. + +--- + +### Task 1: Pure Reconciliation Engine + +**Files:** +- Create: `src/proof_readiness_reconciliation.py` +- Create: `tests/test_proof_readiness_reconciliation.py` + +**Interfaces:** +- Consumes: `ReviewedBatchProof` objects from `src.reviewed_batch_proof`, plus ticker, DCF, and peer readiness `pandas.DataFrame` objects. +- Produces: `ProofReadinessReconciliationRow`, `ProofReadinessReconciliationSummary`, `build_proof_readiness_reconciliation`, `load_proof_readiness_reconciliation`, and `filter_reconciliation_rows`. + +- [ ] **Step 1: Write failing state and independence tests** + +Create `tests/test_proof_readiness_reconciliation.py` with fixtures that use real `ReviewedBatchProof` objects and in-memory readiness frames. The first tests must assert these exact states: + +```python +def test_historical_supported_fundamentals_stays_blocked_when_current_readiness_is_false(): + summary = build_proof_readiness_reconciliation( + proofs=[proof(tickers="ARCT", lane="fundamentals", outcome="auto_supported")], + ticker_readiness=ticker_frame(ARCT={"fundamentals_ready": "False"}), + dcf_readiness=dcf_frame(ARCT={"has_shares_outstanding": "False"}), + peer_readiness=peer_frame(), + ) + row = row_for(summary, "ARCT", "fundamentals") + assert row.state == "historical_supported_currently_blocked" + assert row.current_ready is False + assert row.latest_batch_id == "RB-1" + + +def test_current_ready_without_supporting_latest_proof_is_not_proof_backed(): + summary = build_proof_readiness_reconciliation( + proofs=[proof(tickers="ARCT", lane="fundamentals", outcome="still_blocked")], + ticker_readiness=ticker_frame(ARCT={"fundamentals_ready": "True"}), + dcf_readiness=dcf_frame(ARCT={"has_shares_outstanding": "False"}), + peer_readiness=peer_frame(), + ) + assert row_for(summary, "ARCT", "fundamentals").state == "current_ready_proof_not_supporting" +``` + +Add separate tests for current support with matching proof, later `still_blocked` superseding earlier support, `fundamentals_dcf`, share count, price aliases, peer mapping, peer valuation inputs, no proof, unknown lanes/outcomes, malformed dates/booleans, duplicate tickers, and descriptive ticker text. + +- [ ] **Step 2: Run the new test file and verify RED** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests/test_proof_readiness_reconciliation.py -q +``` + +Expected: collection fails because `src.proof_readiness_reconciliation` does not exist. + +- [ ] **Step 3: Implement immutable rows, explicit mappings, and deterministic latest-proof selection** + +Create `src/proof_readiness_reconciliation.py` with these public values and signatures: + +```python +SUPPORTING_OUTCOMES = frozenset({"supported", "auto_supported", "human_reviewed_supported"}) + +LANE_MAPPINGS = { + "fundamentals": ("fundamentals", "ticker", "fundamentals_ready"), + "fundamentals_dcf": ("dcf", "ticker", "dcf_ready"), + "share_count": ("share_count", "dcf", "has_shares_outstanding"), + "price": ("price", "ticker", "price_ready"), + "prices": ("price", "ticker", "price_ready"), + "price_coverage": ("price", "ticker", "price_ready"), + "price_history": ("price", "ticker", "price_ready"), + "peers": ("peer_mapping", "ticker", "peer_ready"), + "peer_mapping": ("peer_mapping", "ticker", "peer_ready"), + "peer_valuation_inputs": ("peer_valuation_inputs", "peer", "peer_valuation_ready"), +} + +@dataclass(frozen=True) +class ProofReadinessReconciliationRow: + ticker: str + lane: str + current_field: str + current_ready: bool | None + latest_batch_id: str + latest_review_date: str + latest_outcome: str + review_date_valid: bool + state: str + reason: str + + +@dataclass(frozen=True) +class ProofReadinessReconciliationSummary: + rows: tuple[ProofReadinessReconciliationRow, ...] + status_counts: tuple[tuple[str, int], ...] + conflict_counts_by_lane: tuple[tuple[str, int], ...] + input_status: str + input_message: str + + +def _reconciliation_state(*, current_ready: bool | None, proof_exists: bool, supporting: bool) -> str: + if current_ready is None: + return "not_applicable" + if current_ready and supporting: + return "current_supported_with_matching_proof" + if not current_ready and supporting: + return "historical_supported_currently_blocked" + if current_ready: + return "current_ready_proof_not_supporting" + if proof_exists: + return "currently_blocked_with_non_supporting_history" + return "no_proof_record" +``` + +Expose `build_proof_readiness_reconciliation` with keyword-only `proofs`, `ticker_readiness`, `dcf_readiness`, and `peer_readiness` arguments and a `ProofReadinessReconciliationSummary` return. Expose `load_proof_readiness_reconciliation` with keyword-only `root: Path`. Expose `filter_reconciliation_rows` with `summary`, keyword-only `tickers: Sequence[str] = ()`, and `top_n: int = 20`, returning an immutable tuple of rows. + +Implementation requirements: + +- create six canonical rows per valid ticker: fundamentals, DCF, share count, price, peer mapping, and peer valuation inputs; +- use current ticker-readiness membership as the valid ticker set; +- split proof tickers on commas or semicolons, uppercase, deduplicate, and ignore tokens absent from the valid set; +- choose the latest proof per canonical ticker/lane by valid ISO review date then append index; +- never let a malformed date outrank a valid date; a malformed supporting outcome is non-supporting; +- parse only explicit `true` and `false`; every other value yields `current_ready=None` and `not_applicable`; +- sort conflicts first, then current-ready proof gaps, then other states, each by lane and ticker; +- calculate global counts before presentation filtering. + +- [ ] **Step 4: Run focused tests and verify GREEN** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests/test_proof_readiness_reconciliation.py -q +``` + +Expected: all reconciliation engine tests pass. + +- [ ] **Step 5: Commit the pure engine** + +Run: + +```bash +git add src/proof_readiness_reconciliation.py tests/test_proof_readiness_reconciliation.py +make staged-hygiene-check +git diff --cached --check +git commit -m "Reconcile proof history with current readiness" +``` + +Expected: exactly the engine and its tests are committed; generated files remain unstaged. + +--- + +### Task 2: Read-Only CLI And Make Entry Point + +**Files:** +- Modify: `src/proof_readiness_reconciliation.py` +- Modify: `tests/test_proof_readiness_reconciliation.py` +- Modify: `Makefile` + +**Interfaces:** +- Consumes: `load_proof_readiness_reconciliation`, `filter_reconciliation_rows`. +- Produces: `render_proof_readiness_reconciliation`, `proof_readiness_reconciliation_payload`, module `main()`, and `make proof-readiness-reconciliation TOP_N=20`. + +- [ ] **Step 1: Write failing CLI rendering tests** + +Add tests asserting: + +```python +def test_render_names_conflicts_and_non_promotion_boundary(): + rendered = render_proof_readiness_reconciliation(summary_with_conflict(), top_n=10) + assert "Proof-Readiness Reconciliation" in rendered + assert "historical_supported_currently_blocked" in rendered + assert "Current saved readiness remains authoritative" in rendered + assert "does not restore data, promote readiness, or rewrite proof history" in rendered + + +def test_main_is_read_only(tmp_path, capsys): + write_fixture_inputs(tmp_path) + before = snapshot_files(tmp_path) + assert main(["--root", str(tmp_path), "--top-n", "10"]) == 0 + assert snapshot_files(tmp_path) == before + assert "Research-only" in capsys.readouterr().out +``` + +Add a JSON test asserting the payload retains global counts when `--tickers ARCT` narrows displayed rows. + +- [ ] **Step 2: Run CLI tests and verify RED** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests/test_proof_readiness_reconciliation.py -q +``` + +Expected: tests fail because rendering and `main` are absent. + +- [ ] **Step 3: Implement rendering, JSON, argparse, and Make target** + +Add: + +```python +def _parse_ticker_filter(value: str) -> tuple[str, ...]: + return tuple(dict.fromkeys(token.strip().upper() for token in value.split(",") if token.strip())) +``` + +Expose `render_proof_readiness_reconciliation` and `proof_readiness_reconciliation_payload` with the same `summary`, `tickers`, and `top_n` contract as the filter. Expose `main(argv: Sequence[str] | None = None) -> int`; it parses `--root`, `--top-n`, `--tickers`, and `--json`, prints exactly one representation to stdout, and returns zero without opening any output file for writing. + +The human renderer must print input status, global state counts, conflict counts by canonical lane, bounded detail rows, the read-only boundary, and the exact next command. JSON must serialize dataclasses without changing state. + +Add the target to `.PHONY`, Make help, and the command body: + +```make +proof-readiness-reconciliation: + @python3 -m src.proof_readiness_reconciliation --root . --top-n $(or $(TOP_N),20) $(if $(TICKERS),--tickers "$(TICKERS)",) $(if $(filter 1,$(JSON)),--json,) +``` + +- [ ] **Step 4: Run focused tests and the real command** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests/test_proof_readiness_reconciliation.py -q +make proof-readiness-reconciliation TOP_N=10 +make diff-hygiene-summary +``` + +Expected: tests pass; the command reports current conflicts; diff hygiene still reports only intentional product files plus the protected 18 generated files. + +- [ ] **Step 5: Commit the CLI slice** + +Run: + +```bash +git add Makefile src/proof_readiness_reconciliation.py tests/test_proof_readiness_reconciliation.py +make staged-hygiene-check +git diff --cached --check +git commit -m "Expose proof readiness reconciliation" +``` + +--- + +### Task 3: Advanced Proof History Integration + +**Files:** +- Modify: `src/dashboard.py` +- Modify: `tests/test_dashboard_helpers.py` +- Modify: `tests/test_dashboard_render_smoke.py` only if the established route-marker contract needs the new marker. + +**Interfaces:** +- Consumes: `load_proof_readiness_reconciliation`, filtered reconciliation rows, current `DATA_DIR`, and the optional `ticker` query parameter. +- Produces: `proof_readiness_reconciliation_cards` and an answer-first warning before raw ledger detail in both public and operator Proof History render paths. + +- [ ] **Step 1: Write failing dashboard helper and ordering tests** + +Add tests proving the card contract: + +```python +def test_proof_reconciliation_cards_warn_when_historical_support_is_currently_blocked(): + cards = dashboard.proof_readiness_reconciliation_cards(summary_with_conflict(), ticker="ARCT") + rendered = " ".join(str(value) for card in cards for value in card.values()).lower() + assert "historical support is not current readiness" in rendered + assert "arct" in rendered + assert "current saved readiness remains authoritative" in rendered + assert "make " not in rendered +``` + +Add source-order assertions that reconciliation cards are rendered after the latest evidence timeline/answer but before `Advanced: proof ledger details`. Add a selected-ticker test and a no-conflict test. Keep existing tests proving primary pages and public Proof History do not expose operator commands. + +- [ ] **Step 2: Run dashboard tests and verify RED** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests/test_dashboard_helpers.py -q -k 'proof_history or proof_reconciliation' +``` + +Expected: new tests fail because the dashboard helper and rendering call are absent. + +- [ ] **Step 3: Implement the minimal answer-first integration** + +Import the reconciliation loader and row filter. Add: + +```python +def proof_readiness_reconciliation_cards( + summary: ProofReadinessReconciliationSummary, + *, + ticker: str = "", +) -> list[dict[str, object]]: + conflicts = [row for row in summary.rows if row.state == "historical_supported_currently_blocked"] + selected = ticker.strip().upper() + selected_conflicts = [row for row in conflicts if selected and row.ticker == selected] + cards = [{ + "kicker": "PROOF / CURRENT STATE", + "title": f"{len(conflicts):,} historical-support conflict(s)", + "body": ( + "Historical support is not current readiness. Current saved readiness remains authoritative; " + "reconciliation does not restore data, promote readiness, or rewrite proof history." + ), + "badges": [summary.input_status, "read-only"], + "command": "", + }] + if selected_conflicts: + lanes = ", ".join(row.lane.replace("_", " ") for row in selected_conflicts) + cards.append({ + "kicker": "SELECTED TICKER", + "title": f"{selected}: historical proof conflicts with current readiness", + "body": f"Current blocked lane(s): {lanes}. Re-review source evidence before relying on an older supported outcome.", + "badges": [selected, "currently blocked"], + "command": "", + }) + return cards +``` + +The first card must report the global conflict count. When the selected ticker has conflicts, a second card must name only that ticker's canonical lanes. When no conflict exists, the card must say that reconciliation found no historical-support/current-readiness conflict while still refusing to prove source rights or payload truth. Cards must have empty commands. + +In `render_proof_history`, load the summary from the current repository root, read `ticker` from `st.query_params`, and render reconciliation cards before the ledger expander. Do not add the cards to Research Desk, Discover, Company Workbench, or Monitor. + +- [ ] **Step 4: Run focused dashboard and render tests** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests/test_dashboard_helpers.py tests/test_dashboard_render_smoke.py tests/test_research_mode_dashboard_contract.py -q +make research-dashboard-render-smoke +``` + +Expected: all focused dashboard tests and six research route renders pass. + +- [ ] **Step 5: Commit the dashboard slice** + +Run: + +```bash +git add src/dashboard.py tests/test_dashboard_helpers.py tests/test_dashboard_render_smoke.py +make staged-hygiene-check +git diff --cached --check +git commit -m "Show proof readiness conflicts in Proof History" +``` + +Stage `tests/test_dashboard_render_smoke.py` only if it changed. + +--- + +### Task 4: Methodology, Roadmap, Continuation Contract, And Release Verification + +**Files:** +- Modify: `ROADMAP.md` +- Modify: `docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md` +- Modify: `docs/OPERATOR_GUIDE.md` +- Modify: documentation contract tests selected by existing repository patterns. + +**Interfaces:** +- Consumes: verified CLI output, focused tests, and dashboard behavior. +- Produces: durable stage truth and the exact continuation command. + +- [ ] **Step 1: Write failing documentation contract assertions** + +Add assertions to the existing documentation tests that require: + +```text +make proof-readiness-reconciliation TOP_N=20 +historical_supported_currently_blocked +current saved readiness remains authoritative +``` + +The continuation contract must require reconciliation before reusing a supporting proof outcome and must preserve all external classifications. + +- [ ] **Step 2: Run documentation tests and verify RED** + +Run the selected documentation test module identified by `rg` and confirm the new assertions fail before documentation changes. + +- [ ] **Step 3: Update documentation truthfully** + +Document the implemented read-only behavior, the current-snapshot audit finding, and these limits: + +- reconciliation does not repair or restore canonical data; +- historical proof remains append-only; +- current readiness does not prove source rights, field scope, provenance, payload truth, or commercial use; +- the audit count is current-snapshot evidence, not a durable coverage total; +- future runs inspect reconciliation once before restarting a proof lane. + +- [ ] **Step 4: Run focused documentation checks** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests -q -k 'public_v1_release_docs or proof_readiness_reconciliation' +make public-wording-check +git diff --check +``` + +- [ ] **Step 5: Run the complete release matrix** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests -q +make dashboard-smoke +make research-dashboard-render-smoke +make public-wording-check +make public-check +make commercial-beta-check +make commercial-beta-release-check +make pilot-readiness-check TOP_N=10 +make diff-hygiene-summary +git diff --check +``` + +Expected: every gate passes; the pilot remains truthful about manual source-proof and generated-artifact gates; no new generated churn appears. + +- [ ] **Step 6: Stage exact files, verify, commit, push, and update PR #113** + +Run exact `git add` commands for only changed product, tests, Makefile, and documentation. Then run: + +```bash +make staged-hygiene-check +git diff --cached --check +git diff --cached --name-only +git commit -m "Document proof readiness reconciliation" +git push origin codex/personal-research-mode-mvp +``` + +Update draft PR #113 with the defect, state contract, test evidence, commit range, generated-artifact exclusion, and remaining external gates. Confirm exact-head CI passes before claiming the branch safe for review. + +--- + +## Plan Self-Review + +- Spec coverage: engine, CLI, Advanced Proof History, selected ticker, fail-closed states, documentation, full gates, artifact hygiene, PR update, and independent readiness are covered. +- Placeholder scan: no deferred runtime behavior or incomplete acceptance step remains in the plan. +- Type consistency: all tasks use the same `ProofReadinessReconciliationRow` and `ProofReadinessReconciliationSummary` interfaces. +- Scope: one read-only proof-interpretation slice; no canonical-data repair, source activation, provider work, readiness rebuild, or external claim. diff --git a/docs/superpowers/plans/2026-07-21-prospective-field-proof.md b/docs/superpowers/plans/2026-07-21-prospective-field-proof.md new file mode 100644 index 00000000..2fc049e8 --- /dev/null +++ b/docs/superpowers/plans/2026-07-21-prospective-field-proof.md @@ -0,0 +1,282 @@ +# Prospective Per-Ticker Field Proof 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:** Add a prospective, append-only, per-ticker/per-field proof ledger with fail-closed validation, linear revisions, source-rights review, preview receipts, and explicit record commands without activating readiness or upgrading legacy proof. + +**Architecture:** Introduce one isolated module and ledger contract modeled on the repository's prospective consensus collector. The module carries independent technical and commercial evidence decisions, validates proposed rows against a virtual ledger, and permits all-or-nothing append only after exact preview-receipt revalidation. Existing readiness, canonical data, dashboard, and proof-reconciliation paths remain untouched. + +**Tech Stack:** Python 3.12, immutable dataclasses, CSV, SHA-256, argparse, pytest, Make, GitHub Actions. + +## Global Constraints + +- The new ledger is prospective only; never infer, migrate, or upgrade legacy narrative proof. +- A proof row, source reference, rights reference, or payload digest does not prove truth, licensing, commercial usability, readiness, or market validation. +- Keep technical write eligibility and commercial evidence eligibility independent. +- Do not integrate this contract with current readiness, canonical data, Company Workbench, proof reconciliation, or dashboards in this slice. +- Status and preview are filesystem read-only and network-free. +- Do not create `data/prospective_field_proofs.csv`, sample proof rows, CSV/JSON reports, screenshots, timing evidence, or bytecode artifacts during implementation verification. +- Preserve the existing 18 generated-file modifications as unstaged and uncommitted. +- Stage exact files only; never use `git add -A`. +- Push only `codex/personal-research-mode-mvp`; keep PR #113 open and draft; do not merge or deploy. +- Preserve all research-only, no-investment-advice, no-trading, explicit-Q4, EPS split-basis, synthetic-fixture, candidate-context, consensus, and calibration boundaries. + +--- + +## File Structure + +- Create `src/prospective_field_proof.py`: schema, records, validation, identity, revision integrity, preview/receipt, append, render, and CLI. +- Create `tests/test_prospective_field_proof.py`: unit and CLI contract coverage. +- Modify `Makefile`: status, preview, and record targets. +- Modify `tests/test_makefile_test_targets.py`: durable Make target contract. +- Modify `ROADMAP.md`: completed Stage A evidence primitive and explicit non-activation boundary. +- Modify `README.md`: correct artifact-writing and reviewer-cohort operating claims. +- Modify `docs/OPERATOR_GUIDE.md`: preview-before-record operating contract. +- Modify `docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md`: next executable stage and external classifications. +- Modify `tests/test_public_v1_release_docs.py`: durable documentation contract. + +--- + +### Task 1: Strict Record Schema And Ledger Integrity + +**Files:** +- Create: `tests/test_prospective_field_proof.py` +- Create: `src/prospective_field_proof.py` + +**Interfaces:** +- `SCHEMA_VERSION = "prospective-field-proof-v1"` +- `FIELDS` in the exact order specified by the design +- immutable `ProspectiveFieldProofRecord` +- `field_proof_identity`, `load_field_proofs`, `load_proposed_field_proofs`, `validate_field_proof_ledger` + +- [ ] **Step 1: Write schema and normalization tests** + +Create fixture helpers for a valid accepted record and exact CSV writer. Test exact header enforcement, missing/extra/reordered fields, ticker/field normalization, required non-placeholder values, controlled enums, lowercase SHA-256 payload digest, semantic proof ID, UTC timestamps, cutoff-independent timestamp ordering, and row-numbered errors. + +- [ ] **Step 2: Run schema tests and confirm RED** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests/test_prospective_field_proof.py -q +``` + +Expected: import failure because `src.prospective_field_proof` does not exist. + +- [ ] **Step 3: Implement the minimum immutable schema and loaders** + +Use `csv.DictReader`, strict header equality, `parse_utc_timestamp`, canonical normalization, semantic JSON serialization, and SHA-256. A missing ledger loads as an empty tuple; a present empty or malformed ledger fails closed. + +- [ ] **Step 4: Write revision-integrity tests** + +Cover one root per normalized scope, one child per parent, global unique IDs and identities, parent-before-child append order, same-scope parent, current-leaf revision, strictly later `reviewed_at`, missing parent, cross-scope parent, fork, cycle/disconnected cycle, stale-leaf revision, duplicate root, and valid independent scopes. + +- [ ] **Step 5: Run revision tests and confirm RED** + +Expected: failures for the unimplemented chain validation. + +- [ ] **Step 6: Implement linear revision integrity and rerun focused tests** + +Keep validation deterministic and report the ledger/input row number and precise invariant. + +- [ ] **Step 7: Verify, stage exact files, and commit** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests/test_prospective_field_proof.py -q +git diff --check +git add -- src/prospective_field_proof.py tests/test_prospective_field_proof.py +make staged-hygiene-check +git diff --cached --check +git commit -m "Add prospective field proof ledger integrity" +``` + +--- + +### Task 2: Preview Receipt, Rights Review, And Atomic Append + +**Files:** +- Modify: `tests/test_prospective_field_proof.py` +- Modify: `src/prospective_field_proof.py` + +**Interfaces:** +- immutable `FieldProofPreview` and `BatchFieldProofPreview` +- `preview_field_proof_batch` +- `append_reviewed_field_proof_batch` + +- [ ] **Step 1: Write independent eligibility tests** + +Test accepted/rejected/follow-up records; source identified/unavailable/disputed; payload reviewed/unavailable/rejected; unknown source; unapproved commercial rights; unsupported field; missing rights-decision reference; approved exact source/field; and research-mode technical eligibility independent from commercial eligibility. + +- [ ] **Step 2: Run the new tests and confirm RED** + +Expected: missing preview interfaces or eligibility fields. + +- [ ] **Step 3: Implement row and virtual-batch previews** + +Reuse `review_commercial_field_scope`. Validate every proposed row against a virtual ledger so a same-batch revision can follow its parent. Return explicit technical and commercial blockers; never collapse one into the other. + +- [ ] **Step 4: Write receipt and append tests** + +Test deterministic receipt over schema, normalized cutoff, commercial mode, existing-ledger digest, input digest, and source-rights-registry digest. Test invalidation after ledger/input/cutoff/mode/registry changes, missing confirmation, missing receipt, empty batch, mixed-validity all-or-nothing behavior, one header, ordered append, and commercial-mode fail-closed behavior. + +- [ ] **Step 5: Run receipt tests and confirm RED** + +Expected: append and receipt behavior is missing. + +- [ ] **Step 6: Implement receipt revalidation and append** + +Re-read and revalidate the destination ledger immediately before append. Require exact receipt and explicit confirmation. Write nothing unless every dependent gate passes. Append rows once; do not rewrite prior bytes. + +- [ ] **Step 7: Verify, stage exact files, and commit** + +Run the focused file, `git diff --check`, exact add of the two task files, staged hygiene, cached diff check, and commit: + +```bash +git commit -m "Add reviewed field proof preview and append" +``` + +--- + +### Task 3: Read-Only Status/Preview And Explicit Record Commands + +**Files:** +- Modify: `tests/test_prospective_field_proof.py` +- Modify: `src/prospective_field_proof.py` +- Modify: `Makefile` +- Modify: `tests/test_makefile_test_targets.py` + +- [ ] **Step 1: Write CLI and Make contract tests** + +Prove: + +- `status` reports absent/empty-valid/valid/invalid without creating or changing files; +- `preview` emits stable JSON including receipt, both eligibility states, blockers, and `write_performed=false`; +- `record` refuses absent confirmation or receipt and records only a fully revalidated batch; +- CLI errors are specific and nonzero; +- Make targets forward `INPUT`, `LEDGER`, `AS_OF`, and `PREVIEW_RECEIPT` exactly; +- the default ledger is `data/prospective_field_proofs.csv` and no default file is created by status/preview. + +- [ ] **Step 2: Run CLI/Make tests and confirm RED** + +Run the CLI-focused tests plus `tests/test_makefile_test_targets.py`. + +- [ ] **Step 3: Implement renderers, argparse commands, and Make targets** + +Add `.PHONY` entries and these targets: + +```make +prospective-field-proof-status +prospective-field-proof-preview +prospective-field-proof-record +``` + +The record target must reject blank `INPUT`, `AS_OF`, or `PREVIEW_RECEIPT` before Python execution. Wording must distinguish read-only preview from explicit append. + +- [ ] **Step 4: Prove filesystem non-mutation** + +Use test temporary directories and byte snapshots before/after status and preview. Assert no ledger, output, readiness, canonical, or legacy-proof file appears or changes. + +- [ ] **Step 5: Verify, stage exact files, and commit** + +Run focused tests, `git diff --check`, exact add of the four task files, staged hygiene, cached diff check, and commit: + +```bash +git commit -m "Expose prospective field proof workflow" +``` + +--- + +### Task 4: Operating Documentation And Release Evidence + +**Files:** +- Modify: `README.md` +- Modify: `ROADMAP.md` +- Modify: `docs/OPERATOR_GUIDE.md` +- Modify: `docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md` +- Modify: `tests/test_public_v1_release_docs.py` + +- [ ] **Step 1: Write failing documentation-contract tests** + +Require the three commands, prospective-only boundary, legacy non-upgrade boundary, independent technical/commercial status, preview receipt, no readiness activation, absent-ledger empty state, and exact next external unblock conditions. + +- [ ] **Step 2: Run documentation tests and confirm RED** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests/test_public_v1_release_docs.py -q +``` + +- [ ] **Step 3: Update operating documentation truthfully** + +Document the implemented contract and examples without creating example ledger rows. Correct these current documentation contradictions while in the same truth-maintenance slice: + +- the committed PR readiness snapshot is older than the excluded local generated snapshot; +- `make pilot-readiness-packet` writes a Markdown output and is not read-only; +- reviewer target is consistently 10–20 sessions; +- Stage A–G labels are maturity lanes, not replacements for numbered release stages. + +Classify external dependencies once: permitted point-in-time consensus and rights review, hosted account and operated controls, independent reviewers, peer evidence, and calibration cohort. + +- [ ] **Step 4: Run focused documentation and proof tests** + +Run the new module tests, Make target tests, release-doc tests, and `git diff --check`. + +- [ ] **Step 5: Stage exact docs/tests and commit** + +Run staged hygiene and cached diff check, then commit: + +```bash +git commit -m "Document prospective field proof operations" +``` + +--- + +### Task 5: Whole-Slice Verification, Independent Review, And Draft PR Update + +**Files:** No intended product edits unless a reviewer finds a concrete defect. + +- [ ] **Step 1: Run focused changed-module tests** + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest \ + tests/test_prospective_field_proof.py \ + tests/test_makefile_test_targets.py \ + tests/test_public_v1_release_docs.py -q +``` + +- [ ] **Step 2: Run the complete local release matrix** + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests -q +make dashboard-smoke +make research-dashboard-render-smoke +make public-wording-check +make commercial-beta-check +make public-check +make commercial-beta-release-check +make pilot-readiness-check TOP_N=10 +make pr-range-hygiene-check +make diff-hygiene-summary +git diff --check +``` + +Interpret expected fail-closed pilot results as product truth, not test failure. Do not rebuild readiness or stage the existing generated files. + +- [ ] **Step 3: Request independent whole-slice review** + +Review from the design commit through current HEAD for spec compliance, overclaims, append integrity, receipt completeness, source-rights independence, filesystem writes, regression risk, and generated-artifact hygiene. Fix concrete findings test-first and rerun affected gates. + +- [ ] **Step 4: Verify exact staged/unstaged scope** + +Ensure no product changes remain unstaged, no generated files are staged, and the only remaining worktree changes are the pre-existing 18 generated files. Run staged hygiene if any correction is staged. + +- [ ] **Step 5: Push and update PR #113** + +Push only `codex/personal-research-mode-mvp`. Update the draft PR with implemented contract, explicit non-activation boundary, tests/checks, generated-artifact exclusion, current committed-vs-local readiness distinction, external blockers, and next executable slice. Keep the PR draft and wait for exact-head CI. + +- [ ] **Step 6: Continue to the next safe maturity lane** + +After exact-head CI passes, select the next safe local slice from current evidence. Prefer correcting the verified responsive first-viewport handoff regression or strengthening Company Workbench proof activation preview. Do not connect structured proof to readiness without a separately approved design and tests. diff --git a/docs/superpowers/plans/2026-07-21-public-mobile-evidence-handoff.md b/docs/superpowers/plans/2026-07-21-public-mobile-evidence-handoff.md new file mode 100644 index 00000000..cb1df674 --- /dev/null +++ b/docs/superpowers/plans/2026-07-21-public-mobile-evidence-handoff.md @@ -0,0 +1,163 @@ +# Public Mobile Evidence Handoff 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:** Keep the existing fail-closed Single-Stock Report reading order while making its Data Health evidence handoff fully visible in the first 390x844 phone viewport. + +**Architecture:** Reuse the existing public shell and ticker-summary markup. Add only phone-scoped layout rules inside the existing `max-width: 640px` media query, protect them with a source contract, and use a fresh live browser measurement as the layout proof. + +**Tech Stack:** Python 3.12, Streamlit, existing HTML/CSS renderer, pytest, in-app browser responsive audit. + +## Global Constraints + +- Selected ticker, Use now, and Still withheld remain before the evidence handoff. +- Desktop layout remains unchanged. +- No copy, readiness, evidence, source, forecast, probability, or data state changes. +- Advanced details remain collapsed. +- No generated research or manual-review artifact enters the repository. + +--- + +### Task 1: Phone-Scoped Handoff Contract and CSS + +**Files:** +- Modify: `tests/test_dashboard_helpers.py` +- Modify: `src/dashboard.py` + +**Interfaces:** +- Consumes: existing `render_public_shell_mode_styles()`, `.public-ticker-summary`, `.public-ticker-action`, and `.public-primary-action`. +- Produces: phone-only compact summary spacing and action-link ordering; no Python interface change. + +- [ ] **Step 1: Write the failing source contract** + +Add a test that extracts the `max-width: 640px` block inside `render_public_shell_mode_styles()` and requires: + +```python +def test_public_single_stock_phone_keeps_evidence_handoff_in_first_view(): + source = Path("src/dashboard.py").read_text(encoding="utf-8") + shell_start = source.index("def render_public_shell_mode_styles") + mobile_start = source.index("@media (max-width: 640px)", shell_start) + mobile_end = source.index("", mobile_start) + mobile_css = source[mobile_start:mobile_end] + + summary_start = mobile_css.index(".public-ticker-summary {") + summary_end = mobile_css.index("}", summary_start) + summary_rule = mobile_css[summary_start:summary_end] + action_start = mobile_css.index(".public-ticker-action .public-primary-action {") + action_end = mobile_css.index("}", action_start) + action_rule = mobile_css[action_start:action_end] + + assert "grid-template-columns: 1fr;" in summary_rule + assert "gap: 0.25rem;" in summary_rule + assert "padding: 0.125rem 0 0.5rem;" in summary_rule + assert "order: -1;" in action_rule +``` + +- [ ] **Step 2: Run the test and confirm the current layout contract fails** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests/test_dashboard_helpers.py::test_public_single_stock_phone_keeps_evidence_handoff_in_first_view -q +``` + +Expected during the final-review correction: failure because the exact summary +rule still uses `padding: 0.5rem 0`; declarations elsewhere in the mobile block +must not satisfy the selector-local contract. + +- [ ] **Step 3: Add the minimal phone-only CSS** + +In the existing `@media (max-width: 640px)` block, change only the ticker-summary phone rules: + +```css +.public-ticker-summary { + grid-template-columns: 1fr; + gap: 0.25rem; + padding: 0.125rem 0 0.5rem; +} +.public-ticker-action .public-primary-action { + order: -1; +} +``` + +The DOM order and desktop CSS stay unchanged. + +- [ ] **Step 4: Run focused contracts** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests/test_dashboard_helpers.py::test_public_single_stock_phone_keeps_evidence_handoff_in_first_view tests/test_dashboard_helpers.py::test_public_workflow_controls_reserve_accessible_touch_targets tests/test_dashboard_helpers.py::test_public_app_shell_has_compact_mobile_rules -q +``` + +Expected: three passing tests. + +- [ ] **Step 5: Verify the live 390x844 route** + +Reload `http://localhost:8501/?mode=public&page=single-stock-report&ticker=NVDA&open=1` at 390x844 and measure the existing `Open Data Health` link. Expected: + +- the link top is at least 0; +- the link bottom is at most 844; +- `document.documentElement.scrollWidth <= window.innerWidth + 2`; +- no traceback is present; +- Selected ticker, Use now, and Still withheld precede the link in document order. + +Save the accepted screenshot only under `/tmp/stock-command-center-public-ux-review`. +The final accepted measurement is `top=792.53125px`, `bottom=836.53125px`, +leaving `7.46875px` of clearance in the `844px` viewport. The computed summary +spacing is `gap=4px`, `padding-top=2px`, and `padding-bottom=8px`. + +- [ ] **Step 6: Commit the tested product correction** + +Stage exactly `src/dashboard.py` and `tests/test_dashboard_helpers.py`, run staged hygiene, and commit with: + +```bash +git commit -m "Keep mobile evidence handoff in view" +``` + +--- + +### Task 2: QA and Continuation Evidence + +**Files:** +- Modify: `docs/DASHBOARD_QA.md` +- Modify: `ROADMAP.md` +- Modify: `docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md` + +**Interfaces:** +- Consumes: the verified phone CSS and fresh 390x844 browser result from Task 1. +- Produces: truthful current audit evidence and the persistent continuation boundary. + +- [ ] **Step 1: Update documentation contracts** + +Record: + +- all five public pages passed fresh desktop and phone review without overflow or tracebacks; +- Single-Stock Report required and received a phone-only evidence-handoff density correction; +- Data Health and Proof History remain answer/evidence destinations and do not gain invented calls to action; +- no readiness, source, research, or generated artifact state changed. + +- [ ] **Step 2: Run focused documentation and render tests** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests/test_public_v1_release_docs.py tests/test_browser_qa_evidence.py tests/test_public_ux_review_checklist.py tests/test_dashboard_render_smoke.py -q +make research-dashboard-render-smoke +``` + +Expected: all tests and route renders pass. + +- [ ] **Step 3: Run full release verification** + +Run the full pytest suite, dashboard and public render smokes, public wording, commercial beta, public, commercial release, pilot, diff, PR-range, whitespace, and staged hygiene gates required by the continuation contract. + +- [ ] **Step 4: Commit, push, and verify the draft PR** + +Stage exactly the three documentation files, run staged hygiene, commit with: + +```bash +git commit -m "Document public mobile workflow audit" +``` + +Push only `codex/personal-research-mode-mvp`, update draft PR #113, and require a successful exact-head GitHub Actions result. Keep the PR draft; do not merge or deploy. diff --git a/docs/superpowers/plans/2026-07-21-public-packaging-reconciliation.md b/docs/superpowers/plans/2026-07-21-public-packaging-reconciliation.md new file mode 100644 index 00000000..5c9dae48 --- /dev/null +++ b/docs/superpowers/plans/2026-07-21-public-packaging-reconciliation.md @@ -0,0 +1,81 @@ +# Public Packaging Reconciliation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the public README, LinkedIn Featured package, curated visual, and Workbench performance evidence tell one accurate answer-first Personal Research story. + +**Architecture:** Reuse the existing four-step Personal Research workflow and read-only public packaging checks. Tighten documentation and test contracts, bind Workbench first-useful timing to the rendered `Use now` answer, and replace the stale count-heavy social image with one reviewed real-app Workbench capture. No research data, forecasts, readiness state, or routing behavior changes. + +**Tech Stack:** Markdown, Python 3.12, pytest, Make, Streamlit, in-app browser QA, GitHub draft PR. + +## Global Constraints + +- Research-only; no investment advice, trading, orders, or price prediction. +- Do not fabricate research, readiness, source, rights, validation, hosting, or market evidence. +- Generated research CSV, JSON, report, sample-report, screenshot, timing, canonical-data, and readiness churn remains excluded. +- The one intentional `1200x627` LinkedIn thumbnail is reviewed before it replaces the committed curated asset. +- Stage exact files only; never use `git add -A`. +- Keep PR #113 draft; do not merge or deploy. + +--- + +### Task 1: Red Tests for the Public Story and Evidence Contracts + +**Files:** +- Modify: `tests/test_public_v1_release_docs.py` +- Modify: `tests/test_public_performance_gate.py` +- Modify: `tests/test_browser_qa_evidence.py` + +- [ ] Add contracts requiring one README reviewer entry point, the four-step Personal Research workflow as primary, and the five-page Public demo as secondary. +- [ ] Add LinkedIn contracts for the evidence-first title, default-branch/draft-preview link boundary, Workbench visual, and no stale readiness-count guidance. +- [ ] Require `Use now` as the Company Workbench first-useful performance marker. +- [ ] Require the browser-QA asset contract to describe a `1200x627` Company Workbench answer-first visual and its visible evidence markers. +- [ ] Run the focused tests and confirm they fail only because the old packaging contracts remain. + +--- + +### Task 2: Reconcile README, LinkedIn, Share Check, and Performance Marker + +**Files:** +- Modify: `README.md` +- Modify: `docs/LINKEDIN_PROJECT_BRIEF.md` +- Modify: `docs/PUBLIC_RELEASE_CHECKLIST.md` +- Modify: `Makefile` or the existing LinkedIn share-check source located during implementation +- Modify: `src/public_performance_gate.py` +- Modify: `src/browser_qa_evidence.py` + +- [ ] Consolidate the README opening into one `External Reviewer Start Here` section. +- [ ] Make Research Desk -> Discover -> Company Workbench -> Monitor the primary product path and keep the controlled five-page Public demo secondary. +- [ ] Replace LinkedIn title, description, visual guidance, and link guidance with maturity-accurate copy. +- [ ] Update the read-only share check to print the same title, link boundary, visual rule, and research-only stop rules. +- [ ] Bind Workbench first-useful timing to `Use now`, retaining route identity as a full-settle marker. +- [ ] Update the browser-QA asset record to the Workbench answer-first visual. +- [ ] Run focused tests and confirm they pass. + +--- + +### Task 3: Capture and Review the Curated Workbench Thumbnail + +**Files:** +- Modify: `docs/assets/linkedin-public-dashboard.png` + +- [ ] Open the current Company Workbench route for one selected ticker and wait for the final answer state. +- [ ] Capture an intentional `1200x627` frame showing Company Workbench, `Use now`, `Still withheld`, Data Health handoff, stop condition, and the research-only boundary. +- [ ] Exclude numerical readiness/source-date claims, loading states, errors, operator controls, raw tables, and generated artwork. +- [ ] Inspect the candidate beside the current app reference, verify readable composition, then replace the curated asset. +- [ ] Verify exact pixel dimensions and run the focused asset/browser-QA contracts. + +--- + +### Task 4: Record Verified Truth and Complete Release Gates + +**Files:** +- Modify: `ROADMAP.md` +- Modify: `docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md` +- Modify: other narrowly relevant QA documentation only if an existing contract requires it + +- [ ] Record the packaging slice without claiming new research readiness, hosting, reviewer, source-rights, consensus, calibration, or market validation evidence. +- [ ] Run focused tests, full pytest, dashboard and research render smokes, public wording/checks, LinkedIn share check, browser-QA evidence, public and Commercial Research performance gates, Commercial Research release check, pilot readiness, PR-range/diff/staged hygiene, and whitespace checks. +- [ ] Confirm generated research churn remains unstaged and the intentional curated thumbnail is the only staged PNG. +- [ ] Commit the coherent verified implementation, push only `codex/personal-research-mode-mvp`, and update PR #113 with the slice and verification evidence. +- [ ] Keep PR #113 open and draft, then require exact-head CI success before handoff. diff --git a/docs/superpowers/plans/2026-07-21-public-ux-post-fix-outcome-contract.md b/docs/superpowers/plans/2026-07-21-public-ux-post-fix-outcome-contract.md new file mode 100644 index 00000000..0f037175 --- /dev/null +++ b/docs/superpowers/plans/2026-07-21-public-ux-post-fix-outcome-contract.md @@ -0,0 +1,278 @@ +# Public UX Post-Fix Outcome Contract 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 a verified `resolved_post_fix` public UX audit row satisfy the local share-review gate while preserving separate audit counts and fail-closed handling for every incomplete or unknown outcome. + +**Architecture:** `src/public_ux_review_checklist.py` remains the owner of the public UX outcome vocabulary and status decision. It will publish an explicit successful-outcome set used by both the checklist parser and `src/project_status.py`, avoiding duplicated strings and unsafe prefix matching. Tests first prove the current false-limited state and the project-status undercount, then documentation records the corrected local-only maturity claim. + +**Tech Stack:** Python 3, pytest, Make, Markdown, GitHub Actions. + +## Global Constraints + +- Research-only; this slice cannot change research conclusions, readiness, data, forecasts, probabilities, or recommendations. +- `resolved` and `resolved_post_fix` are the only successful public UX outcomes. +- Raw classification counts remain separate. +- `pending`, `intentionally_deferred`, `environment_limited`, `skipped`, `blocked_with_evidence`, and unknown outcomes remain fail closed. +- Do not edit or stage `/tmp` review notes or screenshots. +- Do not run `make readiness` or generate/stage CSV, JSON, report, sample-report, screenshot, timing, readiness, canonical-data, or manual-review churn. +- Stage exact files only; never use `git add -A`. +- Keep PR #113 open and draft; do not merge or deploy. + +--- + +### Task 1: Prove the post-fix classification defect + +**Files:** +- Modify: `tests/test_public_ux_review_checklist.py` +- Modify: `tests/test_project_status.py` + +**Interfaces:** +- Consumes: `public_ux_review_notes_status(notes_path)` and `project_status._public_ux_stage_from_status(status)`. +- Produces: regression coverage requiring an explicit successful post-fix outcome and a combined resolved-row count. + +- [ ] **Step 1: Add the failing checklist regression test** + +Add a test beside `test_public_ux_review_notes_status_marks_share_review_ready_when_all_rows_resolved` that writes all ten review rows, uses `resolved_post_fix` only for Single-Stock Report phone, and asserts: + +```python +assert status["status"] == "review_complete" +assert status["share_review_gate"] == "share_review_ready" +assert status["pending_rows"] == 0 +assert status["problem_rows"] == [] +assert status["classification_counts"]["resolved"] == 9 +assert status["classification_counts"]["resolved_post_fix"] == 1 +``` + +- [ ] **Step 2: Add the failing project-status regression test** + +Extend `test_project_status_stage_map_reports_completed_public_ux_review` with this status input: + +```python +"classification_counts": {"resolved": 9, "resolved_post_fix": 1}, +``` + +Keep the existing assertion that the evidence contains: + +```python +"10/10 public desktop/mobile review rows resolved" +``` + +- [ ] **Step 3: Run the focused tests and verify the red state** + +Run: + +```bash +python3 -m pytest \ + tests/test_public_ux_review_checklist.py::test_public_ux_review_notes_status_marks_share_review_ready_after_verified_fix \ + tests/test_project_status.py::test_project_status_stage_map_reports_completed_public_ux_review -q +``` + +Expected: both tests fail for the intended reasons—the parser reports `review_limited`, and project status reports `9/10`. + +- [ ] **Step 4: Confirm no production file changed during red** + +Run: + +```bash +git status --short +``` + +Expected: only the two test files and this plan are changed or committed; no data or generated artifact appears. + +--- + +### Task 2: Implement the explicit successful-outcome contract + +**Files:** +- Modify: `src/public_ux_review_checklist.py` +- Modify: `src/project_status.py` +- Test: `tests/test_public_ux_review_checklist.py` +- Test: `tests/test_project_status.py` + +**Interfaces:** +- Produces: `SUCCESSFUL_REVIEW_CLASSIFICATIONS`, an immutable explicit set containing `resolved` and `resolved_post_fix`. +- Consumes: raw `classification_counts` without collapsing keys. + +- [ ] **Step 1: Add the explicit vocabulary** + +Near the existing public UX review constants, add: + +```python +SUCCESSFUL_REVIEW_CLASSIFICATIONS = frozenset({"resolved", "resolved_post_fix"}) +``` + +Add `resolved_post_fix` to the rendered review-log classification vocabulary, describing it as a fresh verified recapture after a fix. + +- [ ] **Step 2: Use the vocabulary in problem-row classification** + +Change the status loop condition from the literal two-value comparison to: + +```python +if classification != "pending" and classification not in SUCCESSFUL_REVIEW_CLASSIFICATIONS: +``` + +Do not use prefix matching and do not modify raw classification counts. + +- [ ] **Step 3: Sum successful outcomes in project status** + +Import `SUCCESSFUL_REVIEW_CLASSIFICATIONS` into `src/project_status.py` and replace the literal `resolved` count with: + +```python +resolved = sum(int(counts.get(classification) or 0) for classification in SUCCESSFUL_REVIEW_CLASSIFICATIONS) +``` + +- [ ] **Step 4: Run the red tests and verify green** + +Run the exact focused command from Task 1 Step 3. + +Expected: 2 passed. + +- [ ] **Step 5: Run both complete focused modules** + +Run: + +```bash +python3 -m pytest tests/test_public_ux_review_checklist.py tests/test_project_status.py -q +``` + +Expected: all tests pass. + +- [ ] **Step 6: Prove unknown outcomes still fail closed** + +Add or extend a checklist test to record `resolved_typo` for one completed row and assert: + +```python +assert status["status"] == "review_has_deferred_or_limited_items" +assert status["share_review_gate"] == "review_limited" +assert status["problem_rows"][0]["classification"] == "resolved_typo" +``` + +Run the complete focused modules again and expect all tests to pass. + +--- + +### Task 3: Record the corrected maturity state + +**Files:** +- Modify: `ROADMAP.md` +- Modify: `docs/DASHBOARD_QA.md` +- Modify: `docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md` + +**Interfaces:** +- Consumes: focused green evidence and the existing ten-row `/tmp` audit status. +- Produces: truthful local-only roadmap and continuation language. + +- [ ] **Step 1: Verify the live local notes status without editing it** + +Run: + +```bash +make public-ux-review-notes-check +make project-status-check +``` + +Expected: + +- `status: review_complete` +- `share_review_gate: share_review_ready` +- raw counts `resolved: 9, resolved_post_fix: 1` +- project status evidence `10/10 public desktop/mobile review rows resolved` + +- [ ] **Step 2: Update ROADMAP.md** + +Record that the ten desktop/phone rows are locally `share_review_ready`, with nine direct resolutions and one verified post-fix recapture. Preserve the boundary that this is screenshot-based local QA, not hosted, accessibility-conformance, external-reviewer, freshness, demand, or market evidence. + +- [ ] **Step 3: Update docs/DASHBOARD_QA.md** + +Document the explicit meaning of `resolved_post_fix`, the final 390x844 recapture, and the fail-closed treatment of unknown or incomplete labels. + +- [ ] **Step 4: Update the continuation contract** + +Add the implementation anchor and corrected public UX status to `docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md`. Keep the exact next external maturity lane as one permitted point-in-time consensus snapshot and source review. + +- [ ] **Step 5: Run documentation and focused contract tests** + +Run: + +```bash +python3 -m pytest \ + tests/test_public_ux_review_checklist.py \ + tests/test_project_status.py \ + tests/test_launchers.py \ + tests/test_pilot_review_feedback_template.py \ + tests/test_public_v1_release_docs.py -q +``` + +Expected: all tests pass. + +--- + +### Task 4: Verify, package, and synchronize the slice + +**Files:** +- No planned repository modifications; stop and return to the relevant earlier task if verification exposes a slice-related defect. + +**Interfaces:** +- Consumes: completed implementation and documentation. +- Produces: one clean coherent commit, synchronized draft PR, and exact-head CI evidence. + +- [ ] **Step 1: Run full local verification** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests -q +make dashboard-smoke +make research-dashboard-render-smoke +make public-wording-check +make commercial-beta-check +make public-check +make commercial-beta-release-check +make pilot-readiness-check TOP_N=10 +make diff-hygiene-summary +make pr-range-hygiene-check +git diff --check +``` + +Expected: commands exit zero. The pilot product verdict may remain truthfully blocked only because readiness is stale. + +- [ ] **Step 2: Review and stage exact files** + +Run `git diff --` for the two production files, two test files, and three documentation files. Stage only those exact intentional paths. Do not stage `/tmp`, data, outputs, screenshots, or generated artifacts. + +- [ ] **Step 3: Verify the staged package** + +Run: + +```bash +make staged-hygiene-check +git diff --cached --check +``` + +Expected: only product/code/docs/test files; zero generated or manual-review paths. + +- [ ] **Step 4: Commit and push** + +Commit with: + +```bash +git commit -m "Recognize verified public UX post-fix outcomes" +git push origin codex/personal-research-mode-mvp +``` + +- [ ] **Step 5: Update draft PR #113** + +Post a concise PR update covering the root cause, explicit successful-outcome contract, raw-count preservation, fail-closed unknown labels, tests, generated-artifact exclusion, and unchanged external gates. Keep the PR draft. + +- [ ] **Step 6: Verify exact-head GitHub CI and final repository state** + +Wait for the Commercial Research Beta workflow on the exact pushed SHA. Require success, then verify: + +```bash +git status --short --branch +git rev-list --left-right --count @{upstream}...HEAD +gh pr view 113 --json state,isDraft,mergeable,headRefOid,statusCheckRollup,url +``` + +Expected: clean, `0 0`, PR open/draft/mergeable, exact head, successful CI. diff --git a/docs/superpowers/plans/2026-07-22-field-proof-stage-b-audit.md b/docs/superpowers/plans/2026-07-22-field-proof-stage-b-audit.md new file mode 100644 index 00000000..8ae46bf9 --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-field-proof-stage-b-audit.md @@ -0,0 +1,43 @@ +# Stage B Prospective Field-Proof Audit 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:** Add deterministic read-only field-proof history audit and clearer per-row preview explanations without adding any write, readiness, or Workbench mapping. + +**Architecture:** Extend the pure `prospective_field_proof` module with immutable audit rows and an aggregate audit contract derived from the already validated append-only ledger. Expose stdout-only text/JSON through one Make target. Reuse the current source-rights registry only to explain active-head commercial blockers. + +**Tech Stack:** Python 3.12, frozen dataclasses, argparse, Make, pytest. + +--- + +### Task 1: Define the read-only audit contract test-first + +**Files:** `src/prospective_field_proof.py`, `tests/test_prospective_field_proof.py` + +- [ ] Add failing tests for absent, two-revision, rejected/follow-up, invalid, deterministic ordering, and no-write audits. +- [ ] Implement frozen audit row/summary contracts, active-head resolution, revision numbers, blocker aggregation, and text rendering. +- [ ] Add the `audit` CLI with controlled invalid-ledger errors and stable JSON. +- [ ] Run `python3 -m pytest tests/test_prospective_field_proof.py -q`. + +### Task 2: Improve preview explanation and expose the Make target + +**Files:** `src/prospective_field_proof.py`, `Makefile`, `tests/test_makefile_test_targets.py` + +- [ ] Add failing tests for per-row preview answers, receipt-binding copy, and a read-only Make audit target. +- [ ] Render row state/reason/blockers and receipt persistence boundary. +- [ ] Add `prospective-field-proof-audit` without any output-file parameter or write command. +- [ ] Run focused module and Make-target tests. + +### Task 3: Reconcile documentation and maturity status + +**Files:** `README.md`, `ROADMAP.md`, `docs/OPERATOR_GUIDE.md`, `docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md`, `tests/test_public_v1_release_docs.py` + +- [ ] Document the audit answer, receipt boundary, and no-mapping rule. +- [ ] Mark Stage B complete locally only after all direct tests pass and set Priority 3 authoring design as next. + +### Task 4: Verify and release the slice + +- [ ] Run focused tests, `python3 -m pytest tests -q`, dashboard/render/public/commercial-beta/pilot/hygiene gates, and `git diff --check`. +- [ ] Confirm audit and preview did not change any tracked or untracked scoped artifact. +- [ ] Stage exact code/test/docs/Makefile paths only; run staged hygiene. +- [ ] Commit, push only the feature branch, update draft PR #113, and require exact-head CI before Priority 3. diff --git a/docs/superpowers/plans/2026-07-22-in-app-research-record-authoring.md b/docs/superpowers/plans/2026-07-22-in-app-research-record-authoring.md new file mode 100644 index 00000000..056ee5ef --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-in-app-research-record-authoring.md @@ -0,0 +1,931 @@ +# In-App Research-Record Authoring 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:** Add safe command-line-free thesis, evidence, catalyst, and outcome authoring to Company Workbench through an exact `Validate -> Preview -> Confirm and save` flow over the existing append-only ledgers. + +**Architecture:** A new pure composition module owns record construction, existing-validator dispatch, cross-ledger references, preview receipts, ledger fingerprints, and one-ledger confirmation. A small Streamlit component owns widgets and session state and is called from Company Workbench after the journal answer; production dashboard code never opens a ledger directly. Tests use temporary ledgers and a test-only Streamlit fixture, while the product route renders the composer closed by default. + +**Tech Stack:** Python 3.12, frozen dataclasses, `hashlib`/`json`/`uuid`, existing CSV ledger engines, Streamlit, pytest, Streamlit AppTest, existing render/release gates. + +## Global Constraints + +- Research-only; no investment advice, recommendation, company ranking, expected-return score, direct buy/sell instruction, allocation, position sizing, live holding, account import, broker connection, order routing, auto-trading, or post-earnings price prediction. +- Reuse `research_thesis_journal.py`, `catalyst_evidence_timeline.py`, and `research_outcome_review.py`; do not create a generic ledger or duplicate their validators. +- Drafts, previews, and the session-only receipt are untrusted session state. Candidate context cannot become trusted evidence, and no receipt is persisted to a ledger or generated artifact. +- Profile and ticker are locked to the active Company Workbench context. +- Preview writes nothing. Confirmation requires an exact current draft, unchanged current ledger fingerprint, unchanged context, and explicit reviewed-source confirmation. +- A successful confirmation appends exactly one row to exactly one established ledger and consumes the preview receipt. +- Tests use temporary ledgers only. Do not append production research data during implementation or verification. +- Do not change readiness, canonical data, source rights, forecasts, DCF assumptions, scenarios, consensus, valuation, peers, backtests, calibration, or numerical probability. +- Empty ledgers remain empty until an explicit successful confirmation targets that exact ledger. +- EPS split basis remains unverified without explicit primary proof; Q4 actuals still require an explicit SEC-filed Q4 table; synthetic fixtures remain test-only. +- Do not run `make readiness`, broad refresh/import/apply commands, or generated CSV/JSON/report/sample-report/screenshot/timing commands. +- Stage exact intentional files only; never use `git add -A`; keep the existing 18 generated CSV/report changes unstaged. + +## File Structure + +- Create `src/research_record_authoring.py`: immutable authoring contracts, record mapping, preview validation, receipt and ledger fingerprints, cross-ledger reference checks, and confirmed dispatch. +- Create `src/research_record_authoring_ui.py`: record-specific field definitions, Streamlit widget rendering, session preview invalidation, confirmation, and saved-result feedback. +- Create `tests/test_research_record_authoring.py`: pure composition, read-only preview, stale receipt, cross-scope, one-ledger-only, and no-fabrication tests. +- Create `tests/fixtures/research_record_authoring_app.py`: test-only Streamlit host wired exclusively to temporary paths supplied by the test process. +- Create `tests/test_research_record_authoring_ui.py`: field-model and AppTest interaction coverage without production writes. +- Modify `src/dashboard.py`: import and render the composer after the journal/outcome answer only in Personal Research Company Workbench. +- Modify `tests/test_dashboard_helpers.py`: source-boundary and placement contracts. +- Modify `tests/test_dashboard_render_smoke.py`: closed-by-default Workbench render contract. +- Modify `src/browser_qa_evidence.py` and `tests/test_browser_qa_evidence.py`: add the collapsed authoring marker and mobile stop rule without claiming save evidence from screenshots. +- Modify `README.md`, `docs/PRODUCT_SPEC.md`, `ROADMAP.md`, `docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md`, and `tests/test_public_v1_release_docs.py`: document the implemented workflow and truthful boundaries after runtime evidence exists. + +--- + +### Task 1: Pure Four-Kind Preview Composition + +**Files:** +- Create: `src/research_record_authoring.py` +- Create: `tests/test_research_record_authoring.py` + +**Interfaces:** +- Produces: `AuthoringPaths`, `AuthoringDraft`, `AuthoringPreview`, `build_authoring_draft()`, `authoring_draft_digest()`, and `preview_authoring_record()`. +- Consumes: existing `JournalEntry`, `CatalystEvent`, `ResearchOutcome`, their load functions, and their preview/validation functions. +- Invariant: preview reads only the targeted ledger plus the thesis journal needed for evidence/outcome references and never creates a path. + +- [ ] **Step 1: Write failing preview tests** + +Add these contracts to `tests/test_research_record_authoring.py`: + +```python +from dataclasses import replace +from pathlib import Path + +import pytest + +from src.research_record_authoring import ( + AuthoringPaths, + build_authoring_draft, + preview_authoring_record, +) +from src.research_thesis_journal import JournalEntry, append_journal_entry + + +def _paths(tmp_path: Path) -> AuthoringPaths: + return AuthoringPaths( + journal=tmp_path / "research_thesis_journal.csv", + catalysts=tmp_path / "catalyst_evidence.csv", + outcomes=tmp_path / "research_outcome_reviews.csv", + ) + + +def _thesis_entry() -> JournalEntry: + return JournalEntry( + schema_version="research-thesis-journal-v1", + entry_id="entry-existing", + profile_key="demo", + ticker="SYN1", + thesis_id="thesis-syn1", + entry_type="thesis", + recorded_at="2026-07-20T12:00:00Z", + effective_at="2026-07-20T11:00:00Z", + reviewer="fixture-reviewer", + summary="Existing synthetic thesis.", + evidence_direction="", + source="", + source_ref="", + source_published_at="", + confidence="0.50", + review_due_date="2026-08-20", + supersedes_entry_id="", + ) + + +@pytest.mark.parametrize( + ("kind", "fields", "destination"), + ( + ("thesis", {"thesis_id": "thesis-new", "summary": "Reviewed hypothesis.", "effective_at": "2026-07-22T10:00:00Z", "reviewer": "owner", "confidence": "0.60", "review_due_date": "2026-08-22", "supersedes_entry_id": ""}, "research_thesis_journal.csv"), + ("evidence", {"thesis_id": "thesis-syn1", "summary": "Source-backed evidence.", "effective_at": "2026-07-22T10:00:00Z", "reviewer": "owner", "evidence_direction": "supporting", "source": "company_ir", "source_ref": "https://example.invalid/source", "source_published_at": "2026-07-22T09:00:00Z"}, "research_thesis_journal.csv"), + ("catalyst", {"event_type": "earnings", "title": "Scheduled results", "summary": "Reviewed event context.", "effective_at": "2026-08-20T21:00:00Z", "published_at": "2026-07-22T09:00:00Z", "retrieved_at": "2026-07-22T10:00:00Z", "source": "company_ir", "source_ref": "https://example.invalid/event", "evidence_state": "candidate_context_only", "reviewer": "owner"}, "catalyst_evidence.csv"), + ("outcome", {"thesis_id": "thesis-syn1", "original_thesis_entry_id": "entry-existing", "reviewed_at": "2026-07-22T12:00:00Z", "observation_start": "2026-07-20T12:00:00Z", "observation_end": "2026-07-22T11:00:00Z", "reviewer": "owner", "outcome_state": "mixed", "summary": "Reviewed outcome.", "source": "reviewed_research_record", "source_ref": "journal://entry-existing", "source_published_at": "2026-07-22T11:00:00Z", "learning": "Separate the evidence lanes."}, "research_outcome_reviews.csv"), + ), +) +def test_preview_maps_all_four_kinds_without_writing(tmp_path, kind, fields, destination): + paths = _paths(tmp_path) + append_journal_entry(paths.journal, _thesis_entry()) + before = {path: path.read_bytes() if path.exists() else None for path in paths.all()} + draft = build_authoring_draft(kind, profile_key="demo", ticker="syn1", fields=fields) + + preview = preview_authoring_record( + draft, + paths=paths, + previewed_at="2026-07-22T12:30:00Z", + generated_id=f"{kind}-generated", + ) + + assert preview.state == "reviewable" + assert preview.profile_key == "demo" + assert preview.ticker == "SYN1" + assert preview.destination_label == destination + assert preview.write_performed is False + assert preview.receipt + assert {path: path.read_bytes() if path.exists() else None for path in paths.all()} == before + + +def test_preview_rejects_cross_scope_evidence_and_outcome_references(tmp_path): + paths = _paths(tmp_path) + append_journal_entry(paths.journal, _thesis_entry()) + fields = {"thesis_id": "thesis-syn1", "summary": "Evidence.", "effective_at": "2026-07-22T10:00:00Z", "reviewer": "owner", "evidence_direction": "supporting", "source": "company_ir", "source_ref": "ref", "source_published_at": "2026-07-22T09:00:00Z"} + + preview = preview_authoring_record( + build_authoring_draft("evidence", profile_key="other", ticker="SYN1", fields=fields), + paths=paths, + previewed_at="2026-07-22T12:30:00Z", + generated_id="evidence-generated", + ) + + assert preview.state == "rejected" + assert preview.reason == "thesis_id must reference an existing thesis in this profile and ticker" + assert preview.receipt == "" +``` + +- [ ] **Step 2: Run the tests and verify the missing-module failure** + +Run: `PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests/test_research_record_authoring.py -q` + +Expected: collection fails with `ModuleNotFoundError: No module named 'src.research_record_authoring'`. + +- [ ] **Step 3: Implement immutable preview composition** + +Create `src/research_record_authoring.py` with these public contracts and complete internal dispatch: + +```python +from __future__ import annotations + +import hashlib +import json +from dataclasses import asdict, dataclass +from pathlib import Path +from types import MappingProxyType +from typing import Mapping + +from src.catalyst_evidence_timeline import CatalystEvent, load_catalyst_events, preview_event +from src.research_outcome_review import ResearchOutcome, load_outcomes, preview_outcome +from src.research_thesis_journal import ( + JOURNAL_SCHEMA_VERSION, + JournalEntry, + load_journal_entries, + validate_journal_entry, +) + +RECORD_KINDS = ("thesis", "evidence", "catalyst", "outcome") + + +@dataclass(frozen=True) +class AuthoringPaths: + journal: Path + catalysts: Path + outcomes: Path + + def all(self) -> tuple[Path, Path, Path]: + return (self.journal, self.catalysts, self.outcomes) + + +@dataclass(frozen=True) +class AuthoringDraft: + record_kind: str + profile_key: str + ticker: str + fields: tuple[tuple[str, str], ...] + + def field_map(self) -> Mapping[str, str]: + return MappingProxyType(dict(self.fields)) + + +@dataclass(frozen=True) +class AuthoringPreview: + state: str + reason: str + record_kind: str + profile_key: str + ticker: str + destination_label: str + previewed_at: str + persisted_fields: tuple[tuple[str, str], ...] + receipt: str + draft_digest: str + ledger_fingerprint: str + record: JournalEntry | CatalystEvent | ResearchOutcome | None + write_performed: bool = False + + +def build_authoring_draft(record_kind: str, *, profile_key: str, ticker: str, fields: Mapping[str, object]) -> AuthoringDraft: + kind = str(record_kind or "").strip().lower() + if kind not in RECORD_KINDS: + raise ValueError(f"Unsupported record kind: {record_kind!r}") + profile = str(profile_key or "").strip() + symbol = str(ticker or "").strip().upper() + if not profile: + raise ValueError("profile_key is required") + if not symbol: + raise ValueError("ticker is required") + normalized = tuple(sorted((str(key), str(value or "").strip()) for key, value in fields.items())) + return AuthoringDraft(kind, profile, symbol, normalized) + + +def _stable_digest(payload: object) -> str: + encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode() + return hashlib.sha256(encoded).hexdigest() + + +def authoring_draft_digest(draft: AuthoringDraft) -> str: + return _stable_digest(asdict(draft)) + + +def _ledger_fingerprint(path: Path) -> str: + payload = b"missing\0" if not path.exists() else b"present\0" + path.read_bytes() + return hashlib.sha256(payload).hexdigest() + + +def _destination(draft: AuthoringDraft, paths: AuthoringPaths) -> Path: + return paths.journal if draft.record_kind in {"thesis", "evidence"} else paths.catalysts if draft.record_kind == "catalyst" else paths.outcomes + + +def _scoped_theses(paths: AuthoringPaths, draft: AuthoringDraft) -> tuple[JournalEntry, ...]: + return tuple(row for row in load_journal_entries(paths.journal) if row.entry_type == "thesis" and row.profile_key == draft.profile_key and row.ticker.upper() == draft.ticker) + + +def _build_record(draft: AuthoringDraft, *, previewed_at: str, generated_id: str, paths: AuthoringPaths): + values = dict(draft.fields) + if draft.record_kind in {"thesis", "evidence"}: + if draft.record_kind == "evidence" and not any(row.thesis_id == values.get("thesis_id") for row in _scoped_theses(paths, draft)): + raise ValueError("thesis_id must reference an existing thesis in this profile and ticker") + return JournalEntry( + schema_version=JOURNAL_SCHEMA_VERSION, + entry_id=generated_id, + profile_key=draft.profile_key, + ticker=draft.ticker, + thesis_id=values.get("thesis_id", ""), + entry_type=draft.record_kind, + recorded_at=previewed_at, + effective_at=values.get("effective_at", ""), + reviewer=values.get("reviewer", ""), + summary=values.get("summary", ""), + evidence_direction=values.get("evidence_direction", ""), + source=values.get("source", ""), + source_ref=values.get("source_ref", ""), + source_published_at=values.get("source_published_at", ""), + confidence=values.get("confidence", ""), + review_due_date=values.get("review_due_date", ""), + supersedes_entry_id=values.get("supersedes_entry_id", ""), + ) + if draft.record_kind == "catalyst": + return CatalystEvent(schema_version="catalyst-evidence-v1", event_id=generated_id, profile_key=draft.profile_key, ticker=draft.ticker, **values) + theses = _scoped_theses(paths, draft) + if not any(row.thesis_id == values.get("thesis_id") and row.entry_id == values.get("original_thesis_entry_id") for row in theses): + raise ValueError("outcome must reference an existing thesis entry in this profile and ticker") + return ResearchOutcome(schema_version="research-outcome-review-v1", outcome_id=generated_id, profile_key=draft.profile_key, ticker=draft.ticker, **values) + + +def preview_authoring_record(draft: AuthoringDraft, *, paths: AuthoringPaths, previewed_at: str, generated_id: str) -> AuthoringPreview: + destination = _destination(draft, paths) + draft_digest = authoring_draft_digest(draft) + ledger_fingerprint = _ledger_fingerprint(destination) + try: + record = _build_record(draft, previewed_at=previewed_at, generated_id=generated_id, paths=paths) + if isinstance(record, JournalEntry): + validate_journal_entry(record, existing_entries=load_journal_entries(paths.journal)) + elif isinstance(record, CatalystEvent): + event_preview = preview_event(record, existing=load_catalyst_events(paths.catalysts)) + if event_preview.state == "rejected": + raise ValueError(event_preview.reason) + else: + outcome_preview = preview_outcome(record, existing=load_outcomes(paths.outcomes)) + if outcome_preview.state == "rejected": + raise ValueError(outcome_preview.reason) + except (OSError, ValueError) as exc: + return AuthoringPreview("rejected", str(exc), draft.record_kind, draft.profile_key, draft.ticker, destination.name, previewed_at, (), "", draft_digest, ledger_fingerprint, None) + persisted = tuple((key, str(value)) for key, value in asdict(record).items()) + receipt = _stable_digest({"draft": draft_digest, "ledger": ledger_fingerprint, "record": persisted, "destination": destination.name}) + return AuthoringPreview("reviewable", "", draft.record_kind, draft.profile_key, draft.ticker, destination.name, previewed_at, persisted, receipt, draft_digest, ledger_fingerprint, record) +``` + +The implementation must catch read/validation errors as rejected previews, but must not catch programmer errors such as an unexpected dataclass shape. + +- [ ] **Step 4: Run focused preview tests** + +Run: `PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests/test_research_record_authoring.py -q` + +Expected: all Task 1 tests pass; no file other than the pre-seeded temporary journal changes. + +- [ ] **Step 5: Commit Task 1** + +Run: + +```bash +git add -- src/research_record_authoring.py tests/test_research_record_authoring.py +git diff --cached --check +make staged-hygiene-check +git commit -m "Add research record authoring previews" +``` + +### Task 2: Exact Confirmation, Stale Receipts, And One-Ledger Dispatch + +**Files:** +- Modify: `src/research_record_authoring.py` +- Modify: `tests/test_research_record_authoring.py` + +**Interfaces:** +- Produces: `AuthoringSaveResult` and `confirm_authoring_preview()`. +- Consumes: the Task 1 immutable preview and the three existing append functions. +- Invariant: any context, draft, receipt, or ledger mismatch returns `preview_stale` and writes nothing. + +- [ ] **Step 1: Add failing confirmation tests** + +Add tests that seed a valid preview, then prove confirmation is denied without the checkbox, denied after draft edits, denied after a concurrent append, and writes only its selected ledger: + +```python +from src.research_record_authoring import confirm_authoring_preview +from src.research_thesis_journal import load_journal_entries + + +def test_confirmation_requires_review_and_appends_exactly_one_ledger(tmp_path): + paths = _paths(tmp_path) + draft = build_authoring_draft("thesis", profile_key="demo", ticker="SYN1", fields={"thesis_id": "thesis-new", "summary": "Reviewed hypothesis.", "effective_at": "2026-07-22T10:00:00Z", "reviewer": "owner", "confidence": "0.60", "review_due_date": "2026-08-22", "supersedes_entry_id": ""}) + preview = preview_authoring_record(draft, paths=paths, previewed_at="2026-07-22T12:30:00Z", generated_id="thesis-generated") + + denied = confirm_authoring_preview(preview, current_draft=draft, paths=paths, active_profile_key="demo", active_ticker="SYN1", active_kind="thesis", confirm_reviewed=False) + assert denied.state == "confirmation_required" + assert not any(path.exists() for path in paths.all()) + + saved = confirm_authoring_preview(preview, current_draft=draft, paths=paths, active_profile_key="demo", active_ticker="SYN1", active_kind="thesis", confirm_reviewed=True) + assert saved.state == "saved" + assert saved.record_id == "thesis-generated" + assert saved.write_performed is True + assert [row.entry_id for row in load_journal_entries(paths.journal)] == ["thesis-generated"] + assert not paths.catalysts.exists() + assert not paths.outcomes.exists() + + +def test_changed_draft_or_ledger_invalidates_preview_without_writing(tmp_path): + paths = _paths(tmp_path) + append_journal_entry(paths.journal, _thesis_entry()) + fields = {"thesis_id": "thesis-syn1", "summary": "Evidence.", "effective_at": "2026-07-22T10:00:00Z", "reviewer": "owner", "evidence_direction": "supporting", "source": "company_ir", "source_ref": "ref", "source_published_at": "2026-07-22T09:00:00Z"} + draft = build_authoring_draft("evidence", profile_key="demo", ticker="SYN1", fields=fields) + preview = preview_authoring_record(draft, paths=paths, previewed_at="2026-07-22T12:30:00Z", generated_id="evidence-generated") + baseline = paths.journal.read_bytes() + + edited = build_authoring_draft("evidence", profile_key="demo", ticker="SYN1", fields={**fields, "summary": "Edited after preview."}) + stale_draft = confirm_authoring_preview(preview, current_draft=edited, paths=paths, active_profile_key="demo", active_ticker="SYN1", active_kind="evidence", confirm_reviewed=True) + assert stale_draft.state == "preview_stale" + assert paths.journal.read_bytes() == baseline + + append_journal_entry(paths.journal, replace(_thesis_entry(), entry_id="entry-concurrent", thesis_id="thesis-other")) + concurrent = paths.journal.read_bytes() + stale_ledger = confirm_authoring_preview(preview, current_draft=draft, paths=paths, active_profile_key="demo", active_ticker="SYN1", active_kind="evidence", confirm_reviewed=True) + assert stale_ledger.state == "preview_stale" + assert paths.journal.read_bytes() == concurrent +``` + +- [ ] **Step 2: Run and verify the missing-interface failure** + +Run: `PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests/test_research_record_authoring.py -q` + +Expected: import fails because `confirm_authoring_preview` does not exist. + +- [ ] **Step 3: Implement exact confirmation** + +Add this public contract and explicit dispatch to `src/research_record_authoring.py`: + +```python +import hmac + +from src.catalyst_evidence_timeline import append_reviewed_event +from src.research_outcome_review import append_reviewed_outcome +from src.research_thesis_journal import append_journal_entry + + +@dataclass(frozen=True) +class AuthoringSaveResult: + state: str + reason: str + record_kind: str + record_id: str + destination_label: str + write_performed: bool + + +def _record_id(record: JournalEntry | CatalystEvent | ResearchOutcome) -> str: + if isinstance(record, JournalEntry): + return record.entry_id + if isinstance(record, CatalystEvent): + return record.event_id + return record.outcome_id + + +def confirm_authoring_preview( + preview: AuthoringPreview, + *, + current_draft: AuthoringDraft, + paths: AuthoringPaths, + active_profile_key: str, + active_ticker: str, + active_kind: str, + confirm_reviewed: bool, +) -> AuthoringSaveResult: + rejected = lambda state, reason: AuthoringSaveResult(state, reason, preview.record_kind, "", preview.destination_label, False) + if not confirm_reviewed: + return rejected("confirmation_required", "Review and confirm the exact preview before saving.") + if preview.state != "reviewable" or preview.record is None or not preview.receipt: + return rejected("rejected", "Only a valid preview can be confirmed.") + active = (str(active_profile_key or "").strip(), str(active_ticker or "").strip().upper(), str(active_kind or "").strip().lower()) + if active != (preview.profile_key, preview.ticker, preview.record_kind): + return rejected("preview_stale", "Selected profile, ticker, or record type changed; preview again.") + if not hmac.compare_digest(_stable_digest(asdict(current_draft)), preview.draft_digest): + return rejected("preview_stale", "Draft changed after preview; preview again.") + destination = _destination(current_draft, paths) + if not hmac.compare_digest(_ledger_fingerprint(destination), preview.ledger_fingerprint): + return rejected("preview_stale", "Ledger changed after preview; reload and preview again.") + refreshed = preview_authoring_record(current_draft, paths=paths, previewed_at=preview.previewed_at, generated_id=_record_id(preview.record)) + if refreshed.state != "reviewable" or not hmac.compare_digest(refreshed.receipt, preview.receipt): + return rejected("preview_stale", "Record no longer matches the validated preview; preview again.") + try: + if isinstance(preview.record, JournalEntry): + append_journal_entry(paths.journal, preview.record) + elif isinstance(preview.record, CatalystEvent): + append_reviewed_event(paths.catalysts, preview.record, confirm_reviewed=True) + else: + append_reviewed_outcome(paths.outcomes, preview.record, confirm_reviewed=True) + except (OSError, ValueError) as exc: + return rejected("save_failed", f"Record was not saved: {exc}") + return AuthoringSaveResult("saved", "Saved append-only reviewed record.", preview.record_kind, _record_id(preview.record), destination.name, True) +``` + +- [ ] **Step 4: Run focused and adjacent persistence tests** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests/test_research_record_authoring.py tests/test_research_thesis_journal.py tests/test_catalyst_evidence_timeline.py tests/test_research_outcome_review.py -q +``` + +Expected: all tests pass; byte assertions prove rejected confirmations are write-free. + +- [ ] **Step 5: Commit Task 2** + +Run: + +```bash +git add -- src/research_record_authoring.py tests/test_research_record_authoring.py +git diff --cached --check +make staged-hygiene-check +git commit -m "Add exact research record confirmation" +``` + +### Task 3: Streamlit Composer And Session-State Contract + +**Files:** +- Create: `src/research_record_authoring_ui.py` +- Create: `tests/test_research_record_authoring_ui.py` +- Create: `tests/fixtures/research_record_authoring_app.py` + +**Interfaces:** +- Produces: `authoring_field_contract()`, `authoring_session_key()`, and `render_research_record_authoring()`. +- Consumes: Task 1/2 composition APIs and an injected `AuthoringPaths`. +- Invariant: widgets never receive editable profile/ticker values, and the fixture refuses paths outside the temporary directory passed by the test. +- Usability contract: controlled vocabularies use select controls, and thesis/supersession references are selected only from thesis rows in the active profile/ticker scope. + +- [ ] **Step 1: Write failing field and AppTest contracts** + +Add tests requiring the four record types, locked scope, source fields only where appropriate, preview-before-confirm order, and no production paths: + +```python +from pathlib import Path + +from streamlit.testing.v1 import AppTest + +from src.research_record_authoring import AuthoringPaths +from src.research_record_authoring_ui import authoring_field_contract, authoring_session_key +from src.research_thesis_journal import JournalEntry, append_journal_entry + + +def _paths(tmp_path: Path) -> AuthoringPaths: + return AuthoringPaths(tmp_path / "journal.csv", tmp_path / "catalysts.csv", tmp_path / "outcomes.csv") + + +def _thesis_entry() -> JournalEntry: + return JournalEntry( + "research-thesis-journal-v1", "entry-existing", "demo", "SYN1", "thesis-syn1", "thesis", + "2026-07-20T12:00:00Z", "2026-07-20T11:00:00Z", "fixture-reviewer", + "Existing synthetic thesis.", "", "", "", "", "0.50", "2026-08-20", "", + ) + + +def test_field_contract_is_kind_specific_and_never_exposes_scope_for_editing(): + thesis = authoring_field_contract("thesis") + evidence = authoring_field_contract("evidence") + catalyst = authoring_field_contract("catalyst") + outcome = authoring_field_contract("outcome") + + assert "profile_key" not in thesis and "ticker" not in thesis + assert tuple(thesis) == ("thesis_id", "summary", "effective_at", "reviewer", "confidence", "review_due_date", "supersedes_entry_id") + assert {"evidence_direction", "source", "source_ref", "source_published_at"} <= set(evidence) + assert {"event_type", "evidence_state", "retrieved_at"} <= set(catalyst) + assert {"original_thesis_entry_id", "observation_start", "observation_end", "learning"} <= set(outcome) + assert all("return" not in field and "skill" not in field and "rank" not in field for field in outcome) + + +def test_session_key_is_profile_ticker_and_kind_scoped(): + assert authoring_session_key("demo", "nvda", "preview") == "research-authoring:demo:NVDA:preview" + + +def test_fixture_renders_locked_scope_and_no_confirmation_before_preview(tmp_path, monkeypatch): + monkeypatch.setenv("RESEARCH_AUTHORING_FIXTURE_DIR", str(tmp_path)) + app = AppTest.from_file("tests/fixtures/research_record_authoring_app.py") + app.run(timeout=20) + + assert not app.exception + assert "Profile: demo | Ticker: SYN1" in "\n".join(item.value for item in app.markdown) + assert any(item.label == "Validate and preview" for item in app.button) + assert not any(item.label == "Confirm and save" for item in app.button) + assert not any(tmp_path.iterdir()) + + +def test_fixture_uses_controlled_choices_and_scoped_thesis_references(tmp_path, monkeypatch): + paths = _paths(tmp_path) + append_journal_entry(paths.journal, _thesis_entry()) + monkeypatch.setenv("RESEARCH_AUTHORING_FIXTURE_DIR", str(tmp_path)) + app = AppTest.from_file("tests/fixtures/research_record_authoring_app.py").run(timeout=20) + app.selectbox(key="research-authoring:demo:SYN1:kind").set_value("evidence").run() + + assert app.selectbox(key="research-authoring:demo:SYN1:field:evidence:thesis_id").options == ["thesis-syn1"] + assert app.selectbox(key="research-authoring:demo:SYN1:field:evidence:evidence_direction").options == ["supporting", "conflicting", "context"] +``` + +- [ ] **Step 2: Verify the missing-UI-module failure** + +Run: `PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests/test_research_record_authoring_ui.py -q` + +Expected: collection fails because `src.research_record_authoring_ui` does not exist. + +- [ ] **Step 3: Implement field contracts and renderer** + +Create `src/research_record_authoring_ui.py` with immutable field definitions and a renderer using widgets outside a Streamlit form so every edit causes a rerun and invalidates a mismatched preview: + +```python +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any, Mapping +from uuid import uuid4 + +from src.research_record_authoring import ( + AuthoringPaths, + AuthoringPreview, + authoring_draft_digest, + build_authoring_draft, + confirm_authoring_preview, + preview_authoring_record, +) + +FIELD_CONTRACTS = { + "thesis": ("thesis_id", "summary", "effective_at", "reviewer", "confidence", "review_due_date", "supersedes_entry_id"), + "evidence": ("thesis_id", "summary", "effective_at", "reviewer", "evidence_direction", "source", "source_ref", "source_published_at"), + "catalyst": ("event_type", "title", "summary", "effective_at", "published_at", "retrieved_at", "source", "source_ref", "evidence_state", "reviewer"), + "outcome": ("thesis_id", "original_thesis_entry_id", "reviewed_at", "observation_start", "observation_end", "reviewer", "outcome_state", "summary", "source", "source_ref", "source_published_at", "learning"), +} + +SELECT_OPTIONS = { + "evidence_direction": ("supporting", "conflicting", "context"), + "event_type": ("earnings", "product", "regulatory", "customer", "industry", "capital_allocation", "management", "macro"), + "evidence_state": ("candidate_context_only", "supported", "still_blocked", "skipped", "excluded"), + "outcome_state": ("supported", "mixed", "not_supported", "inconclusive"), +} + + +def authoring_field_contract(record_kind: str) -> tuple[str, ...]: + try: + return FIELD_CONTRACTS[record_kind] + except KeyError as exc: + raise ValueError(f"Unsupported record kind: {record_kind!r}") from exc + + +def authoring_session_key(profile_key: str, ticker: str, suffix: str) -> str: + return f"research-authoring:{profile_key}:{ticker.upper()}:{suffix}" + + +def _generated_id(kind: str) -> str: + return f"{kind}-{uuid4().hex}" + + +def _utc_now() -> str: + return datetime.now(UTC).isoformat().replace("+00:00", "Z") + + +def _scoped_thesis_options(paths: AuthoringPaths, profile_key: str, ticker: str) -> tuple[tuple[str, str], ...]: + from src.research_thesis_journal import load_journal_entries + + return tuple( + (row.thesis_id, row.entry_id) + for row in load_journal_entries(paths.journal) + if row.entry_type == "thesis" and row.profile_key == profile_key and row.ticker.upper() == ticker.upper() + ) + + +def render_research_record_authoring(*, st_api: Any, profile_key: str, ticker: str, paths: AuthoringPaths) -> None: + symbol = ticker.upper() + with st_api.expander("Add a reviewed research record", expanded=False): + st_api.caption(f"Profile: {profile_key} | Ticker: {symbol} — locked to this Company Workbench.") + kind = st_api.selectbox("Record type", tuple(FIELD_CONTRACTS), format_func=str.title, key=authoring_session_key(profile_key, symbol, "kind")) + thesis_options = _scoped_thesis_options(paths, profile_key, symbol) + fields: dict[str, str] = {} + for name in authoring_field_contract(kind): + label = name.replace("_", " ").title() + key = authoring_session_key(profile_key, symbol, f"field:{kind}:{name}") + if name == "thesis_id" and kind in {"evidence", "outcome"}: + fields[name] = st_api.selectbox(label, tuple(dict.fromkeys(item[0] for item in thesis_options)), key=key) + elif name == "original_thesis_entry_id": + fields[name] = st_api.selectbox(label, tuple(item[1] for item in thesis_options if item[0] == fields.get("thesis_id")), key=key) + elif name == "supersedes_entry_id": + fields[name] = st_api.selectbox(label, ("", *(item[1] for item in thesis_options)), key=key) + elif name in SELECT_OPTIONS: + fields[name] = st_api.selectbox(label, SELECT_OPTIONS[name], key=key) + elif name in {"summary", "learning"}: + fields[name] = st_api.text_area(label, key=key) + else: + fields[name] = st_api.text_input(label, key=key) + draft = build_authoring_draft(kind, profile_key=profile_key, ticker=symbol, fields=fields) + preview_key = authoring_session_key(profile_key, symbol, "preview") + if st_api.button("Validate and preview", key=authoring_session_key(profile_key, symbol, "validate"), use_container_width=True): + st_api.session_state[preview_key] = preview_authoring_record(draft, paths=paths, previewed_at=_utc_now(), generated_id=_generated_id(kind)) + preview: AuthoringPreview | None = st_api.session_state.get(preview_key) + if preview is None: + st_api.caption("No record is saved until this draft passes preview and you confirm the exact reviewed source evidence.") + return + if preview.draft_digest != authoring_draft_digest(draft): + st_api.warning("Draft changed after preview. Validate and preview again before saving.") + return + if preview.state == "rejected": + st_api.error(preview.reason) + return + st_api.markdown("#### Exact append-only preview") + st_api.dataframe(dict(preview.persisted_fields), width="stretch") + confirmed = st_api.checkbox("I reviewed this exact record and its source evidence", key=authoring_session_key(profile_key, symbol, "confirmed")) + if st_api.button("Confirm and save", key=authoring_session_key(profile_key, symbol, "save"), use_container_width=True): + result = confirm_authoring_preview(preview, current_draft=draft, paths=paths, active_profile_key=profile_key, active_ticker=symbol, active_kind=kind, confirm_reviewed=confirmed) + if result.state == "saved": + del st_api.session_state[preview_key] + st_api.success(f"Saved {result.record_id}. Corrections require a new append-only record; history is never edited or deleted.") + else: + st_api.error(result.reason) +``` + +Create `tests/fixtures/research_record_authoring_app.py`: + +```python +import os +from pathlib import Path + +import streamlit as st + +from src.research_record_authoring import AuthoringPaths +from src.research_record_authoring_ui import render_research_record_authoring + +root = Path(os.environ["RESEARCH_AUTHORING_FIXTURE_DIR"]).resolve() +if not root.is_dir() or "pytest-" not in str(root): + raise RuntimeError("The authoring fixture requires a pytest temporary directory.") +render_research_record_authoring( + st_api=st, + profile_key="demo", + ticker="SYN1", + paths=AuthoringPaths(root / "journal.csv", root / "catalysts.csv", root / "outcomes.csv"), +) +``` + +- [ ] **Step 4: Expand AppTest through rejected preview and successful temporary save** + +Use widget keys rather than positional indexes. Enter a synthetic thesis, click `Validate and preview`, assert the exact preview and confirmation control appear while files remain absent, then check confirmation, click save, reload the temporary journal, and assert one row. Add a second test that edits the summary after preview and proves the save button is unavailable until re-preview. + +- [ ] **Step 5: Run focused UI and composition tests** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests/test_research_record_authoring.py tests/test_research_record_authoring_ui.py -q +``` + +Expected: all tests pass; only pytest temporary directories contain new ledgers. + +- [ ] **Step 6: Commit Task 3** + +Run: + +```bash +git add -- src/research_record_authoring.py src/research_record_authoring_ui.py tests/test_research_record_authoring.py tests/test_research_record_authoring_ui.py tests/fixtures/research_record_authoring_app.py +git diff --cached --check +make staged-hygiene-check +git commit -m "Add research record authoring composer" +``` + +### Task 4: Company Workbench Integration And Read-Side Reload + +**Files:** +- Modify: `src/dashboard.py` +- Modify: `tests/test_dashboard_helpers.py` +- Modify: `tests/test_dashboard_render_smoke.py` +- Modify: `tests/test_research_record_authoring_ui.py` + +**Interfaces:** +- Consumes: `render_research_record_authoring()` with production paths explicitly provided by Company Workbench. +- Produces: one collapsed composer after the journal/outcome answer and before Advanced thesis history. +- Invariant: Public and Operator routes do not render an authoring composer; only Personal Research Company Workbench can invoke it. + +- [ ] **Step 1: Add failing source and render contracts** + +Add source-boundary assertions to `tests/test_dashboard_helpers.py`: + +```python +def test_company_workbench_authoring_is_research_only_and_below_the_journal_answer(): + source = Path("src/dashboard.py").read_text(encoding="utf-8") + render_start = source.index("def render_single_stock_report(") + render_end = source.index("def ", render_start + 4) + render_source = source[render_start:render_end] + + assert "render_research_record_authoring" in render_source + assert render_source.index("research_thesis_journal_html") < render_source.index("render_research_record_authoring") + assert render_source.index("render_research_record_authoring") < render_source.index("Advanced: thesis and evidence history") + assert "if research_mode:" in render_source + assert "AuthoringPaths(" in render_source +``` + +Add a render test to `tests/test_dashboard_render_smoke.py` requiring exactly one closed `Add a reviewed research record` expander on Research Company Workbench, no expanded Advanced section, and no composer marker on the equivalent Public Single-Stock Report. + +- [ ] **Step 2: Run and verify placement failures** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests/test_dashboard_helpers.py -k research_record_authoring -q +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests/test_dashboard_render_smoke.py -k authoring -q +``` + +Expected: failures show the renderer import/call and route marker are absent. + +- [ ] **Step 3: Wire the composer into Company Workbench** + +Add imports: + +```python +from src.research_record_authoring import AuthoringPaths +from src.research_record_authoring_ui import render_research_record_authoring +``` + +Immediately after `outcome_status_cards()` and before `Advanced: thesis and evidence history`, add: + +```python +if research_mode: + render_research_record_authoring( + st_api=st, + profile_key=selected_context.profile_key, + ticker=ticker, + paths=AuthoringPaths( + journal=DATA_DIR / "research_thesis_journal.csv", + catalysts=DATA_DIR / "catalyst_evidence.csv", + outcomes=DATA_DIR / "research_outcome_reviews.csv", + ), + ) +``` + +Do not add the renderer to Public or Operator branches. After a successful save, call `st.rerun()` only after the success receipt has been placed in session state; on the next run, reload all three existing read-side states and show the receipt once. The UI module must never claim persistence solely from the prior button callback. + +- [ ] **Step 4: Add read-side reload test** + +In the test-only fixture, confirm a record, rerun AppTest, and assert the saved receipt is displayed only when `load_journal_entries()` contains the record identifier. Delete the temporary row before rerun in a separate test and assert the UI reports `Saved record could not be reloaded; review the ledger` instead of a success claim. + +- [ ] **Step 5: Run focused Workbench tests** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests/test_research_record_authoring.py tests/test_research_record_authoring_ui.py tests/test_dashboard_helpers.py -k 'authoring or research_thesis' -q +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests/test_dashboard_render_smoke.py -q +``` + +Expected: all focused tests pass, Public and Operator routes contain no composer, and no repository ledger changes. + +- [ ] **Step 6: Commit Task 4** + +Run: + +```bash +git add -- src/dashboard.py src/research_record_authoring_ui.py tests/test_dashboard_helpers.py tests/test_dashboard_render_smoke.py tests/test_research_record_authoring_ui.py +git diff --cached --check +make staged-hygiene-check +git commit -m "Integrate research authoring in Company Workbench" +``` + +### Task 5: Responsive Workflow Evidence And Release Documentation + +**Files:** +- Modify: `src/browser_qa_evidence.py` +- Modify: `tests/test_browser_qa_evidence.py` +- Modify: `README.md` +- Modify: `docs/PRODUCT_SPEC.md` +- Modify: `ROADMAP.md` +- Modify: `docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md` +- Modify: `docs/superpowers/specs/2026-07-22-in-app-research-record-authoring-design.md` +- Modify: `tests/test_public_v1_release_docs.py` + +**Interfaces:** +- Produces: truthful release and responsive evidence for the completed Priority 3 implementation. +- Consumes: current runtime results only; screenshots remain route-marker evidence, not proof that a record was persisted. +- Invariant: Priority 4 becomes the earliest incomplete lane only after every Priority 3 acceptance criterion has direct current evidence. + +- [ ] **Step 1: Add failing browser and documentation contracts** + +Require the Research Company Workbench route to include `Add a reviewed research record` under its details boundary and a stop rule that rejects any visible confirmation before preview. Require README/Product Spec/Roadmap/continuation docs to state: + +- all four record types are available; +- exact preview and explicit confirmation are required; +- drafts are untrusted and preview receipts are session-only; +- production tests do not append repository ledgers; +- saves cannot change readiness, forecasts, probabilities, recommendations, or another ledger; and +- Priority 3 is complete locally while Priority 4 is next and still requires a permitted point-in-time dataset for exit. + +Use exact assertions in `tests/test_public_v1_release_docs.py`; do not use broad keyword-count assertions. + +- [ ] **Step 2: Run and verify documentation failures** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests/test_browser_qa_evidence.py tests/test_public_v1_release_docs.py -q +``` + +Expected: failures name the missing implemented-status and responsive-authoring contracts. + +- [ ] **Step 3: Update evidence and documentation** + +Update the Company Workbench browser-QA row to keep the composer below the primary answer and collapsed by default. State explicitly that route-marker screenshots do not prove validation, confirmation, or persistence; the temporary-ledger AppTest and direct persistence tests provide that evidence. + +Update the design `Status` and add an `Implementation Evidence` section containing commit identifiers only after those commits exist. Update `ROADMAP.md` and the continuation prompt to mark Priority 3 complete locally only after desktop/phone route review and all automated acceptance tests pass. + +- [ ] **Step 4: Run the complete required verification matrix** + +Run each command and record the actual result: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests/test_research_record_authoring.py tests/test_research_record_authoring_ui.py tests/test_research_thesis_journal.py tests/test_catalyst_evidence_timeline.py tests/test_research_outcome_review.py tests/test_dashboard_helpers.py tests/test_dashboard_render_smoke.py tests/test_browser_qa_evidence.py tests/test_public_v1_release_docs.py -q +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests -q +make dashboard-smoke +make research-dashboard-render-smoke +make public-wording-check +make public-check +make linkedin-share-check +make browser-qa-evidence +make pilot-readiness-check TOP_N=10 +make commercial-beta-release-check +make diff-hygiene-summary +git diff --check +``` + +Expected: focused and full tests pass; all read-only release gates pass; pilot readiness may remain truthfully blocked on external/source-depth gates; diff hygiene reports only intentional product files plus the same excluded generated churn. + +- [ ] **Step 5: Perform live desktop and phone review** + +Open `http://localhost:8501/?mode=research&page=company-workbench&ticker=NVDA&open=1` at `1280x720` and `390x844`. Verify: + +1. The primary selected-company answer precedes the collapsed composer. +2. The composer opens without horizontal overflow. +3. Locked profile/ticker and the four record types are understandable. +4. Invalid source/timestamp input shows a field-level rejection and writes nothing. +5. Editing after preview removes or blocks confirmation until re-preview. +6. A successful persistence rehearsal uses only the test fixture with temporary ledgers, never the production NVDA ledger. +7. Advanced evidence stays collapsed and the Research Conclusion/Next Research Task remain below the journal workflow. + +Record measurements and text findings in the design's `Implementation Evidence` section. Keep screenshots ephemeral and unstaged. + +- [ ] **Step 6: Stage exact files and commit the release slice** + +Run `git status --short`, review every intentional path, then stage only the implementation/docs/tests named by `git diff --name-only`: + +```bash +git add -- src/research_record_authoring.py src/research_record_authoring_ui.py src/dashboard.py src/browser_qa_evidence.py tests/test_research_record_authoring.py tests/test_research_record_authoring_ui.py tests/fixtures/research_record_authoring_app.py tests/test_dashboard_helpers.py tests/test_dashboard_render_smoke.py tests/test_browser_qa_evidence.py tests/test_public_v1_release_docs.py README.md docs/PRODUCT_SPEC.md ROADMAP.md docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md docs/superpowers/specs/2026-07-22-in-app-research-record-authoring-design.md +make staged-hygiene-check +git diff --cached --check +git commit -m "Complete in-app research record authoring" +``` + +If a listed file is unchanged, omit it from the exact `git add --` command rather than staging a broader path. + +- [ ] **Step 7: Push, update draft PR #113, and require exact-head CI** + +Run: + +```bash +git push origin codex/personal-research-mode-mvp +gh pr view 113 --json state,isDraft,mergeable,headRefOid,statusCheckRollup,url +gh pr checks 113 --watch --interval 20 +``` + +Update the draft PR with the exact commit, test counts, runtime evidence, generated-artifact exclusion, and remaining external gates. Confirm the PR remains open and draft and the successful hosted check's head SHA equals local `HEAD`. + +- [ ] **Step 8: Rescan Priorities 1-10** + +Run the current roadmap/status/hygiene checks. If Priority 3 has direct evidence for every acceptance criterion, select Priority 4's earliest safe local contract slice. Do not call Priority 4 complete without one bounded permitted real point-in-time dataset that passes rights, identity, corporate-action, delisting, survivorship, cutoff, reproduction, and leakage gates. diff --git a/docs/superpowers/plans/2026-07-22-legacy-research-utility-quarantine.md b/docs/superpowers/plans/2026-07-22-legacy-research-utility-quarantine.md new file mode 100644 index 00000000..9847e68a --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-legacy-research-utility-quarantine.md @@ -0,0 +1,126 @@ +# Legacy Research Utility Quarantine 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 legacy portfolio, ranking, and action-language surfaces unmistakably operator-only compatibility utilities while preserving the supported Personal Research workflow. + +**Architecture:** Add a pure navigation contract for quarantined canonical page titles, safe mode-specific route resolution, and operator display labels. Reuse the dashboard renderers only inside a shared collapsed compatibility shell. Keep all calculations and historical filenames unchanged, then lock isolation with route, source, wording, documentation, and release tests. + +**Tech Stack:** Python 3.12, Streamlit, pytest, Streamlit render smoke tests, Markdown contract tests. + +## Global Constraints + +- Preserve Research Desk -> Discover -> Company Workbench -> Monitor as the supported workflow. +- Keep legacy calculations only for compatibility; do not promote them into current product capability. +- Do not change readiness, canonical data, forecasts, calibration, sources, or generated report schemas. +- Do not run readiness rebuilds, broad refreshes, or generated CSV/JSON/report/screenshot/timing commands. +- Never stage generated working-data churn and never use `git add -A`. +- Keep draft PR #113 draft; push only `codex/personal-research-mode-mvp`; do not merge or deploy. + +--- + +### Task 1: Lock the quarantine and route contract + +**Files:** +- Modify: `src/dashboard_navigation.py` +- Modify: `src/dashboard.py` +- Modify: `tests/test_dashboard_navigation.py` +- Modify: `tests/test_research_mode_dashboard_contract.py` + +- [ ] **Step 1: Write failing tests for the exact quarantine set, labels, and mode-specific deep-link resolution** + +Assert that Public resolves every quarantined page to `Home`, Personal Research resolves it to `Research Desk`, and Operator preserves it. Assert historical aliases still parse to canonical titles. + +- [ ] **Step 2: Run focused tests and verify RED** + +Run: `python3 -m pytest tests/test_dashboard_navigation.py tests/test_research_mode_dashboard_contract.py -q` + +- [ ] **Step 3: Implement the pure quarantine helpers and use them before route-rail selection** + +Add immutable `LEGACY_RESEARCH_UTILITY_PAGES`, `legacy_research_utility_label(...)`, and `workspace_page_for_mode(...)`. Keep Streamlit out of the navigation module. + +- [ ] **Step 4: Run focused tests and verify GREEN** + +Run: `python3 -m pytest tests/test_dashboard_navigation.py tests/test_research_mode_dashboard_contract.py -q` + +--- + +### Task 2: Quarantine rendered legacy output + +**Files:** +- Modify: `src/dashboard.py` +- Modify: `tests/test_dashboard_helpers.py` +- Modify: `tests/test_research_mode_dashboard_contract.py` + +- [ ] **Step 1: Write failing source and render-helper tests** + +Require the exact boundary copy, one collapsed `Advanced: legacy compatibility output` disclosure, and a separate load checkbox. Prove legacy page renderers do not render details until the Operator deliberately uses both controls. + +- [ ] **Step 2: Run focused tests and verify RED** + +Run: `python3 -m pytest tests/test_dashboard_helpers.py tests/test_research_mode_dashboard_contract.py -q` + +- [ ] **Step 3: Implement the shared legacy shell** + +Render the compatibility boundary first. Gate Monthly Picks details and the five quarantined output-tab branches behind the collapsed disclosure and explicit load checkbox. Render after confirmation rather than nesting existing Streamlit expanders. Preserve explicit missing-output states without generating data. + +- [ ] **Step 4: Add and pass isolation tests** + +Prove `src/research_decision_lab.py` and the Company Workbench composition path do not import or consume `portfolio_review`, `monthly_picks`, `final_watchlist`, or legacy dashboard output frames. + +--- + +### Task 3: Reconcile product documentation and roadmap truth + +**Files:** +- Modify: `README.md` +- Modify: `PRODUCT_SPEC.md` +- Modify: `READINESS_MODEL.md` +- Modify: `ROADMAP.md` +- Modify: `docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md` +- Modify: `tests/test_public_v1_release_docs.py` + +- [ ] **Step 1: Write failing documentation-contract tests** + +Require the supported workflow, exact legacy boundary, operator-only compatibility classification, and explicit exclusion from readiness, Decision Lab, recommendations, sizing, and transactions. + +- [ ] **Step 2: Update the documents without rewriting historical evidence** + +Mark Priority 1 complete only after code and direct tests pass. Set Priority 2 Stage B as the next local executable lane. + +- [ ] **Step 3: Run focused documentation and action-language checks** + +Run: `python3 -m pytest tests/test_public_v1_release_docs.py tests/test_public_wording.py tests/test_action_language_contract.py -q` + +--- + +### Task 4: Verify, stage exactly, synchronize the draft PR, and require exact-head CI + +- [ ] **Step 1: Run the complete verification matrix** + +```bash +python3 -m pytest tests -q +make dashboard-smoke +make research-dashboard-render-smoke +make public-wording-check +make public-check +make commercial-beta-release-check +make pilot-readiness-check TOP_N=10 +make pr-range-hygiene-check +make diff-hygiene-summary +git diff --check +``` + +Pilot readiness may remain truthfully blocked by stale saved readiness or external evidence; it must not be regenerated in this slice. + +- [ ] **Step 2: Review and stage exact intentional paths only** + +Run `git diff --stat`, inspect every intentional diff, stage only the code/test/docs paths listed above, then run `make staged-hygiene-check` and `git diff --cached --check`. + +- [ ] **Step 3: Commit and push the coherent slice** + +Commit with `Quarantine legacy research utilities`, push only the existing feature branch, update draft PR #113 with evidence and remaining gates, keep it draft, and wait for exact-head `local-engineering-gate` success. + +- [ ] **Step 4: Continue automatically** + +Re-scan the ordered maturity program. Begin Priority 2 Stage B if a safe local task remains; otherwise classify its exact external unblock condition once and move to the next executable priority. diff --git a/docs/superpowers/plans/2026-07-22-research-decision-lab.md b/docs/superpowers/plans/2026-07-22-research-decision-lab.md new file mode 100644 index 00000000..fbbbed15 --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-research-decision-lab.md @@ -0,0 +1,391 @@ +# Research Decision Lab 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:** Add a read-only six-lane Research Decision Lab to Company Workbench and a no-ranking Research Discipline Review to Monitor. + +**Architecture:** A new pure-Python `src/research_decision_lab.py` composes existing immutable journal, decision-process, outcome, change-review, and valuation results into immutable display contracts. `src/dashboard.py` only loads those existing results and renders cards/tables; it adds no persistence, readiness field, route, provider, or generated artifact. + +**Tech Stack:** Python 3.12, frozen dataclasses, pandas display frames, Streamlit, pytest, Streamlit AppTest, Chrome performance gate. + +## Global Constraints + +- Research-only; no investment advice, recommendation, transaction direction, position size, allocation, stop-loss, take-profit, expected return, company score, or broker action. +- Do not import or surface `src/portfolio_review.py` action states. +- Candidate context cannot populate a trusted lane or change forecasts, DCF, readiness, conclusions, or process states. +- Empty, blocked, excluded, invalid, and commercially blocked inputs remain explicit and independent. +- Add no ledger, readiness state, route, provider, generated report, CSV, JSON, screenshot, or timing artifact. +- Preserve the Company Workbench first handoff and uppercase `USE NOW` performance marker. +- Preserve Weekly Research Summary as Monitor's first useful answer and stable focused-cohort order without ranking. +- Keep technical identities, source rows, timestamps, and raw evidence under collapsed Advanced sections. +- Never run `make readiness`, broad refreshes, imports, or apply commands for this feature. +- Never use `git add -A`; stage exact reviewed code, test, and documentation paths only. +- Keep draft PR #113 draft; push only `codex/personal-research-mode-mvp`; do not merge or deploy. + +--- + +### Task 1: Read-only Decision Lab composition contract + +**Files:** +- Create: `src/research_decision_lab.py` +- Create: `tests/test_research_decision_lab.py` + +**Interfaces:** +- Consumes: `JournalState`, `DecisionProcessScorecard`, `OutcomeStatus`, and an iterable of research-review items. +- Produces: `DecisionLabLane`, `ResearchDecisionLabState`, `ResearchDisciplineRow`, `build_research_decision_lab_state(...)`, `unavailable_research_decision_lab_state(...)`, `build_research_discipline_rows(...)`, `decision_lab_cards(...)`, and `research_discipline_rows(...)`. + +- [ ] **Step 1: Write failing immutable-contract and lane-order tests** + +```python +def test_empty_history_keeps_six_lanes_independent_and_not_started(): + state = build_research_decision_lab_state( + profile_key="demo", + journal_state=empty_journal(), + scorecard=empty_scorecard(), + outcome_status=OutcomeStatus("not_started", 0, "", "", "Review later.", 0, ()), + review_items=(), + ) + assert [lane.key for lane in state.lanes] == [ + "plan", "evidence", "invalidation", "scenario", "review_trigger", "learning" + ] + assert {lane.key: lane.state for lane in state.lanes} == { + "plan": "not_started", "evidence": "not_started", "invalidation": "missing", + "scenario": "blocked", "review_trigger": "not_started", "learning": "not_started", + } +``` + +- [ ] **Step 2: Run the new module tests and verify RED** + +Run: `python3 -m pytest tests/test_research_decision_lab.py -q` + +Expected: collection fails because `src.research_decision_lab` does not exist. + +- [ ] **Step 3: Implement frozen contracts and deterministic mapping** + +```python +@dataclass(frozen=True) +class DecisionLabLane: + key: str + label: str + state: str + answer: str + evidence: str + next_step: str + + +@dataclass(frozen=True) +class ResearchDecisionLabState: + profile_key: str + ticker: str + status: str + lanes: tuple[DecisionLabLane, ...] + next_process_step: str + boundary: str + identity: str + + +@dataclass(frozen=True) +class ResearchDisciplineRow: + cohort_order: int + ticker: str + status: str + due_lanes: tuple[str, ...] + next_process_step: str + identity: str +``` + +Use the existing scorecard checks by key. Map Plan from `thesis_documented`; Evidence from `evidence_recorded` plus `conflicting_evidence_reviewed`; Invalidation from `invalidation_documented`; Scenario from `dcf_assumptions_visible`; Review trigger from matching open review items plus the journal due state; and Learning from `OutcomeStatus`. Hash the normalized contract with SHA-256 for deterministic identity. + +- [ ] **Step 4: Run lane tests and verify GREEN** + +Run: `python3 -m pytest tests/test_research_decision_lab.py -q` + +Expected: the empty-state and immutable-contract tests pass. + +- [ ] **Step 5: Add failing priority, fail-closed, and language tests** + +```python +@pytest.mark.parametrize( + ("mutation", "expected"), + [ + ("conflict", "Review recorded conflicting evidence"), + ("overdue", "Review the overdue thesis"), + ("missing_thesis", "Record a current reviewer-authored thesis"), + ("missing_invalidation", "Record a source-backed invalidation condition"), + ("missing_evidence", "Record source-backed research evidence"), + ("unscheduled", "Schedule the next evidence review"), + ("missing_dcf_assumptions", "Restore visible DCF assumptions"), + ], +) +def test_next_process_step_uses_approved_priority(mutation, expected): + assert build_state(mutation).next_process_step.startswith(expected) + + +def test_contract_contains_no_transaction_or_allocation_language(): + rendered = str(build_state("complete")).lower() + for forbidden in ("buy", "sell", "position size", "allocation", "stop loss", "take profit"): + assert forbidden not in rendered +``` + +Also cover later review clearing only the conflict gap, DCF blocked/excluded/ready, commercial learning blocker, mismatch `ValueError`, invalid-journal unavailable state, lane independence, deterministic identity, stable cohort ordering, and no severity sorting. + +- [ ] **Step 6: Implement the approved priority and cohort composition** + +Select the first applicable gap in this exact order: conflict without later review; overdue thesis; missing thesis; missing invalidation; missing evidence; unscheduled review; DCF ready without visible assumptions; otherwise continue monitoring. `build_research_discipline_rows` must preserve the caller's focused-cohort order and use ticker only as a deterministic tie-break, never process severity or market value. + +- [ ] **Step 7: Verify the complete composition slice** + +Run: + +```bash +python3 -m pytest tests/test_research_decision_lab.py tests/test_decision_process_scorecard.py tests/test_research_thesis_journal.py tests/test_research_outcome_review.py -q +python3 -m pytest tests -q +make dashboard-smoke research-dashboard-render-smoke public-wording-check public-check +make pilot-readiness-check TOP_N=10 +make pr-range-hygiene-check diff-hygiene-summary +git diff --check +``` + +Expected: all commands exit zero except pilot readiness may remain truthfully blocked by excluded generated churn or external gates; no generated artifact is added. + +- [ ] **Step 8: Stage, verify, commit, push, update PR, and require exact-head CI** + +```bash +git add -- src/research_decision_lab.py tests/test_research_decision_lab.py +make staged-hygiene-check +git diff --cached --check +git commit -m "Add research decision lab composition" +git push origin codex/personal-research-mode-mvp +``` + +Update draft PR #113 with the slice evidence and wait for the `local-engineering-gate` check on the pushed SHA to pass before Task 2. + +--- + +### Task 2: Company Workbench integration + +**Files:** +- Modify: `src/dashboard.py` +- Modify: `tests/test_research_mode_dashboard_contract.py` +- Modify: `tests/test_dashboard_render_smoke.py` +- Modify: `tests/test_public_performance_gate.py` + +**Interfaces:** +- Consumes: `build_research_decision_lab_state`, `unavailable_research_decision_lab_state`, and `decision_lab_cards` from Task 1. +- Produces: one Workbench `Research Decision Lab` section and one collapsed `Advanced: Decision Lab evidence` disclosure. + +- [ ] **Step 1: Write failing source-order and render tests** + +```python +def test_workbench_places_one_decision_lab_after_what_changed_before_business_trend(): + render = report_renderer_source() + what_changed = render.index('st.markdown("### What Changed")') + decision_lab = render.index('st.markdown("### Research Decision Lab")') + business_trend = render.index('st.markdown("### Business Trend")') + assert what_changed < decision_lab < business_trend + assert render.count('st.markdown("### Research Decision Lab")') == 1 +``` + +Add AppTest assertions that the route contains all six lane labels, `Next process step`, Research Conclusion, and Next Research Task; that `USE NOW` occurs first; and that technical identity is inside a collapsed Advanced disclosure. + +- [ ] **Step 2: Run focused tests and verify RED** + +Run: `python3 -m pytest tests/test_research_mode_dashboard_contract.py tests/test_dashboard_render_smoke.py tests/test_public_performance_gate.py -q` + +Expected: failures show the missing Workbench section and markers. + +- [ ] **Step 3: Compose state once and render the compact summary** + +Build the existing `DecisionProcessScorecard` once after the journal/outcome payloads load. If the journal is invalid, build one compact unavailable Decision Lab state using the existing verification error. After `What Changed`, render six compact cards and the next process step. Reuse the same scorecard in the existing collapsed scorecard section; do not replace Research Conclusion or Next Research Task. + +- [ ] **Step 4: Keep technical evidence collapsed** + +```python +with st.expander("Advanced: Decision Lab evidence", expanded=False): + st.dataframe(decision_lab_frame(decision_lab_state), width="stretch", hide_index=True) + st.caption(f"Decision Lab identity: {decision_lab_state.identity}") + st.caption(decision_lab_state.boundary) +``` + +- [ ] **Step 5: Verify Workbench behavior and responsive performance** + +Run: + +```bash +python3 -m pytest tests/test_research_decision_lab.py tests/test_research_mode_dashboard_contract.py tests/test_dashboard_render_smoke.py tests/test_public_performance_gate.py -q +python3 -m pytest tests -q +make dashboard-smoke research-dashboard-render-smoke public-wording-check public-check browser-qa-evidence +make commercial-beta-performance-gate commercial-beta-release-check +make pilot-readiness-check TOP_N=10 +make pr-range-hygiene-check diff-hygiene-summary +git diff --check +``` + +Expected: desktop and `390x844` Workbench cases pass, uppercase `USE NOW` remains first useful evidence under three seconds, and no horizontal-overflow or duplicate-section failure appears. + +- [ ] **Step 6: Stage, verify, commit, push, update PR, and require exact-head CI** + +```bash +git add -- src/dashboard.py tests/test_research_mode_dashboard_contract.py tests/test_dashboard_render_smoke.py tests/test_public_performance_gate.py +make staged-hygiene-check +git diff --cached --check +git commit -m "Integrate decision lab into company workbench" +git push origin codex/personal-research-mode-mvp +``` + +Update draft PR #113 with Workbench placement, responsive, and performance evidence; require exact-head CI before Task 3. + +--- + +### Task 3: Monitor Research Discipline Review + +**Files:** +- Modify: `src/dashboard.py` +- Modify: `tests/test_research_mode_dashboard_contract.py` +- Modify: `tests/test_dashboard_render_smoke.py` +- Modify: `tests/test_public_performance_gate.py` + +**Interfaces:** +- Consumes: `FocusedCohort`, existing saved journal/outcome ledgers, existing research-review items, and Task 1 cohort rows. +- Produces: `load_dashboard_research_discipline_rows(...)`, a compact `Research Discipline Review`, and collapsed Advanced evidence. + +- [ ] **Step 1: Write failing cohort-load, order, empty-state, and placement tests** + +```python +def test_monitor_places_discipline_review_after_weekly_summary_before_change_monitor(): + monitor = monitor_renderer_source() + weekly = monitor.index("weekly_summary_cards(weekly_summary)") + discipline = monitor.index('st.markdown("### Research Discipline Review")') + changes = monitor.index('st.markdown("### Research change monitor")') + assert weekly < discipline < changes + + +def test_monitor_discipline_rows_preserve_focused_cohort_order_without_rank(): + assert [row.ticker for row in rows] == ["BBB", "AAA"] + assert "rank" not in str(rows).lower() +``` + +Add an empty-state assertion that says no process item is due from saved reviewer-authored evidence and does not claim no risk, no research need, or no market event. + +- [ ] **Step 2: Run focused tests and verify RED** + +Run: `python3 -m pytest tests/test_research_decision_lab.py tests/test_research_mode_dashboard_contract.py tests/test_dashboard_render_smoke.py -q` + +Expected: failures show the missing Monitor loader and section. + +- [ ] **Step 3: Implement one read-only cohort loader** + +Load journal and outcome ledgers once, derive each focused ticker independently, construct only saved readiness context needed by the existing scorecard, and convert failures for one ticker to an unavailable state without promoting another ticker. Do not fetch, refresh, build generated reports, or pad the cohort. + +- [ ] **Step 4: Render the review after Weekly Research Summary** + +Render a compact table with `Ticker`, `Process state`, `Due lanes`, and `Next process step`. Keep identity and evidence rows under `Advanced: Research Discipline evidence`. Render the existing Research change monitor afterward so market/source-change state stays independent. + +- [ ] **Step 5: Verify Monitor behavior and responsive performance** + +Run: + +```bash +python3 -m pytest tests/test_research_decision_lab.py tests/test_research_mode_dashboard_contract.py tests/test_dashboard_render_smoke.py tests/test_public_performance_gate.py -q +python3 -m pytest tests -q +make dashboard-smoke research-dashboard-render-smoke public-wording-check public-check browser-qa-evidence +make commercial-beta-performance-gate commercial-beta-release-check +make pilot-readiness-check TOP_N=10 +make pr-range-hygiene-check diff-hygiene-summary +git diff --check +``` + +Expected: Monitor remains within desktop/phone thresholds, Weekly Research Summary remains first useful evidence, stable cohort order is preserved, and empty process evidence creates no market or risk claim. + +- [ ] **Step 6: Stage, verify, commit, push, update PR, and require exact-head CI** + +```bash +git add -- src/dashboard.py tests/test_research_mode_dashboard_contract.py tests/test_dashboard_render_smoke.py tests/test_public_performance_gate.py +make staged-hygiene-check +git diff --cached --check +git commit -m "Add monitor research discipline review" +git push origin codex/personal-research-mode-mvp +``` + +Update draft PR #113 with Monitor ordering, empty-state, and no-ranking evidence; require exact-head CI before Task 4. + +--- + +### Task 4: Documentation and release evidence + +**Files:** +- Modify: `README.md` only if the current public feature summary materially omits the implemented workflow +- Modify: `ROADMAP.md` +- Modify: `docs/METHODOLOGY.md` +- Modify: `docs/PROVENANCE_CONTRACT.md` +- Modify: `docs/PERSONAL_RESEARCH_MODE.md` +- Modify: `docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md` +- Modify: `docs/internal/RESEARCH_DECISION_LAB_CONTINUATION_GOAL_PROMPT.md` +- Modify: browser-QA contract source and its focused tests only if current markers require Decision Lab coverage +- Test: `tests/test_public_v1_release_docs.py` + +**Interfaces:** +- Consumes: verified Task 1-3 behavior and exact current command outputs. +- Produces: acceptance-criterion evidence map, current roadmap state, continuation contract, public-safe product explanation, and exact-head draft-PR evidence. + +- [ ] **Step 1: Write failing documentation contract tests** + +```python +def test_personal_research_docs_define_decision_lab_without_trading_scope(): + text = Path("docs/PERSONAL_RESEARCH_MODE.md").read_text(encoding="utf-8") + assert "Research Decision Lab" in text + assert "Research Discipline Review" in text + assert "no new ledger" in text.lower() +``` + +Add assertions for six lane names, independent fail-closed states, Company Workbench and Monitor placement, and the broader external maturity boundary. + +- [ ] **Step 2: Run docs tests and verify RED** + +Run: `python3 -m pytest tests/test_public_v1_release_docs.py tests/test_launchers.py -q` + +Expected: failures identify missing implemented-status and methodology/provenance wording. + +- [ ] **Step 3: Update docs and roadmap with verified evidence only** + +Mark the Decision Lab local implementation complete only after Tasks 1-3 are committed and pushed. Document that it composes saved evidence read-only; it does not prove source coverage, predictive accuracy, investment performance, independent adoption, hosted reliability, commercial demand, competitive superiority, or product-market fit. Update the dedicated continuation prompt to route to the next locally executable or external gate without repeating completed Decision Lab work. + +- [ ] **Step 4: Audit README and browser-QA markers** + +Change README only if the answer-first public workflow materially benefits from one concise Decision Lab sentence. Update browser-QA markers without replacing screenshots; existing screenshots remain route/product evidence only until explicitly recaptured and reviewed. + +- [ ] **Step 5: Run the complete release matrix** + +Run: + +```bash +python3 -m pytest tests/test_public_v1_release_docs.py tests/test_launchers.py tests/test_research_decision_lab.py tests/test_research_mode_dashboard_contract.py tests/test_dashboard_render_smoke.py tests/test_public_performance_gate.py -q +python3 -m pytest tests -q +make dashboard-smoke research-dashboard-render-smoke public-wording-check public-check browser-qa-evidence +make commercial-beta-performance-gate commercial-beta-release-check +make pilot-readiness-check TOP_N=10 +make pr-range-hygiene-check diff-hygiene-summary +git diff --check +``` + +Expected: local code, full tests, route renders, wording, browser contract, performance, release, and hygiene pass; pilot or external gates remain blocked only where current evidence says so. + +- [ ] **Step 6: Map every acceptance criterion to current evidence** + +Record each design criterion as `proven`, `contradicted`, `incomplete`, `indirect`, or `missing`. Evidence must name the exact test/gate and current revision; screenshot-only, fixture-only, local-only, stale, or indirect evidence cannot prove a broader gate. + +- [ ] **Step 7: Stage exact docs/tests, verify, commit, and push** + +```bash +git add -- ROADMAP.md docs/METHODOLOGY.md docs/PROVENANCE_CONTRACT.md docs/PERSONAL_RESEARCH_MODE.md docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md docs/internal/RESEARCH_DECISION_LAB_CONTINUATION_GOAL_PROMPT.md tests/test_public_v1_release_docs.py +make staged-hygiene-check +git diff --cached --check +git commit -m "Document research decision lab evidence" +git push origin codex/personal-research-mode-mvp +``` + +Add README or browser-QA files to the exact staging command only if Step 4 required and verified them. + +- [ ] **Step 8: Update draft PR and require exact-head CI** + +Update PR #113 with the final acceptance map, exact local verification, generated-artifact exclusion, and external dependency classifications. Keep it draft. Confirm `headRefOid` equals local HEAD and `local-engineering-gate` succeeds on that exact SHA. Do not merge or deploy. diff --git a/docs/superpowers/plans/2026-07-23-point-in-time-universe-foundation.md b/docs/superpowers/plans/2026-07-23-point-in-time-universe-foundation.md new file mode 100644 index 00000000..14c0682f --- /dev/null +++ b/docs/superpowers/plans/2026-07-23-point-in-time-universe-foundation.md @@ -0,0 +1,2362 @@ +# Point-in-Time Benchmark and Universe Foundation 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:** Build the approved provider-neutral, read-only validator for immutable point-in-time security identity, benchmark/research-universe membership, corporate actions, delistings, source rights, cutoffs, reproduction, and leakage. + +**Architecture:** Keep the new validator isolated from the ticker-centric current-universe merge path. A manifest loader proves package identity, typed parsers preserve ordered source rows, lineage and temporal validators classify evidence independently, and one composer emits status/preview packets without writing, fetching, applying, rebuilding readiness, or activating analysis. + +**Tech Stack:** Python 3.12, frozen dataclasses, `csv`, `hashlib`, `json`, `pathlib`, existing `src.commercial_source_rights`, pytest, Make. + +## Global Constraints + +- Follow `docs/superpowers/specs/2026-07-23-point-in-time-universe-foundation-design.md` exactly. +- Research-only; no ranking, recommendations, investment advice, position sizing, broker integration, order routing, auto-trading, or price prediction. +- Do not modify or call the apply paths for `data/universe.csv`, `data/universe_master.csv`, or `data/universe_active.csv`. +- Do not fetch data, call providers, add keys, alter source rights, rebuild readiness, or write normalized/rejected/report artifacts. +- Stable `security_id` and `issuer_id` are required; ticker must never become permanent identity. +- Technical, temporal, identity, membership, corporate-action, delisting, source-rights, reproduction, and leakage states remain independent. +- Synthetic evidence exists only in pytest temporary directories and cannot complete Priority 4. +- Every production behavior starts with a failing focused test. +- Stage exact intentional product/code/docs/test files only; never use `git add -A`. +- Keep the 18 existing generated CSV/JSON/report files unstaged. +- Keep PR #113 open and draft; do not merge or deploy. + +--- + +## File Structure + +- Create `src/point_in_time_universe_manifest.py`: immutable manifest types, safe path resolution, hashes, row counts, and package loading. +- Create `src/point_in_time_universe_contracts.py`: schema constants, typed records, timestamp/date/enum parsing, and ordered raw/normalized rows. +- Create `src/point_in_time_universe_lineage.py`: generic exact-parent lineage validation and cutoff leaf selection. +- Create `src/point_in_time_universe.py`: independent decisions, identity/membership/action/delisting/rights/leakage composition, reproduction digests, rendering, and CLI. +- Create `tests/point_in_time_universe_fixture.py`: test-only package builder that writes exclusively beneath pytest `tmp_path`. +- Create `tests/test_point_in_time_universe_manifest.py`: manifest-integrity and no-write tests. +- Create `tests/test_point_in_time_universe_contracts.py`: schema, type, enum, and timestamp tests. +- Create `tests/test_point_in_time_universe_lineage.py`: root/leaf, fork, cycle, cross-scope, and cutoff tests. +- Create `tests/test_point_in_time_universe.py`: composed state, action, delisting, rights, leakage, reproduction, and no-current-fallback tests. +- Create `tests/test_point_in_time_universe_cli.py`: CLI/Make, status/preview, invocation, blocked-package, and no-write tests. +- Modify `Makefile`: add the two read-only entry points. +- Modify `ROADMAP.md`, `docs/METHODOLOGY.md`, `docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md`, and `tests/test_public_v1_release_docs.py`: record implemented local scope without claiming Priority 4 completion. + +--- + +### Task 1: Test-Only Package Builder and Immutable Manifest Loader + +**Files:** +- Create: `tests/point_in_time_universe_fixture.py` +- Create: `tests/test_point_in_time_universe_manifest.py` +- Create: `src/point_in_time_universe_manifest.py` + +**Interfaces:** +- Produces `ManifestFile`, `UniverseManifest`, and `LoadedUniversePackage`. +- Produces `load_universe_package(manifest_path: Path, registry_path: Path) -> LoadedUniversePackage`. +- Produces `build_valid_package(root: Path) -> tuple[Path, Path]` for tests only. + +- [ ] **Step 1: Write the test-only package builder** + +```python +# tests/point_in_time_universe_fixture.py +from __future__ import annotations + +import csv +import hashlib +import json +from pathlib import Path + + +def _write_csv(path: Path, rows: list[dict[str, str]]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=list(rows[0])) + writer.writeheader() + writer.writerows(rows) + + +def _sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def build_valid_package(root: Path) -> tuple[Path, Path]: + package = root / "package" + package.mkdir() + identity = [{ + "identity_row_id": "id-1", "security_id": "sec-1", "issuer_id": "issuer-1", + "ticker": "AAA", "exchange": "XNYS", "security_type": "common_stock", + "currency": "USD", "valid_from": "2020-01-01T00:00:00Z", "valid_to": "", + "source_id": "fixture_source", "source_ref": "fixture://identity/id-1", + "source_published_at": "2020-01-01T00:00:00Z", + "retrieved_at": "2020-01-02T00:00:00Z", + "supersedes_identity_row_id": "", + }] + membership = [ + { + "membership_row_id": f"member-{universe}", "universe_id": universe, + "universe_kind": kind, "security_id": "sec-1", + "membership_state": "included", "effective_from": "2020-01-01T00:00:00Z", + "effective_to": "", "observation_at": "2020-01-01T00:00:00Z", + "source_id": "fixture_source", "source_ref": f"fixture://membership/{universe}", + "source_published_at": "2020-01-01T00:00:00Z", + "retrieved_at": "2020-01-02T00:00:00Z", + "supersedes_membership_row_id": "", + } + for universe, kind in (("bench-1", "benchmark"), ("research-1", "research_universe")) + ] + events = [{ + "event_row_id": "event-1", "security_id": "sec-1", "event_type": "listing", + "effective_at": "2020-01-01T00:00:00Z", "successor_security_id": "", + "ratio_numerator": "", "ratio_denominator": "", "listing_state_after": "active", + "source_id": "fixture_source", "source_ref": "fixture://event/event-1", + "source_published_at": "2020-01-01T00:00:00Z", + "retrieved_at": "2020-01-02T00:00:00Z", "supersedes_event_row_id": "", + }] + evaluations = [ + { + "evaluation_row_id": f"eval-{universe}", "universe_id": universe, + "evaluation_at": "2021-01-01T00:00:00Z", + "available_at": "2021-01-01T00:00:00Z", "partition": "walk_forward", + "source_ref": f"fixture://evaluation/{universe}", + } + for universe in ("bench-1", "research-1") + ] + payloads = { + "identity.csv": identity, + "membership.csv": membership, + "events.csv": events, + "evaluations.csv": evaluations, + } + files = [] + contract_by_name = { + "identity.csv": "security_identity", + "membership.csv": "membership", + "events.csv": "events", + "evaluations.csv": "evaluations", + } + for name, rows in payloads.items(): + path = package / name + _write_csv(path, rows) + files.append({ + "path": name, "contract": contract_by_name[name], + "sha256": _sha256(path), "row_count": len(rows), + }) + registry = root / "source_rights.yml" + registry.write_text( + "sources:\n" + " - source_id: fixture_source\n" + " display_name: Test-only source\n" + " permitted_use: test_only\n" + " commercial_use: approved\n" + " redistribution: test_only\n" + " storage_limits: pytest temporary directory only\n" + " attribution: synthetic fixture\n" + " rate_limits: not_applicable\n" + " authentication: none\n" + " expected_freshness: point in time\n" + " supported_fields: [security_identity, universe_membership, corporate_actions, delistings]\n" + " fallback_priority: 1\n", + encoding="utf-8", + ) + manifest = { + "schema_version": "point_in_time_universe_v1", + "dataset_id": "fixture-dataset", "manifest_id": "fixture-manifest", + "manifest_created_at": "2021-01-02T00:00:00Z", + "observation_cutoff_at": "2021-01-01T00:00:00Z", + "coverage_semantics": "complete_snapshot", + "declared_universes": [ + {"universe_id": "bench-1", "universe_kind": "benchmark"}, + {"universe_id": "research-1", "universe_kind": "research_universe"}, + ], + "allowed_source_ids": ["fixture_source"], + "source_rights_registry_sha256": _sha256(registry), + "files": files, + "evaluation_policy": {"kind": "walk_forward", "minimum_history_count": 1}, + "corporate_action_policy": { + "listing": "required", "ticker_change": "not_applicable", + "exchange_change": "not_applicable", "split": "not_applicable", + "reverse_split": "not_applicable", "merger": "not_applicable", + "acquisition": "not_applicable", "spinoff": "not_applicable", + "delisting": "not_applicable", "suspension": "not_applicable", + "reactivation": "not_applicable", + }, + "delisting_policy": { + "retain_historical_members": True, + "missing_evidence": "blocked", + }, + "survivorship_policy": { + "filter_by_current_listing_state": False, + }, + "reproduction_contract": "membership_count_and_sha256_at_cutoff_v1", + } + manifest_path = package / "manifest.json" + manifest_path.write_text(json.dumps(manifest, sort_keys=True), encoding="utf-8") + return manifest_path, registry +``` + +- [ ] **Step 2: Write failing manifest tests** + +```python +# tests/test_point_in_time_universe_manifest.py +from pathlib import Path +import json +import pytest + +from tests.point_in_time_universe_fixture import build_valid_package + + +def _file_bytes(root: Path) -> dict[str, bytes]: + return {p.relative_to(root).as_posix(): p.read_bytes() for p in root.rglob("*") if p.is_file()} + + +def test_loads_hash_bound_manifest_without_writing(tmp_path): + from src.point_in_time_universe_manifest import load_universe_package + manifest, registry = build_valid_package(tmp_path) + before = _file_bytes(tmp_path) + loaded = load_universe_package(manifest, registry) + assert loaded.manifest.schema_version == "point_in_time_universe_v1" + assert set(loaded.files) == {"security_identity", "membership", "events", "evaluations"} + assert _file_bytes(tmp_path) == before + + +@pytest.mark.parametrize("mutation,match", [ + ("hash", "manifest_hash_mismatch"), + ("row_count", "manifest_row_count_mismatch"), + ("schema", "manifest_schema_unsupported"), + ("registry", "manifest_registry_digest_mismatch"), +]) +def test_manifest_integrity_fails_closed(tmp_path, mutation, match): + from src.point_in_time_universe_manifest import load_universe_package + manifest, registry = build_valid_package(tmp_path) + raw = json.loads(manifest.read_text()) + if mutation == "hash": + raw["files"][0]["sha256"] = "0" * 64 + elif mutation == "row_count": + raw["files"][0]["row_count"] += 1 + elif mutation == "schema": + raw["schema_version"] = "unknown" + else: + registry.write_text(registry.read_text() + "\n", encoding="utf-8") + manifest.write_text(json.dumps(raw), encoding="utf-8") + with pytest.raises(ValueError, match=match): + load_universe_package(manifest, registry) +``` + +- [ ] **Step 3: Run the manifest tests and observe RED** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests/test_point_in_time_universe_manifest.py -q +``` + +Expected: collection fails because `src.point_in_time_universe_manifest` does not exist. + +- [ ] **Step 4: Implement the immutable manifest loader** + +```python +# src/point_in_time_universe_manifest.py +from __future__ import annotations + +import csv +import hashlib +import json +from dataclasses import dataclass +from pathlib import Path +from types import MappingProxyType +from typing import Any, Mapping + + +REQUIRED_CONTRACTS = frozenset({"security_identity", "membership", "events", "evaluations"}) + + +@dataclass(frozen=True) +class ManifestFile: + path: str + contract: str + sha256: str + row_count: int + + +@dataclass(frozen=True) +class UniverseManifest: + schema_version: str + dataset_id: str + manifest_id: str + manifest_created_at: str + observation_cutoff_at: str + coverage_semantics: str + declared_universes: tuple[Mapping[str, str], ...] + allowed_source_ids: tuple[str, ...] + source_rights_registry_sha256: str + files: tuple[ManifestFile, ...] + evaluation_policy: Mapping[str, Any] + corporate_action_policy: Mapping[str, str] + delisting_policy: Mapping[str, Any] + survivorship_policy: Mapping[str, Any] + reproduction_contract: str + + +@dataclass(frozen=True) +class LoadedUniversePackage: + manifest_path: Path + registry_path: Path + manifest: UniverseManifest + files: Mapping[str, Path] + + +def _sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _csv_row_count(path: Path) -> int: + with path.open(encoding="utf-8", newline="") as handle: + return sum(1 for _ in csv.DictReader(handle)) + + +def _safe_child(base: Path, relative: str) -> Path: + if not relative or Path(relative).is_absolute(): + raise ValueError("manifest_path_unsafe") + resolved_base = base.resolve() + resolved = (base / relative).resolve() + if resolved == resolved_base or resolved_base not in resolved.parents: + raise ValueError("manifest_path_unsafe") + return resolved + + +def load_universe_package(manifest_path: Path, registry_path: Path) -> LoadedUniversePackage: + manifest_path = Path(manifest_path) + registry_path = Path(registry_path) + try: + raw = json.loads(manifest_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ValueError("manifest_unreadable") from exc + if raw.get("schema_version") != "point_in_time_universe_v1": + raise ValueError("manifest_schema_unsupported") + file_records = tuple(ManifestFile(**item) for item in raw.get("files", [])) + contracts = [item.contract for item in file_records] + if set(contracts) != REQUIRED_CONTRACTS or len(contracts) != len(set(contracts)): + raise ValueError("manifest_contract_set_invalid") + resolved: dict[str, Path] = {} + for item in file_records: + path = _safe_child(manifest_path.parent, item.path) + if _sha256(path) != item.sha256: + raise ValueError("manifest_hash_mismatch") + if _csv_row_count(path) != item.row_count: + raise ValueError("manifest_row_count_mismatch") + resolved[item.contract] = path + if _sha256(registry_path) != raw.get("source_rights_registry_sha256"): + raise ValueError("manifest_registry_digest_mismatch") + manifest = UniverseManifest( + schema_version=raw["schema_version"], + dataset_id=raw["dataset_id"], + manifest_id=raw["manifest_id"], + manifest_created_at=raw["manifest_created_at"], + observation_cutoff_at=raw["observation_cutoff_at"], + coverage_semantics=raw["coverage_semantics"], + declared_universes=tuple(MappingProxyType(dict(item)) for item in raw["declared_universes"]), + allowed_source_ids=tuple(raw["allowed_source_ids"]), + source_rights_registry_sha256=raw["source_rights_registry_sha256"], + files=file_records, + evaluation_policy=MappingProxyType(dict(raw["evaluation_policy"])), + corporate_action_policy=MappingProxyType(dict(raw["corporate_action_policy"])), + delisting_policy=MappingProxyType(dict(raw["delisting_policy"])), + survivorship_policy=MappingProxyType(dict(raw["survivorship_policy"])), + reproduction_contract=raw["reproduction_contract"], + ) + return LoadedUniversePackage( + manifest_path=manifest_path.resolve(), + registry_path=registry_path.resolve(), + manifest=manifest, + files=MappingProxyType(resolved), + ) +``` + +- [ ] **Step 5: Add path traversal and symlink-escape tests** + +```python +def test_manifest_rejects_parent_traversal(tmp_path): + from src.point_in_time_universe_manifest import load_universe_package + manifest, registry = build_valid_package(tmp_path) + raw = json.loads(manifest.read_text()) + raw["files"][0]["path"] = "../outside.csv" + manifest.write_text(json.dumps(raw), encoding="utf-8") + with pytest.raises(ValueError, match="manifest_path_unsafe"): + load_universe_package(manifest, registry) + + +def test_manifest_rejects_symlink_escape(tmp_path): + from src.point_in_time_universe_manifest import load_universe_package + manifest, registry = build_valid_package(tmp_path) + outside = tmp_path / "outside.csv" + outside.write_text("identity_row_id\nid-1\n", encoding="utf-8") + link = manifest.parent / "escape.csv" + link.symlink_to(outside) + raw = json.loads(manifest.read_text()) + raw["files"][0].update(path="escape.csv", sha256=__import__("hashlib").sha256(outside.read_bytes()).hexdigest(), row_count=1) + manifest.write_text(json.dumps(raw), encoding="utf-8") + with pytest.raises(ValueError, match="manifest_path_unsafe"): + load_universe_package(manifest, registry) +``` + +- [ ] **Step 6: Run focused tests and commit** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests/test_point_in_time_universe_manifest.py -q +git diff --check +git add -- src/point_in_time_universe_manifest.py tests/point_in_time_universe_fixture.py tests/test_point_in_time_universe_manifest.py +make staged-hygiene-check +git commit -m "Add immutable universe package loader" +``` + +Expected: all manifest tests pass; staged generated artifacts are `0`. + +--- + +### Task 2: Typed Evidence Contracts and Technical Validation + +**Files:** +- Create: `src/point_in_time_universe_contracts.py` +- Create: `tests/test_point_in_time_universe_contracts.py` + +**Interfaces:** +- Consumes `LoadedUniversePackage`. +- Produces `RawEvidenceRow`, `IdentityObservation`, `MembershipObservation`, `UniverseEvent`, `EvaluationObservation`, `ContractFinding`, and `ParsedUniverseEvidence`. +- Produces `parse_universe_evidence(package: LoadedUniversePackage) -> ParsedUniverseEvidence`. + +- [ ] **Step 1: Write failing schema and type tests** + +```python +# tests/test_point_in_time_universe_contracts.py +import csv +import json +import math +import pytest + +from tests.point_in_time_universe_fixture import build_valid_package + + +def _rewrite_csv_and_manifest(manifest, contract, mutate): + raw = json.loads(manifest.read_text()) + entry = next(item for item in raw["files"] if item["contract"] == contract) + path = manifest.parent / entry["path"] + with path.open(encoding="utf-8", newline="") as handle: + rows = list(csv.DictReader(handle)) + mutate(rows) + with path.open("w", encoding="utf-8", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=list(rows[0])) + writer.writeheader() + writer.writerows(rows) + entry["sha256"] = __import__("hashlib").sha256(path.read_bytes()).hexdigest() + entry["row_count"] = len(rows) + manifest.write_text(json.dumps(raw), encoding="utf-8") + + +def test_parser_preserves_raw_order_and_normalizes_display_ticker(tmp_path): + from src.point_in_time_universe_manifest import load_universe_package + from src.point_in_time_universe_contracts import parse_universe_evidence + manifest, registry = build_valid_package(tmp_path) + _rewrite_csv_and_manifest(manifest, "security_identity", lambda rows: rows[0].update(ticker=" aaa ")) + parsed = parse_universe_evidence(load_universe_package(manifest, registry)) + assert parsed.identities[0].ticker == "AAA" + assert parsed.raw[0].contract == "security_identity" + assert parsed.raw[0].source_row == 2 + + +@pytest.mark.parametrize("contract,column,value,reason", [ + ("security_identity", "valid_from", "2020-01-01", "schema_timestamp_invalid"), + ("membership", "membership_state", "maybe", "schema_enum_invalid"), + ("membership", "universe_kind", "portfolio", "schema_enum_invalid"), + ("events", "event_type", "dividend", "schema_enum_invalid"), + ("events", "ratio_numerator", "nan", "schema_ratio_invalid"), + ("evaluations", "partition", "future", "schema_enum_invalid"), +]) +def test_invalid_values_become_stable_technical_findings(tmp_path, contract, column, value, reason): + from src.point_in_time_universe_manifest import load_universe_package + from src.point_in_time_universe_contracts import parse_universe_evidence + manifest, registry = build_valid_package(tmp_path) + _rewrite_csv_and_manifest(manifest, contract, lambda rows: rows[0].update({column: value})) + parsed = parse_universe_evidence(load_universe_package(manifest, registry)) + assert reason in {code for finding in parsed.findings for code in finding.reason_codes} +``` + +- [ ] **Step 2: Run tests and observe RED** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests/test_point_in_time_universe_contracts.py -q +``` + +Expected: collection fails because `src.point_in_time_universe_contracts` does not exist. + +- [ ] **Step 3: Implement frozen record types and parsers** + +```python +# src/point_in_time_universe_contracts.py +from __future__ import annotations + +import csv +import math +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from types import MappingProxyType +from typing import Any, Mapping + +from src.point_in_time_universe_manifest import LoadedUniversePackage + + +EVENT_TYPES = frozenset({"listing", "ticker_change", "exchange_change", "split", "reverse_split", "merger", "acquisition", "spinoff", "delisting", "suspension", "reactivation"}) +LISTING_STATES = frozenset({"", "active", "delisted", "suspended"}) +PARTITIONS = frozenset({"train", "validation", "test", "walk_forward"}) + + +@dataclass(frozen=True) +class RawEvidenceRow: + contract: str + source_file: str + source_row: int + values: Mapping[str, str] + + +@dataclass(frozen=True) +class ContractFinding: + contract: str + source_row: int + row_id: str + reason_codes: tuple[str, ...] + + +@dataclass(frozen=True) +class IdentityObservation: + identity_row_id: str; security_id: str; issuer_id: str; ticker: str + exchange: str; security_type: str; currency: str + valid_from: datetime; valid_to: datetime | None + source_id: str; source_ref: str; source_published_at: datetime; retrieved_at: datetime + supersedes_identity_row_id: str + + +@dataclass(frozen=True) +class MembershipObservation: + membership_row_id: str; universe_id: str; universe_kind: str; security_id: str + membership_state: str; effective_from: datetime; effective_to: datetime | None + observation_at: datetime; source_id: str; source_ref: str + source_published_at: datetime; retrieved_at: datetime + supersedes_membership_row_id: str + + +@dataclass(frozen=True) +class UniverseEvent: + event_row_id: str; security_id: str; event_type: str; effective_at: datetime + successor_security_id: str; ratio_numerator: float | None; ratio_denominator: float | None + listing_state_after: str; source_id: str; source_ref: str + source_published_at: datetime; retrieved_at: datetime; supersedes_event_row_id: str + + +@dataclass(frozen=True) +class EvaluationObservation: + evaluation_row_id: str; universe_id: str; evaluation_at: datetime + available_at: datetime; partition: str; source_ref: str + + +@dataclass(frozen=True) +class ParsedUniverseEvidence: + raw: tuple[RawEvidenceRow, ...] + identities: tuple[IdentityObservation, ...] + memberships: tuple[MembershipObservation, ...] + events: tuple[UniverseEvent, ...] + evaluations: tuple[EvaluationObservation, ...] + findings: tuple[ContractFinding, ...] + + +def parse_utc(value: str) -> datetime: + text = str(value or "").strip() + if not text.endswith("Z"): + raise ValueError("schema_timestamp_invalid") + parsed = datetime.fromisoformat(text[:-1] + "+00:00") + if parsed.tzinfo != timezone.utc: + raise ValueError("schema_timestamp_invalid") + return parsed + + +def optional_utc(value: str) -> datetime | None: + return None if not str(value or "").strip() else parse_utc(value) + + +def optional_positive_float(value: str) -> float | None: + if not str(value or "").strip(): + return None + parsed = float(value) + if not math.isfinite(parsed) or parsed <= 0: + raise ValueError("schema_ratio_invalid") + return parsed + + +IDENTITY_COLUMNS = ( + "identity_row_id", "security_id", "issuer_id", "ticker", "exchange", + "security_type", "currency", "valid_from", "valid_to", "source_id", + "source_ref", "source_published_at", "retrieved_at", + "supersedes_identity_row_id", +) +MEMBERSHIP_COLUMNS = ( + "membership_row_id", "universe_id", "universe_kind", "security_id", + "membership_state", "effective_from", "effective_to", "observation_at", + "source_id", "source_ref", "source_published_at", "retrieved_at", + "supersedes_membership_row_id", +) +EVENT_COLUMNS = ( + "event_row_id", "security_id", "event_type", "effective_at", + "successor_security_id", "ratio_numerator", "ratio_denominator", + "listing_state_after", "source_id", "source_ref", + "source_published_at", "retrieved_at", "supersedes_event_row_id", +) +EVALUATION_COLUMNS = ( + "evaluation_row_id", "universe_id", "evaluation_at", "available_at", + "partition", "source_ref", +) +COLUMNS = { + "security_identity": IDENTITY_COLUMNS, + "membership": MEMBERSHIP_COLUMNS, + "events": EVENT_COLUMNS, + "evaluations": EVALUATION_COLUMNS, +} +ROW_ID_FIELDS = { + "security_identity": "identity_row_id", + "membership": "membership_row_id", + "events": "event_row_id", + "evaluations": "evaluation_row_id", +} + + +def _required(row, *names): + values = tuple(str(row.get(name, "") or "").strip() for name in names) + if any(not value for value in values): + raise ValueError("schema_required_field_missing") + return values + + +def _parse_identity(row): + required = _required( + row, "identity_row_id", "security_id", "issuer_id", "ticker", + "exchange", "security_type", "currency", "valid_from", "source_id", + "source_ref", "source_published_at", "retrieved_at", + ) + return IdentityObservation( + identity_row_id=required[0], security_id=required[1], + issuer_id=required[2], ticker=required[3].upper(), + exchange=required[4], security_type=required[5], currency=required[6], + valid_from=parse_utc(required[7]), valid_to=optional_utc(row["valid_to"]), + source_id=required[8], source_ref=required[9], + source_published_at=parse_utc(required[10]), + retrieved_at=parse_utc(required[11]), + supersedes_identity_row_id=row["supersedes_identity_row_id"].strip(), + ) + + +def _parse_membership(row): + required = _required( + row, "membership_row_id", "universe_id", "universe_kind", + "security_id", "membership_state", "effective_from", + "observation_at", "source_id", "source_ref", + "source_published_at", "retrieved_at", + ) + if required[2] not in {"benchmark", "research_universe"}: + raise ValueError("schema_enum_invalid") + if required[4] not in {"included", "excluded"}: + raise ValueError("schema_enum_invalid") + return MembershipObservation( + membership_row_id=required[0], universe_id=required[1], + universe_kind=required[2], security_id=required[3], + membership_state=required[4], effective_from=parse_utc(required[5]), + effective_to=optional_utc(row["effective_to"]), + observation_at=parse_utc(required[6]), source_id=required[7], + source_ref=required[8], source_published_at=parse_utc(required[9]), + retrieved_at=parse_utc(required[10]), + supersedes_membership_row_id=row["supersedes_membership_row_id"].strip(), + ) + + +def _parse_event(row): + required = _required( + row, "event_row_id", "security_id", "event_type", "effective_at", + "source_id", "source_ref", "source_published_at", "retrieved_at", + ) + if required[2] not in EVENT_TYPES or row["listing_state_after"].strip() not in LISTING_STATES: + raise ValueError("schema_enum_invalid") + try: + numerator = optional_positive_float(row["ratio_numerator"]) + denominator = optional_positive_float(row["ratio_denominator"]) + except (TypeError, ValueError) as exc: + raise ValueError("schema_ratio_invalid") from exc + if (numerator is None) != (denominator is None): + raise ValueError("schema_ratio_pair_required") + return UniverseEvent( + event_row_id=required[0], security_id=required[1], + event_type=required[2], effective_at=parse_utc(required[3]), + successor_security_id=row["successor_security_id"].strip(), + ratio_numerator=numerator, ratio_denominator=denominator, + listing_state_after=row["listing_state_after"].strip(), + source_id=required[4], source_ref=required[5], + source_published_at=parse_utc(required[6]), + retrieved_at=parse_utc(required[7]), + supersedes_event_row_id=row["supersedes_event_row_id"].strip(), + ) + + +def _parse_evaluation(row): + required = _required( + row, "evaluation_row_id", "universe_id", "evaluation_at", + "available_at", "partition", "source_ref", + ) + if required[4] not in PARTITIONS: + raise ValueError("schema_enum_invalid") + return EvaluationObservation( + evaluation_row_id=required[0], universe_id=required[1], + evaluation_at=parse_utc(required[2]), available_at=parse_utc(required[3]), + partition=required[4], source_ref=required[5], + ) + + +PARSERS = { + "security_identity": _parse_identity, + "membership": _parse_membership, + "events": _parse_event, + "evaluations": _parse_evaluation, +} + + +def parse_universe_evidence(package: LoadedUniversePackage) -> ParsedUniverseEvidence: + raw_rows: list[RawEvidenceRow] = [] + parsed: dict[str, list] = {name: [] for name in COLUMNS} + findings: list[ContractFinding] = [] + for contract in ("security_identity", "membership", "events", "evaluations"): + path = package.files[contract] + with path.open(encoding="utf-8", newline="") as handle: + reader = csv.DictReader(handle) + if tuple(reader.fieldnames or ()) != COLUMNS[contract]: + findings.append(ContractFinding( + contract, 1, "", ("schema_columns_invalid",), + )) + continue + for source_row, values in enumerate(reader, start=2): + clean = MappingProxyType({ + key: str(value or "") for key, value in values.items() + }) + raw_rows.append(RawEvidenceRow( + contract, path.name, source_row, clean, + )) + row_id = clean.get(ROW_ID_FIELDS[contract], "").strip() + try: + parsed[contract].append(PARSERS[contract](clean)) + except (KeyError, TypeError, ValueError) as exc: + reason = str(exc) + if not reason.startswith("schema_"): + reason = "schema_value_invalid" + findings.append(ContractFinding( + contract, source_row, row_id, (reason,), + )) + return ParsedUniverseEvidence( + raw=tuple(raw_rows), + identities=tuple(parsed["security_identity"]), + memberships=tuple(parsed["membership"]), + events=tuple(parsed["events"]), + evaluations=tuple(parsed["evaluations"]), + findings=tuple(findings), + ) +``` + +- [ ] **Step 4: Add ratio-pair and exact-column tests** + +```python +def test_split_requires_both_positive_ratio_values(tmp_path): + from src.point_in_time_universe_manifest import load_universe_package + from src.point_in_time_universe_contracts import parse_universe_evidence + manifest, registry = build_valid_package(tmp_path) + def mutate(rows): + rows[0].update(event_type="split", ratio_numerator="2", ratio_denominator="") + _rewrite_csv_and_manifest(manifest, "events", mutate) + parsed = parse_universe_evidence(load_universe_package(manifest, registry)) + assert "schema_ratio_pair_required" in {c for f in parsed.findings for c in f.reason_codes} + + +def test_unexpected_or_missing_columns_block_contract(tmp_path): + from src.point_in_time_universe_manifest import load_universe_package + from src.point_in_time_universe_contracts import parse_universe_evidence + manifest, registry = build_valid_package(tmp_path) + path = manifest.parent / "identity.csv" + text = path.read_text().replace("issuer_id,", "") + path.write_text(text, encoding="utf-8") + raw = json.loads(manifest.read_text()) + entry = next(i for i in raw["files"] if i["contract"] == "security_identity") + entry["sha256"] = __import__("hashlib").sha256(path.read_bytes()).hexdigest() + manifest.write_text(json.dumps(raw), encoding="utf-8") + parsed = parse_universe_evidence(load_universe_package(manifest, registry)) + assert "schema_columns_invalid" in {c for f in parsed.findings for c in f.reason_codes} +``` + +- [ ] **Step 5: Run focused tests and commit** + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests/test_point_in_time_universe_contracts.py tests/test_point_in_time_universe_manifest.py -q +git diff --check +git add -- src/point_in_time_universe_contracts.py tests/test_point_in_time_universe_contracts.py +make staged-hygiene-check +git commit -m "Add point-in-time universe contracts" +``` + +Expected: contract and manifest tests pass; no repository CSV/JSON is staged. + +--- + +### Task 3: Exact Revision Lineage, Stable Identity, and Historical Membership + +**Files:** +- Create: `src/point_in_time_universe_lineage.py` +- Create: `tests/test_point_in_time_universe_lineage.py` +- Create: `src/point_in_time_universe.py` +- Create: `tests/test_point_in_time_universe.py` + +**Interfaces:** +- Produces `LineageResult[T]` and `resolve_lineage(records, row_id, parent_id, scope, available_at, cutoff)`. +- Produces `Decision`, `ExcludedRow`, `MembershipDigest`, and `PointInTimeUniversePacket`. +- Produces `validate_point_in_time_universe(manifest_path, registry_path, top_n=20)`. + +- [ ] **Step 1: Write failing generic lineage tests** + +```python +# tests/test_point_in_time_universe_lineage.py +from dataclasses import dataclass +from datetime import datetime, timezone + +from src.point_in_time_universe_lineage import resolve_lineage + + +@dataclass(frozen=True) +class Row: + row_id: str + parent_id: str + scope: str + available_at: datetime + + +T0 = datetime(2020, 1, 1, tzinfo=timezone.utc) +T1 = datetime(2021, 1, 1, tzinfo=timezone.utc) + + +def test_selects_latest_unambiguous_leaf_available_by_cutoff(): + rows = (Row("a", "", "scope", T0), Row("b", "a", "scope", T1)) + result = resolve_lineage( + rows, row_id=lambda r: r.row_id, parent_id=lambda r: r.parent_id, + scope=lambda r: r.scope, available_at=lambda r: r.available_at, cutoff=T1, + ) + assert result.leaves == (rows[1],) + assert result.reason_codes == () + + +def test_fork_blocks_scope_instead_of_picking_a_leaf(): + rows = (Row("a", "", "scope", T0), Row("b", "a", "scope", T1), Row("c", "a", "scope", T1)) + result = resolve_lineage( + rows, row_id=lambda r: r.row_id, parent_id=lambda r: r.parent_id, + scope=lambda r: r.scope, available_at=lambda r: r.available_at, cutoff=T1, + ) + assert result.leaves == () + assert "lineage_fork" in result.reason_codes +``` + +Add the remaining lineage cases with explicit rows: + +```python +def _resolve(rows, cutoff=T1): + return resolve_lineage( + rows, row_id=lambda r: r.row_id, parent_id=lambda r: r.parent_id, + scope=lambda r: r.scope, available_at=lambda r: r.available_at, + cutoff=cutoff, + ) + + +@pytest.mark.parametrize("rows,reason", [ + ((Row("a", "", "s", T0), Row("a", "", "s", T1)), "lineage_duplicate_id"), + ((Row("b", "missing", "s", T1),), "lineage_missing_parent"), + ((Row("a", "", "s1", T0), Row("b", "a", "s2", T1)), "lineage_cross_scope_parent"), + ((Row("a", "", "s", T0), Row("b", "", "s", T1)), "lineage_multiple_roots"), + ((Row("a", "b", "s", T0), Row("b", "a", "s", T1)), "lineage_cycle"), + ((Row("a", "", "s", T1), Row("b", "a", "s", T0)), "lineage_order_reversed"), +]) +def test_invalid_lineage_is_blocked(rows, reason): + assert reason in _resolve(rows).reason_codes + + +def test_post_cutoff_revision_is_not_selected(): + rows = (Row("a", "", "scope", T0), Row("b", "a", "scope", T1)) + result = _resolve(rows, cutoff=T0) + assert tuple(row.row_id for row in result.leaves) == ("a",) +``` + +- [ ] **Step 2: Run lineage tests and observe RED** + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests/test_point_in_time_universe_lineage.py -q +``` + +Expected: collection fails because `src.point_in_time_universe_lineage` does not exist. + +- [ ] **Step 3: Implement generic lineage resolution** + +```python +# src/point_in_time_universe_lineage.py +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import Callable, Generic, Iterable, TypeVar + +T = TypeVar("T") + + +@dataclass(frozen=True) +class LineageResult(Generic[T]): + leaves: tuple[T, ...] + excluded: tuple[T, ...] + reason_codes: tuple[str, ...] + + +def resolve_lineage( + records: Iterable[T], *, row_id: Callable[[T], str], + parent_id: Callable[[T], str], scope: Callable[[T], str], + available_at: Callable[[T], datetime], cutoff: datetime, +) -> LineageResult[T]: + eligible = tuple(record for record in records if available_at(record) <= cutoff) + ids = [row_id(record) for record in eligible] + reasons: set[str] = set() + if len(ids) != len(set(ids)): + reasons.add("lineage_duplicate_id") + by_id = {row_id(record): record for record in eligible} + children: dict[str, list[T]] = {} + roots: dict[str, list[T]] = {} + for record in eligible: + parent = parent_id(record) + record_scope = scope(record) + if not parent: + roots.setdefault(record_scope, []).append(record) + continue + prior = by_id.get(parent) + if prior is None: + reasons.add("lineage_missing_parent") + continue + if scope(prior) != record_scope: + reasons.add("lineage_cross_scope_parent") + if available_at(record) <= available_at(prior): + reasons.add("lineage_order_reversed") + children.setdefault(parent, []).append(record) + if any(len(value) > 1 for value in roots.values()): + reasons.add("lineage_multiple_roots") + if any(len(value) > 1 for value in children.values()): + reasons.add("lineage_fork") + for start in eligible: + seen: set[str] = set() + current = start + while parent_id(current): + current_id = row_id(current) + if current_id in seen: + reasons.add("lineage_cycle") + break + seen.add(current_id) + parent = by_id.get(parent_id(current)) + if parent is None: + break + current = parent + if reasons: + return LineageResult((), eligible, tuple(sorted(reasons))) + leaves = tuple(record for record in eligible if row_id(record) not in children) + return LineageResult(leaves, tuple(record for record in eligible if record not in leaves), ()) +``` + +- [ ] **Step 4: Write failing identity and membership tests** + +```python +# tests/test_point_in_time_universe.py +import csv +import json + +from tests.point_in_time_universe_fixture import build_valid_package +from tests.test_point_in_time_universe_contracts import _rewrite_csv_and_manifest + + +def test_ticker_change_preserves_security_identity_without_current_ticker_fallback(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + manifest, registry = build_valid_package(tmp_path) + def mutate(rows): + prior = dict(rows[0]) + rows[0]["valid_to"] = "2020-06-01T00:00:00Z" + rows.append({ + **prior, "identity_row_id": "id-2", "ticker": "BBB", + "valid_from": "2020-06-01T00:00:00Z", "valid_to": "", + "source_ref": "fixture://identity/id-2", + "source_published_at": "2020-06-01T00:00:00Z", + "retrieved_at": "2020-06-02T00:00:00Z", + "supersedes_identity_row_id": "id-1", + }) + _rewrite_csv_and_manifest(manifest, "security_identity", mutate) + packet = validate_point_in_time_universe(manifest, registry) + assert packet.decisions["identity_coverage"].status == "passed" + assert packet.display_tickers["sec-1"] == "BBB" + + +def test_same_ticker_for_two_security_ids_does_not_merge_membership(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + manifest, registry = build_valid_package(tmp_path) + def mutate(rows): + rows.append({**rows[0], "identity_row_id": "id-2", "security_id": "sec-2", "issuer_id": "issuer-2", "source_ref": "fixture://identity/id-2"}) + _rewrite_csv_and_manifest(manifest, "security_identity", mutate) + packet = validate_point_in_time_universe(manifest, registry) + assert packet.membership_digests[0].member_count == 1 +``` + +Add explicit identity and membership failure cases: + +```python +@pytest.mark.parametrize("case,reason", [ + ("overlapping_identity", "identity_interval_overlap"), + ("missing_identity", "identity_missing"), + ("membership_outside_interval", "membership_interval_inactive"), + ("undeclared_universe", "membership_universe_undeclared"), + ("kind_mismatch", "membership_universe_kind_mismatch"), +]) +def test_identity_and_membership_fail_closed(tmp_path, case, reason): + from src.point_in_time_universe import validate_point_in_time_universe + manifest, registry = build_valid_package(tmp_path) + mutate_identity_membership_case(manifest, case) + packet = validate_point_in_time_universe(manifest, registry) + assert reason in { + code for decision in packet.decisions.values() + for code in decision.reason_codes + } + assert packet.analysis_eligible is False + + +def mutate_identity_membership_case(manifest, case): + if case == "overlapping_identity": + def mutate(rows): + rows.append({ + **rows[0], "identity_row_id": "id-overlap", + "source_ref": "fixture://identity/id-overlap", + "source_published_at": "2020-02-01T00:00:00Z", + "retrieved_at": "2020-02-02T00:00:00Z", + "supersedes_identity_row_id": "id-1", + }) + _rewrite_csv_and_manifest(manifest, "security_identity", mutate) + elif case == "missing_identity": + _rewrite_csv_and_manifest( + manifest, "membership", + lambda rows: rows[0].update(security_id="sec-missing"), + ) + elif case == "membership_outside_interval": + _rewrite_csv_and_manifest( + manifest, "membership", + lambda rows: rows[0].update(effective_to="2020-06-01T00:00:00Z"), + ) + elif case == "undeclared_universe": + _rewrite_csv_and_manifest( + manifest, "membership", + lambda rows: rows[0].update(universe_id="unknown"), + ) + elif case == "kind_mismatch": + _rewrite_csv_and_manifest( + manifest, "membership", + lambda rows: rows[0].update(universe_kind="research_universe"), + ) +``` + +- [ ] **Step 5: Implement packet types and identity/membership composition** + +```python +# src/point_in_time_universe.py +from __future__ import annotations + +import hashlib +from dataclasses import dataclass +from pathlib import Path +from types import MappingProxyType +from typing import Mapping + +from src.point_in_time_universe_contracts import parse_universe_evidence, parse_utc +from src.point_in_time_universe_lineage import resolve_lineage +from src.point_in_time_universe_manifest import load_universe_package + + +@dataclass(frozen=True) +class Decision: + area: str + status: str + reason_codes: tuple[str, ...] + + +@dataclass(frozen=True) +class ExcludedRow: + contract: str + source_row: int + row_id: str + reason_codes: tuple[str, ...] + + +@dataclass(frozen=True) +class MembershipDigest: + universe_id: str + evaluation_at: str + member_count: int + sha256: str + + +@dataclass(frozen=True) +class PointInTimeUniversePacket: + dataset_id: str + manifest_id: str + analysis_eligible: bool + decisions: Mapping[str, Decision] + raw_count: int + normalized_count: int + excluded: tuple[ExcludedRow, ...] + membership_digests: tuple[MembershipDigest, ...] + display_tickers: Mapping[str, str] + boundary: str + + +def _membership_digest(universe_id: str, evaluation_at: str, members: set[str]) -> MembershipDigest: + payload = "\n".join(sorted(members)).encode("utf-8") + return MembershipDigest(universe_id, evaluation_at, len(members), hashlib.sha256(payload).hexdigest()) + + +def _contains(start, end, at): + return start <= at and (end is None or at < end) + + +def _row_number(parsed, contract, row_id): + id_field = { + "security_identity": "identity_row_id", + "membership": "membership_row_id", + }[contract] + return next( + (row.source_row for row in parsed.raw if row.contract == contract and row.values.get(id_field) == row_id), + 0, + ) + + +def _identity_membership_decisions(manifest, parsed): + declared = { + item["universe_id"]: item["universe_kind"] + for item in manifest.declared_universes + } + identity_reasons: set[str] = set() + membership_reasons: set[str] = set() + excluded: list[ExcludedRow] = [] + digests: list[MembershipDigest] = [] + display: dict[str, str] = {} + cutoff = parse_utc(manifest.observation_cutoff_at) + evaluations = tuple(item for item in parsed.evaluations if item.evaluation_at <= cutoff) + for evaluation in evaluations: + expected_kind = declared.get(evaluation.universe_id) + if expected_kind is None: + membership_reasons.add("membership_universe_undeclared") + continue + grouped: dict[str, list] = {} + for row in parsed.memberships: + if row.universe_id != evaluation.universe_id: + continue + if row.universe_kind != expected_kind: + membership_reasons.add("membership_universe_kind_mismatch") + continue + grouped.setdefault(row.security_id, []).append(row) + members: set[str] = set() + for security_id, rows in grouped.items(): + lineage = resolve_lineage( + rows, + row_id=lambda row: row.membership_row_id, + parent_id=lambda row: row.supersedes_membership_row_id, + scope=lambda row: f"{row.universe_id}:{row.security_id}", + available_at=lambda row: max(row.observation_at, row.source_published_at, row.retrieved_at), + cutoff=evaluation.evaluation_at, + ) + membership_reasons.update(lineage.reason_codes) + for leaf in lineage.leaves: + if not _contains(leaf.effective_from, leaf.effective_to, evaluation.evaluation_at): + membership_reasons.add("membership_interval_inactive") + excluded.append(ExcludedRow( + "membership", _row_number(parsed, "membership", leaf.membership_row_id), + leaf.membership_row_id, ("membership_interval_inactive",), + )) + continue + if leaf.membership_state == "excluded": + continue + identity_rows = tuple(row for row in parsed.identities if row.security_id == security_id) + identity_lineage = resolve_lineage( + identity_rows, + row_id=lambda row: row.identity_row_id, + parent_id=lambda row: row.supersedes_identity_row_id, + scope=lambda row: row.security_id, + available_at=lambda row: max(row.source_published_at, row.retrieved_at), + cutoff=evaluation.evaluation_at, + ) + identity_reasons.update(identity_lineage.reason_codes) + active = tuple( + row for row in identity_lineage.leaves + if _contains(row.valid_from, row.valid_to, evaluation.evaluation_at) + ) + if not active: + identity_reasons.add("identity_missing") + excluded.append(ExcludedRow( + "membership", _row_number(parsed, "membership", leaf.membership_row_id), + leaf.membership_row_id, ("identity_missing",), + )) + continue + if len(active) != 1: + identity_reasons.add("identity_interval_overlap") + continue + members.add(security_id) + display[security_id] = active[0].ticker + if not members: + membership_reasons.add("membership_no_eligible_members") + digests.append(_membership_digest( + evaluation.universe_id, + evaluation.evaluation_at.isoformat().replace("+00:00", "Z"), + members, + )) + kinds = set(declared.values()) + if "benchmark" not in kinds: + membership_reasons.add("membership_benchmark_missing") + if "research_universe" not in kinds: + membership_reasons.add("membership_research_universe_missing") + if not evaluations: + membership_reasons.add("membership_no_evaluation") + return ( + Decision("identity_coverage", "blocked" if identity_reasons else "passed", tuple(sorted(identity_reasons))), + Decision("membership_coverage", "blocked" if membership_reasons else "passed", tuple(sorted(membership_reasons))), + tuple(digests), + MappingProxyType(display), + tuple(excluded), + ) + + +def validate_point_in_time_universe(manifest_path: Path, registry_path: Path, *, top_n: int = 20) -> PointInTimeUniversePacket: + package = load_universe_package(manifest_path, registry_path) + parsed = parse_universe_evidence(package) + cutoff = parse_utc(package.manifest.observation_cutoff_at) + decisions: dict[str, Decision] = {} + excluded: list[ExcludedRow] = [ + ExcludedRow(f.contract, f.source_row, f.row_id, f.reason_codes) + for f in parsed.findings + ] + decisions["technical_validity"] = Decision( + "technical_validity", "blocked" if parsed.findings else "passed", + tuple(sorted({code for finding in parsed.findings for code in finding.reason_codes})), + ) + identity, membership, digests, display, composed_excluded = ( + _identity_membership_decisions(package.manifest, parsed) + ) + decisions[identity.area] = identity + decisions[membership.area] = membership + excluded.extend(composed_excluded) + return PointInTimeUniversePacket( + dataset_id=package.manifest.dataset_id, + manifest_id=package.manifest.manifest_id, + analysis_eligible=False, + decisions=MappingProxyType(decisions), + raw_count=len(parsed.raw), + normalized_count=sum((len(parsed.identities), len(parsed.memberships), len(parsed.events), len(parsed.evaluations))), + excluded=tuple(excluded[:top_n]), + membership_digests=digests, + display_tickers=display, + boundary="Local evidence eligibility only; no readiness, backtest, probability, recommendation, or trading activation.", + ) +``` + +- [ ] **Step 6: Run focused tests and commit** + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest \ + tests/test_point_in_time_universe_lineage.py \ + tests/test_point_in_time_universe.py \ + tests/test_point_in_time_universe_contracts.py \ + tests/test_point_in_time_universe_manifest.py -q +git diff --check +git add -- src/point_in_time_universe_lineage.py src/point_in_time_universe.py tests/test_point_in_time_universe_lineage.py tests/test_point_in_time_universe.py +make staged-hygiene-check +git commit -m "Validate historical universe identity and membership" +``` + +Expected: lineage, identity, membership, contracts, and manifest tests pass. + +--- + +### Task 4: Corporate Actions, Delistings, and Exact-Source Rights + +**Files:** +- Modify: `src/point_in_time_universe.py` +- Modify: `tests/test_point_in_time_universe.py` + +**Interfaces:** +- Adds `corporate_action_coverage`, `delisting_coverage`, and `source_rights_eligibility` decisions. +- Uses `load_source_rights_registry` and `review_commercial_field_scope`. + +- [ ] **Step 0: Add the existing source-rights imports** + +```python +# src/point_in_time_universe.py +from src.commercial_source_rights import ( + load_source_rights_registry, + review_commercial_field_scope, +) +``` + +- [ ] **Step 1: Write failing action and delisting tests** + +```python +def test_split_requires_positive_explicit_ratio_and_does_not_rewrite_membership(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + manifest, registry = build_valid_package(tmp_path) + def mutate(rows): + rows[0].update(event_type="split", ratio_numerator="2", ratio_denominator="1") + _rewrite_csv_and_manifest(manifest, "events", mutate) + packet = validate_point_in_time_universe(manifest, registry) + assert packet.decisions["corporate_action_coverage"].status == "passed" + assert packet.membership_digests[0].member_count == 1 + + +def test_delisted_historical_member_is_retained_and_not_filtered_by_current_state(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + manifest, registry = build_valid_package(tmp_path) + def mutate(rows): + rows.append({ + **rows[0], "event_row_id": "event-2", "event_type": "delisting", + "effective_at": "2022-01-01T00:00:00Z", "listing_state_after": "delisted", + "source_ref": "fixture://event/event-2", + "source_published_at": "2022-01-01T00:00:00Z", + "retrieved_at": "2022-01-02T00:00:00Z", + "supersedes_event_row_id": "", + }) + _rewrite_csv_and_manifest(manifest, "events", mutate) + raw = json.loads(manifest.read_text()) + raw["corporate_action_policy"]["delisting"] = "required" + manifest.write_text(json.dumps(raw), encoding="utf-8") + packet = validate_point_in_time_universe(manifest, registry) + assert all(d.member_count == 1 for d in packet.membership_digests) + assert packet.decisions["delisting_coverage"].status == "passed" +``` + +Add the remaining explicit action-policy cases: + +```python +@pytest.mark.parametrize("event_type,updates,reason", [ + ("merger", {"successor_security_id": ""}, "corporate_action_successor_required"), + ("acquisition", {"successor_security_id": ""}, "corporate_action_successor_required"), + ("spinoff", {"successor_security_id": ""}, "corporate_action_successor_required"), + ("delisting", {"listing_state_after": "active"}, "delisting_state_invalid"), + ("reactivation", {"listing_state_after": "active"}, "delisting_transition_invalid"), +]) +def test_invalid_action_or_listing_transition_is_blocked(tmp_path, event_type, updates, reason): + from src.point_in_time_universe import validate_point_in_time_universe + manifest, registry = build_valid_package(tmp_path) + def mutate(rows): + rows[0].update(event_type=event_type, **updates) + _rewrite_csv_and_manifest(manifest, "events", mutate) + raw = json.loads(manifest.read_text()) + raw["corporate_action_policy"][event_type] = "required" + manifest.write_text(json.dumps(raw), encoding="utf-8") + packet = validate_point_in_time_universe(manifest, registry) + assert reason in { + code for decision in packet.decisions.values() + for code in decision.reason_codes + } + + +def test_present_event_marked_unsupported_is_blocked(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + manifest, registry = build_valid_package(tmp_path) + raw = json.loads(manifest.read_text()) + raw["corporate_action_policy"]["listing"] = "unsupported" + manifest.write_text(json.dumps(raw), encoding="utf-8") + packet = validate_point_in_time_universe(manifest, registry) + assert "corporate_action_policy_unsupported" in packet.decisions["corporate_action_coverage"].reason_codes +``` + +- [ ] **Step 2: Write failing independent rights tests** + +```python +def test_technical_pass_does_not_promote_unverified_rights(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + manifest, registry = build_valid_package(tmp_path) + registry.write_text(registry.read_text().replace("commercial_use: approved", "commercial_use: unverified"), encoding="utf-8") + raw = json.loads(manifest.read_text()) + raw["source_rights_registry_sha256"] = __import__("hashlib").sha256(registry.read_bytes()).hexdigest() + manifest.write_text(json.dumps(raw), encoding="utf-8") + packet = validate_point_in_time_universe(manifest, registry) + assert packet.decisions["technical_validity"].status == "passed" + assert packet.decisions["source_rights_eligibility"].status == "blocked" + assert packet.analysis_eligible is False + + +def test_missing_registered_delisting_scope_blocks_only_rights_state(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + manifest, registry = build_valid_package(tmp_path) + _rewrite_csv_and_manifest( + manifest, "events", + lambda rows: rows[0].update( + event_type="delisting", listing_state_after="delisted", + ), + ) + raw = json.loads(manifest.read_text()) + raw["corporate_action_policy"]["listing"] = "not_applicable" + raw["corporate_action_policy"]["delisting"] = "required" + manifest.write_text(json.dumps(raw), encoding="utf-8") + registry.write_text(registry.read_text().replace(", delistings", ""), encoding="utf-8") + raw = json.loads(manifest.read_text()) + raw["source_rights_registry_sha256"] = __import__("hashlib").sha256(registry.read_bytes()).hexdigest() + manifest.write_text(json.dumps(raw), encoding="utf-8") + packet = validate_point_in_time_universe(manifest, registry) + assert packet.decisions["technical_validity"].status == "passed" + assert "source_rights_field_scope_missing" in packet.decisions["source_rights_eligibility"].reason_codes +``` + +- [ ] **Step 3: Run focused tests and observe RED** + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests/test_point_in_time_universe.py -q +``` + +Expected: new assertions fail because the three independent decisions are absent. + +- [ ] **Step 4: Implement actions, delistings, and rights composition** + +Add these exact helpers: + +```python +def _event_decisions(manifest, parsed) -> tuple[Decision, Decision, tuple[ExcludedRow, ...]]: + action_reasons: set[str] = set() + delisting_reasons: set[str] = set() + excluded: list[ExcludedRow] = [] + events_by_type: dict[str, list] = {} + for event in parsed.events: + events_by_type.setdefault(event.event_type, []).append(event) + reasons: set[str] = set() + policy = manifest.corporate_action_policy.get(event.event_type) + if policy == "unsupported": + reasons.add("corporate_action_policy_unsupported") + if event.event_type in {"split", "reverse_split"} and ( + event.ratio_numerator is None or event.ratio_denominator is None + ): + reasons.add("corporate_action_ratio_required") + if event.event_type in {"merger", "acquisition", "spinoff"} and not event.successor_security_id: + reasons.add("corporate_action_successor_required") + if event.event_type == "delisting" and event.listing_state_after != "delisted": + reasons.add("delisting_state_invalid") + if event.event_type == "suspension" and event.listing_state_after != "suspended": + reasons.add("delisting_transition_invalid") + if event.event_type == "reactivation": + prior_suspension = any( + prior.security_id == event.security_id + and prior.event_type == "suspension" + and prior.effective_at < event.effective_at + for prior in parsed.events + ) + if event.listing_state_after != "active" or not prior_suspension: + reasons.add("delisting_transition_invalid") + if reasons: + target = delisting_reasons if event.event_type in {"delisting", "suspension", "reactivation"} else action_reasons + target.update(reasons) + source_row = next( + (row.source_row for row in parsed.raw if row.contract == "events" and row.values.get("event_row_id") == event.event_row_id), + 0, + ) + excluded.append(ExcludedRow("events", source_row, event.event_row_id, tuple(sorted(reasons)))) + for event_type, state in manifest.corporate_action_policy.items(): + if state == "required" and not events_by_type.get(event_type): + if event_type == "delisting": + delisting_reasons.add("delisting_evidence_missing") + else: + action_reasons.add("corporate_action_evidence_missing") + if manifest.delisting_policy.get("retain_historical_members") is not True: + delisting_reasons.add("delisting_survivorship_policy_invalid") + if manifest.survivorship_policy.get("filter_by_current_listing_state") is not False: + delisting_reasons.add("delisting_survivorship_policy_invalid") + delisting_applicable = ( + manifest.corporate_action_policy.get("delisting") == "required" + or any(event.event_type in {"delisting", "suspension", "reactivation"} for event in parsed.events) + ) + return ( + Decision("corporate_action_coverage", "blocked" if action_reasons else "passed", tuple(sorted(action_reasons))), + Decision( + "delisting_coverage", + "blocked" if delisting_reasons else "passed" if delisting_applicable else "not_applicable", + tuple(sorted(delisting_reasons)), + ), + tuple(excluded), + ) + + +def _rights_decision(manifest, parsed, registry) -> Decision: + blockers: set[str] = set() + for source_id in sorted({row.source_id for rows in (parsed.identities, parsed.memberships, parsed.events) for row in rows}): + if source_id not in manifest.allowed_source_ids: + blockers.add("source_rights_source_not_allowed") + required: set[str] = set() + if any(row.source_id == source_id for row in parsed.identities): + required.add("security_identity") + if any(row.source_id == source_id for row in parsed.memberships): + required.add("universe_membership") + source_events = tuple(row for row in parsed.events if row.source_id == source_id) + if any(row.event_type != "delisting" for row in source_events): + required.add("corporate_actions") + if any(row.event_type == "delisting" for row in source_events): + required.add("delistings") + review = review_commercial_field_scope(registry, source_id, tuple(sorted(required))) + if not review.commercial_rights_approved: + blockers.add(f"source_rights_{review.rights_status}") + if review.missing_supported_fields: + blockers.add("source_rights_field_scope_missing") + return Decision("source_rights_eligibility", "blocked" if blockers else "passed", tuple(sorted(blockers))) +``` + +Call `_event_decisions(package.manifest, parsed)` and +`_rights_decision(package.manifest, parsed, +load_source_rights_registry(package.registry_path))` from the packet composer, +then append their decisions and exclusions without changing the already +computed identity or membership decisions. + +- [ ] **Step 5: Run focused tests and commit** + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests/test_point_in_time_universe.py tests/test_commercial_source_rights.py -q +git diff --check +git add -- src/point_in_time_universe.py tests/test_point_in_time_universe.py +make staged-hygiene-check +git commit -m "Enforce universe action and rights gates" +``` + +Expected: action, delisting, rights, and existing commercial-rights tests pass. + +--- + +### Task 5: Evaluation Cutoffs, Leakage Safety, and Deterministic Reproduction + +**Files:** +- Modify: `src/point_in_time_universe.py` +- Modify: `tests/test_point_in_time_universe.py` + +**Interfaces:** +- Adds `temporal_validity`, `reproduction_ready`, and `leakage_safe`. +- Completes `analysis_eligible` as the conjunction of all applicable independent states plus one eligible benchmark and one eligible research universe. + +- [ ] **Step 1: Write failing cutoff and leakage tests** + +```python +import pytest + + +@pytest.mark.parametrize("contract,column", [ + ("security_identity", "source_published_at"), + ("security_identity", "retrieved_at"), + ("membership", "observation_at"), + ("membership", "source_published_at"), + ("membership", "retrieved_at"), + ("events", "effective_at"), + ("events", "source_published_at"), + ("events", "retrieved_at"), +]) +def test_post_cutoff_evidence_is_excluded_without_poisoning_independent_states(tmp_path, contract, column): + from src.point_in_time_universe import validate_point_in_time_universe + manifest, registry = build_valid_package(tmp_path) + _rewrite_csv_and_manifest(manifest, contract, lambda rows: rows[0].update({column: "2022-01-01T00:00:00Z"})) + packet = validate_point_in_time_universe(manifest, registry) + assert packet.decisions["leakage_safe"].status == "blocked" + assert any(code.startswith("leakage_") or code.startswith("cutoff_") for row in packet.excluded for code in row.reason_codes) + assert packet.analysis_eligible is False + + +def test_later_revision_is_invisible_at_earlier_evaluation(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + manifest, registry = build_valid_package(tmp_path) + # Add a post-evaluation membership revision that excludes sec-1. + def mutate(rows): + rows.append({ + **rows[0], "membership_row_id": "member-late", + "membership_state": "excluded", + "source_ref": "fixture://membership/late", + "source_published_at": "2022-01-01T00:00:00Z", + "retrieved_at": "2022-01-02T00:00:00Z", + "supersedes_membership_row_id": rows[0]["membership_row_id"], + }) + _rewrite_csv_and_manifest(manifest, "membership", mutate) + packet = validate_point_in_time_universe(manifest, registry) + assert packet.membership_digests[0].member_count == 1 +``` + +- [ ] **Step 2: Write failing partition and reproduction tests** + +```python +def test_repeated_validation_reproduces_counts_digests_and_reasons(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + manifest, registry = build_valid_package(tmp_path) + first = validate_point_in_time_universe(manifest, registry) + second = validate_point_in_time_universe(manifest, registry) + assert first.membership_digests == second.membership_digests + assert first.decisions == second.decisions + assert first.excluded == second.excluded + assert {d.universe_id: d.member_count for d in first.membership_digests} == {"bench-1": 1, "research-1": 1} + + +def test_partition_policy_rejects_overlap_and_post_hoc_order(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + manifest, registry = build_valid_package(tmp_path) + raw = json.loads(manifest.read_text()) + raw["evaluation_policy"] = { + "kind": "partitioned", + "train": {"start": "2020-01-01T00:00:00Z", "end": "2021-01-01T00:00:00Z"}, + "validation": {"start": "2020-12-01T00:00:00Z", "end": "2021-06-01T00:00:00Z"}, + "test": {"start": "2021-06-01T00:00:00Z", "end": "2022-01-01T00:00:00Z"}, + } + manifest.write_text(json.dumps(raw), encoding="utf-8") + packet = validate_point_in_time_universe(manifest, registry) + assert "partition_overlap" in packet.decisions["leakage_safe"].reason_codes +``` + +- [ ] **Step 3: Run focused tests and observe RED** + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests/test_point_in_time_universe.py -q +``` + +Expected: cutoff, partition, reproduction, or final eligibility assertions fail. + +- [ ] **Step 4: Implement temporal, partition, and reproduction helpers** + +```python +def _temporal_decision(parsed) -> tuple[Decision, tuple[str, ...], tuple[ExcludedRow, ...]]: + reasons: set[str] = set() + leakage_reasons: set[str] = set() + excluded: list[ExcludedRow] = [] + for evaluation in parsed.evaluations: + if evaluation.available_at > evaluation.evaluation_at: + reasons.add("cutoff_evaluation_unavailable") + leakage_reasons.add("leakage_evaluation_available_late") + excluded.append(ExcludedRow( + "evaluations", 0, evaluation.evaluation_row_id, + ("cutoff_evaluation_unavailable", "leakage_evaluation_available_late"), + )) + scoped = ( + ( + "security_identity", parsed.identities, + lambda row: row.security_id, + lambda row: max(row.source_published_at, row.retrieved_at), + lambda row: row.identity_row_id, + ), + ( + "membership", + tuple(row for row in parsed.memberships if row.universe_id == evaluation.universe_id), + lambda row: f"{row.universe_id}:{row.security_id}", + lambda row: max(row.observation_at, row.source_published_at, row.retrieved_at), + lambda row: row.membership_row_id, + ), + ( + "events", parsed.events, + lambda row: f"{row.security_id}:{row.event_type}", + lambda row: max(row.effective_at, row.source_published_at, row.retrieved_at), + lambda row: row.event_row_id, + ), + ) + for contract, rows, scope, available_at, row_id in scoped: + groups: dict[str, list] = {} + for row in rows: + groups.setdefault(scope(row), []).append(row) + for group in groups.values(): + if any(available_at(row) <= evaluation.evaluation_at for row in group): + continue + reasons.add("cutoff_required_scope_unavailable") + leakage_reasons.add("leakage_post_cutoff_evidence") + for row in group: + source_row = next( + ( + raw.source_row for raw in parsed.raw + if raw.contract == contract + and row_id(row) in raw.values.values() + ), + 0, + ) + excluded.append(ExcludedRow( + contract, source_row, row_id(row), + ("cutoff_required_scope_unavailable", "leakage_post_cutoff_evidence"), + )) + return ( + Decision("temporal_validity", "blocked" if reasons else "passed", tuple(sorted(reasons))), + tuple(sorted(leakage_reasons)), + tuple(excluded), + ) + + +def _partition_decision(manifest, evaluations, extra_reasons=()) -> Decision: + policy = manifest.evaluation_policy + reasons = set(extra_reasons) + if policy.get("kind") == "walk_forward": + minimum = policy.get("minimum_history_count") + if not isinstance(minimum, int) or isinstance(minimum, bool) or minimum <= 0: + reasons.add("partition_minimum_history_invalid") + elif policy.get("kind") == "partitioned": + try: + train_start = parse_utc(policy["train"]["start"]) + train_end = parse_utc(policy["train"]["end"]) + validation_start = parse_utc(policy["validation"]["start"]) + validation_end = parse_utc(policy["validation"]["end"]) + test_start = parse_utc(policy["test"]["start"]) + test_end = parse_utc(policy["test"]["end"]) + except (KeyError, TypeError, ValueError): + reasons.add("partition_schema_invalid") + else: + if not all(( + train_start < train_end, + validation_start < validation_end, + test_start < test_end, + )): + reasons.add("partition_order_invalid") + if train_end > validation_start or validation_end > test_start: + reasons.add("partition_overlap") + else: + reasons.add("partition_policy_invalid") + return Decision("leakage_safe", "blocked" if reasons else "passed", tuple(sorted(reasons))) + + +def _reproduction_decision(manifest, digests) -> Decision: + reasons: set[str] = set() + if manifest.reproduction_contract != "membership_count_and_sha256_at_cutoff_v1": + reasons.add("reproduction_contract_unsupported") + keys = [(item.universe_id, item.evaluation_at) for item in digests] + if len(keys) != len(set(keys)): + reasons.add("reproduction_duplicate_evaluation") + if any(len(item.sha256) != 64 for item in digests): + reasons.add("reproduction_digest_invalid") + return Decision("reproduction_ready", "blocked" if reasons else "passed", tuple(sorted(reasons))) + + +def _final_eligibility(decisions, digests, declared_universes) -> bool: + applicable_pass = all(decision.status in {"passed", "not_applicable"} for decision in decisions.values()) + kinds = {item["universe_id"]: item["universe_kind"] for item in declared_universes} + eligible_ids = {digest.universe_id for digest in digests} + return ( + applicable_pass + and any(kinds.get(item) == "benchmark" for item in eligible_ids) + and any(kinds.get(item) == "research_universe" for item in eligible_ids) + ) +``` + +Compose the final packet in this fixed decision order: + +```python +DECISION_ORDER = ( + "manifest_integrity", "technical_validity", "temporal_validity", + "identity_coverage", "membership_coverage", "corporate_action_coverage", + "delisting_coverage", "source_rights_eligibility", + "reproduction_ready", "leakage_safe", +) +``` + +Update `validate_point_in_time_universe` after identity/membership composition +with these exact calls: + +```python +decisions["manifest_integrity"] = Decision("manifest_integrity", "passed", ()) +temporal, cutoff_leakage, temporal_excluded = _temporal_decision(parsed) +decisions[temporal.area] = temporal +excluded.extend(temporal_excluded) +action, delisting, event_excluded = _event_decisions(package.manifest, parsed) +decisions[action.area] = action +decisions[delisting.area] = delisting +excluded.extend(event_excluded) +registry = load_source_rights_registry(package.registry_path) +rights = _rights_decision(package.manifest, parsed, registry) +decisions[rights.area] = rights +reproduction = _reproduction_decision(package.manifest, digests) +decisions[reproduction.area] = reproduction +leakage = _partition_decision( + package.manifest, parsed.evaluations, cutoff_leakage, +) +decisions[leakage.area] = leakage +ordered_decisions = MappingProxyType({ + name: decisions[name] for name in DECISION_ORDER +}) +analysis_eligible = _final_eligibility( + ordered_decisions, digests, package.manifest.declared_universes, +) +``` + +Pass `ordered_decisions` and `analysis_eligible` into the final packet. A +missing decision key is an implementation error and must fail tests rather +than be defaulted to passed. + +- [ ] **Step 5: Add empty, all-excluded, missing-benchmark, and missing-research-universe tests** + +```python +@pytest.mark.parametrize("mutation,reason", [ + ("no_evaluations", "membership_no_evaluation"), + ("benchmark_only", "membership_research_universe_missing"), + ("research_only", "membership_benchmark_missing"), + ("all_excluded", "membership_no_eligible_members"), +]) +def test_empty_or_one_sided_packages_fail_closed(tmp_path, mutation, reason): + from src.point_in_time_universe import validate_point_in_time_universe + manifest, registry = build_valid_package(tmp_path) + mutate_package_for_empty_case(manifest, mutation) + packet = validate_point_in_time_universe(manifest, registry) + assert packet.analysis_eligible is False + assert reason in {code for decision in packet.decisions.values() for code in decision.reason_codes} +``` + +Add these exact test helpers above the parametrized test: + +```python +def _replace_contract_rows(manifest, contract, rows): + raw = json.loads(manifest.read_text()) + entry = next(item for item in raw["files"] if item["contract"] == contract) + path = manifest.parent / entry["path"] + with path.open(encoding="utf-8", newline="") as handle: + fieldnames = next(csv.reader(handle)) + with path.open("w", encoding="utf-8", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=fieldnames) + writer.writeheader() + writer.writerows(rows) + entry["sha256"] = __import__("hashlib").sha256(path.read_bytes()).hexdigest() + entry["row_count"] = len(rows) + manifest.write_text(json.dumps(raw), encoding="utf-8") + + +def _read_contract_rows(manifest, contract): + raw = json.loads(manifest.read_text()) + entry = next(item for item in raw["files"] if item["contract"] == contract) + with (manifest.parent / entry["path"]).open(encoding="utf-8", newline="") as handle: + return list(csv.DictReader(handle)) + + +def mutate_package_for_empty_case(manifest, mutation): + memberships = _read_contract_rows(manifest, "membership") + evaluations = _read_contract_rows(manifest, "evaluations") + raw = json.loads(manifest.read_text()) + if mutation == "no_evaluations": + _replace_contract_rows(manifest, "evaluations", []) + return + if mutation == "benchmark_only": + memberships = [row for row in memberships if row["universe_kind"] == "benchmark"] + evaluations = [row for row in evaluations if row["universe_id"] == "bench-1"] + raw["declared_universes"] = [ + item for item in raw["declared_universes"] + if item["universe_kind"] == "benchmark" + ] + elif mutation == "research_only": + memberships = [row for row in memberships if row["universe_kind"] == "research_universe"] + evaluations = [row for row in evaluations if row["universe_id"] == "research-1"] + raw["declared_universes"] = [ + item for item in raw["declared_universes"] + if item["universe_kind"] == "research_universe" + ] + elif mutation == "all_excluded": + for row in memberships: + row["membership_state"] = "excluded" + manifest.write_text(json.dumps(raw), encoding="utf-8") + _replace_contract_rows(manifest, "membership", memberships) + _replace_contract_rows(manifest, "evaluations", evaluations) +``` + +- [ ] **Step 6: Run focused tests and commit** + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests/test_point_in_time_universe.py tests/test_point_in_time_universe_lineage.py -q +git diff --check +git add -- src/point_in_time_universe.py tests/test_point_in_time_universe.py +make staged-hygiene-check +git commit -m "Add leakage-safe universe reproduction" +``` + +Expected: all composed validator and lineage tests pass. + +--- + +### Task 6: Read-Only CLI and Make Interfaces + +**Files:** +- Create: `tests/test_point_in_time_universe_cli.py` +- Modify: `src/point_in_time_universe.py` +- Modify: `Makefile` + +**Interfaces:** +- Produces `render_status(packet) -> str` and `render_preview(packet, top_n=20) -> str`. +- Produces `main(argv: list[str] | None = None) -> int`. +- Adds `make point-in-time-universe-status MANIFEST= [REGISTRY=]`. +- Adds `make point-in-time-universe-preview MANIFEST= [REGISTRY=] [TOP_N=20]`. + +- [ ] **Step 1: Write failing CLI and Make tests** + +```python +# tests/test_point_in_time_universe_cli.py +from pathlib import Path +import subprocess +import sys + +from tests.point_in_time_universe_fixture import build_valid_package + + +def _bytes(root: Path) -> dict[str, bytes]: + return {p.relative_to(root).as_posix(): p.read_bytes() for p in root.rglob("*") if p.is_file()} + + +def test_status_and_preview_are_read_only_and_truthful(tmp_path): + manifest, registry = build_valid_package(tmp_path) + before = _bytes(tmp_path) + for mode in ("status", "preview"): + result = subprocess.run( + [sys.executable, "-m", "src.point_in_time_universe", mode, + "--manifest", str(manifest), "--registry", str(registry), "--top-n", "5"], + capture_output=True, text=True, check=False, + ) + assert result.returncode == 0 + assert "Research-only" in result.stdout + assert "does not activate readiness, backtesting, calibration, or probability" in result.stdout + assert "analysis_eligible:" in result.stdout + assert _bytes(tmp_path) == before + + +def test_invalid_invocation_is_nonzero_without_traceback(): + result = subprocess.run( + [sys.executable, "-m", "src.point_in_time_universe", "status"], + capture_output=True, text=True, check=False, + ) + assert result.returncode == 2 + assert "MANIFEST is required" in result.stderr + assert "Traceback" not in result.stderr + + +def test_readable_blocked_package_returns_zero_with_blocked_states(tmp_path): + manifest, registry = build_valid_package(tmp_path) + registry.write_text(registry.read_text().replace("commercial_use: approved", "commercial_use: unverified"), encoding="utf-8") + raw = __import__("json").loads(manifest.read_text()) + raw["source_rights_registry_sha256"] = __import__("hashlib").sha256(registry.read_bytes()).hexdigest() + manifest.write_text(__import__("json").dumps(raw), encoding="utf-8") + result = subprocess.run( + [sys.executable, "-m", "src.point_in_time_universe", "status", + "--manifest", str(manifest), "--registry", str(registry)], + capture_output=True, text=True, check=False, + ) + assert result.returncode == 0 + assert "source_rights_eligibility: blocked" in result.stdout +``` + +- [ ] **Step 2: Run CLI tests and observe RED** + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests/test_point_in_time_universe_cli.py -q +``` + +Expected: CLI and Make assertions fail because rendering and targets are absent. + +- [ ] **Step 3: Implement rendering and CLI** + +```python +def render_status(packet: PointInTimeUniversePacket) -> str: + lines = [ + "Point-in-Time Universe Status", + "Read-only: validates one supplied immutable package; it does not fetch, write, apply, refresh, or rebuild data.", + "Research-only: this does not activate readiness, backtesting, calibration, or probability and is not investment advice.", + f"dataset_id: {packet.dataset_id}", + f"manifest_id: {packet.manifest_id}", + f"analysis_eligible: {str(packet.analysis_eligible).lower()}", + ] + lines.extend( + f"{name}: {packet.decisions[name].status}; reasons={','.join(packet.decisions[name].reason_codes) or 'none'}" + for name in DECISION_ORDER + ) + lines.append(f"boundary: {packet.boundary}") + return "\n".join(lines) + + +def render_preview(packet: PointInTimeUniversePacket, *, top_n: int = 20) -> str: + lines = [render_status(packet), "", "Membership reproduction:"] + lines.extend( + f"- {item.universe_id} @ {item.evaluation_at}: members={item.member_count}; sha256={item.sha256}" + for item in packet.membership_digests + ) + lines.append("Excluded sample:") + lines.extend( + f"- {item.contract}:{item.source_row}:{item.row_id}; reasons={','.join(item.reason_codes)}" + for item in packet.excluded[:top_n] + ) + return "\n".join(lines) + + +def main(argv: list[str] | None = None) -> int: + import argparse + parser = argparse.ArgumentParser(description="Validate one immutable point-in-time universe package.") + parser.add_argument("mode", choices=("status", "preview")) + parser.add_argument("--manifest") + parser.add_argument("--registry", default="config/source_rights.yml") + parser.add_argument("--top-n", type=int, default=20) + args = parser.parse_args(argv) + if not args.manifest: + parser.error("MANIFEST is required") + try: + packet = validate_point_in_time_universe(Path(args.manifest), Path(args.registry), top_n=args.top_n) + except ValueError as exc: + parser.error(str(exc)) + print(render_preview(packet, top_n=args.top_n) if args.mode == "preview" else render_status(packet)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) +``` + +- [ ] **Step 4: Add exact Make targets** + +```make +.PHONY: point-in-time-universe-status point-in-time-universe-preview + +point-in-time-universe-status: + @test -n "$(MANIFEST)" || (echo "MANIFEST is required" >&2; exit 2) + @PYTHONDONTWRITEBYTECODE=1 python3 -m src.point_in_time_universe status \ + --manifest "$(MANIFEST)" \ + --registry "$(or $(REGISTRY),config/source_rights.yml)" \ + --top-n "$(or $(TOP_N),20)" + +point-in-time-universe-preview: + @test -n "$(MANIFEST)" || (echo "MANIFEST is required" >&2; exit 2) + @PYTHONDONTWRITEBYTECODE=1 python3 -m src.point_in_time_universe preview \ + --manifest "$(MANIFEST)" \ + --registry "$(or $(REGISTRY),config/source_rights.yml)" \ + --top-n "$(or $(TOP_N),20)" +``` + +Add both names to the top-level `.PHONY` declaration and help output. Do not +add stage, apply, record, refresh, or output parameters. + +- [ ] **Step 5: Add Make no-write test** + +```python +def test_make_status_and_preview_use_exact_read_only_contract(tmp_path): + manifest, registry = build_valid_package(tmp_path) + before = _bytes(tmp_path) + for target in ("point-in-time-universe-status", "point-in-time-universe-preview"): + result = subprocess.run( + ["make", "--no-print-directory", target, f"MANIFEST={manifest}", + f"REGISTRY={registry}", "TOP_N=5"], + capture_output=True, text=True, check=False, + ) + assert result.returncode == 0 + assert "Read-only" in result.stdout + assert _bytes(tmp_path) == before +``` + +- [ ] **Step 6: Run focused tests and commit** + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest \ + tests/test_point_in_time_universe_cli.py \ + tests/test_point_in_time_universe.py \ + tests/test_makefile_test_targets.py -q +git diff --check +git add -- Makefile src/point_in_time_universe.py tests/test_point_in_time_universe_cli.py +make staged-hygiene-check +git commit -m "Add read-only universe validation commands" +``` + +Expected: CLI, Make, composed validator, and existing Make target tests pass. + +--- + +### Task 7: Full Acceptance Matrix and No-Write Proof + +**Files:** +- Modify: `tests/test_point_in_time_universe_manifest.py` +- Modify: `tests/test_point_in_time_universe_contracts.py` +- Modify: `tests/test_point_in_time_universe_lineage.py` +- Modify: `tests/test_point_in_time_universe.py` +- Modify: `tests/test_point_in_time_universe_cli.py` + +**Interfaces:** +- No new production interface. +- Completes every test group named in the approved specification. + +- [ ] **Step 1: Add the complete stable reason-code matrix** + +```python +EXPECTED_REASON_PREFIXES = { + "manifest_", "schema_", "lineage_", "identity_", "membership_", + "corporate_action_", "delisting_", "source_rights_", "cutoff_", + "leakage_", "partition_", "reproduction_", +} + + +def test_every_exclusion_uses_an_approved_stable_reason_family(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + manifest, registry = build_valid_package(tmp_path) + _rewrite_csv_and_manifest(manifest, "membership", lambda rows: rows[0].update(retrieved_at="2022-01-01T00:00:00Z")) + packet = validate_point_in_time_universe(manifest, registry) + for item in packet.excluded: + for code in item.reason_codes: + assert any(code.startswith(prefix) for prefix in EXPECTED_REASON_PREFIXES) +``` + +- [ ] **Step 2: Add the whole-root no-write test** + +```python +def test_library_cli_and_make_leave_entire_root_byte_identical(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + manifest, registry = build_valid_package(tmp_path) + before = _bytes(tmp_path) + validate_point_in_time_universe(manifest, registry) + subprocess.run( + [sys.executable, "-m", "src.point_in_time_universe", "preview", + "--manifest", str(manifest), "--registry", str(registry)], + check=True, capture_output=True, text=True, + ) + subprocess.run( + ["make", "--no-print-directory", "point-in-time-universe-preview", + f"MANIFEST={manifest}", f"REGISTRY={registry}"], + check=True, capture_output=True, text=True, + ) + assert _bytes(tmp_path) == before +``` + +- [ ] **Step 3: Add explicit no-current-universe dependency test** + +```python +def test_validator_result_is_unchanged_when_current_universe_files_change(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + manifest, registry = build_valid_package(tmp_path) + first = validate_point_in_time_universe(manifest, registry) + data = tmp_path / "data" + data.mkdir() + (data / "universe.csv").write_text("ticker\nZZZ\n", encoding="utf-8") + (data / "universe_master.csv").write_text("ticker,is_active_listing\nAAA,false\n", encoding="utf-8") + second = validate_point_in_time_universe(manifest, registry) + assert first == second +``` + +- [ ] **Step 4: Add test-only and no-completion assertions** + +```python +def test_synthetic_package_never_claims_priority_four_completion(tmp_path): + from src.point_in_time_universe import render_status, validate_point_in_time_universe + manifest, registry = build_valid_package(tmp_path) + output = render_status(validate_point_in_time_universe(manifest, registry)) + assert "Priority 4 complete" not in output + assert "real permitted dataset" in output + assert "synthetic" in output.lower() +``` + +Update `render_status` boundary text so it explicitly says a synthetic or +technically valid package is local software evidence only and Priority 4 still +requires one independently reviewed permitted real dataset. + +- [ ] **Step 5: Run all focused tests and fix only proven gaps** + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest \ + tests/test_point_in_time_universe_manifest.py \ + tests/test_point_in_time_universe_contracts.py \ + tests/test_point_in_time_universe_lineage.py \ + tests/test_point_in_time_universe.py \ + tests/test_point_in_time_universe_cli.py \ + tests/test_commercial_source_rights.py \ + tests/test_makefile_test_targets.py -q +``` + +Expected: all focused tests pass. If a test fails, use +`superpowers:systematic-debugging`; do not weaken the assertion to match an +unsafe implementation. + +- [ ] **Step 6: Commit the acceptance hardening** + +```bash +git diff --check +git add -- src/point_in_time_universe.py tests/test_point_in_time_universe_manifest.py tests/test_point_in_time_universe_contracts.py tests/test_point_in_time_universe_lineage.py tests/test_point_in_time_universe.py tests/test_point_in_time_universe_cli.py +make staged-hygiene-check +git commit -m "Complete universe foundation acceptance matrix" +``` + +Expected: only Python product/test files are staged; generated artifacts are `0`. + +--- + +### Task 8: Documentation, Release Gates, PR, and Exact-Head CI + +**Files:** +- Modify: `ROADMAP.md` +- Modify: `docs/METHODOLOGY.md` +- Modify: `docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md` +- Modify: `tests/test_public_v1_release_docs.py` + +**Interfaces:** +- Records local implementation truth and exact external exit condition. +- Does not change public product claims or call Priority 4 complete. + +- [ ] **Step 1: Write failing documentation-contract test** + +```python +def test_priority_four_local_validator_is_documented_without_claiming_real_data_completion(): + roadmap = _read("ROADMAP.md") + methodology = _read("docs/METHODOLOGY.md") + prompt = _read("docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md") + for text in (roadmap, methodology, prompt): + assert "make point-in-time-universe-status MANIFEST=" in text + assert "make point-in-time-universe-preview MANIFEST= TOP_N=20" in text + assert "membership_count_and_sha256_at_cutoff_v1" in text + assert "Synthetic fixtures remain test-only" in text + assert "one bounded permitted real dataset" in text + assert "Priority 4 is complete" not in roadmap +``` + +- [ ] **Step 2: Run the documentation test and observe RED** + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests/test_public_v1_release_docs.py::test_priority_four_local_validator_is_documented_without_claiming_real_data_completion -q +``` + +Expected: fails because implementation evidence is not yet recorded in all three documents. + +- [ ] **Step 3: Update documentation with exact boundaries** + +Add these facts to all required documents: + +```text +Implemented locally: read-only immutable package status/preview with independent +manifest, technical, temporal, identity, membership, corporate-action, +delisting, source-rights, reproduction, and leakage states. + +Reproduction contract: membership_count_and_sha256_at_cutoff_v1. + +Synthetic fixtures remain test-only. Priority 4 remains incomplete until one +bounded permitted real dataset reproduces the independently reviewed expected +membership count and digest and passes rights, identity, corporate-action, +delisting, survivorship, cutoff, and leakage gates. +``` + +Document only the two read-only commands. Do not document an apply, record, +refresh, readiness, or broad-source command. + +- [ ] **Step 4: Run focused and full verification** + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest \ + tests/test_point_in_time_universe_manifest.py \ + tests/test_point_in_time_universe_contracts.py \ + tests/test_point_in_time_universe_lineage.py \ + tests/test_point_in_time_universe.py \ + tests/test_point_in_time_universe_cli.py \ + tests/test_public_v1_release_docs.py -q +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests -q +make dashboard-smoke +make research-dashboard-render-smoke +make public-wording-check +make public-check +make commercial-beta-release-check +make pilot-readiness-check TOP_N=10 +make diff-hygiene-summary +git diff --check +``` + +Expected: + +- focused tests pass; +- full suite passes; +- dashboard and all six Personal Research routes pass; +- public and commercial-beta gates pass; +- pilot is `pilot-ready with manual gates` after commit; +- diff hygiene lists only intentional product files plus the 18 excluded generated files. + +- [ ] **Step 5: Stage exact files and verify hygiene** + +```bash +git add -- ROADMAP.md docs/METHODOLOGY.md docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md tests/test_public_v1_release_docs.py +make staged-hygiene-check +git diff --cached --check +``` + +Expected: staged generated CSV/JSON/report churn is `0`. + +- [ ] **Step 6: Commit documentation** + +```bash +git commit -m "Document point-in-time universe validator" +make pilot-readiness-check TOP_N=10 +make diff-hygiene-summary +git diff --check +``` + +Expected: no product candidate remains; 18 generated files remain local and unstaged. + +- [ ] **Step 7: Push only the approved branch** + +```bash +git status --short --branch +git rev-list --left-right --count origin/codex/personal-research-mode-mvp...HEAD +git push origin codex/personal-research-mode-mvp +``` + +Expected: push succeeds only to `codex/personal-research-mode-mvp`. + +- [ ] **Step 8: Update draft PR #113 and require exact-head CI** + +Use the PR comment to report: + +```text +Priority 4 local validator implemented: +- immutable manifest and file integrity +- stable identity and timestamped membership +- corporate-action and delisting policy +- exact-source rights and registered field scope +- cutoff/leakage and deterministic membership digest +- status/preview only; no write/apply/readiness activation +- synthetic fixtures test-only; real dataset gate remains open +- focused/full/release/hygiene evidence +``` + +Then: + +```bash +gh pr view 113 --repo YuzeJ21/Stock-Analysis --json state,isDraft,mergeable,headRefOid,statusCheckRollup +gh run list --repo YuzeJ21/Stock-Analysis --branch codex/personal-research-mode-mvp --event pull_request --limit 5 +gh run watch --repo YuzeJ21/Stock-Analysis --exit-status +git fetch origin codex/personal-research-mode-mvp +git rev-list --left-right --count origin/codex/personal-research-mode-mvp...HEAD +``` + +Expected: + +- PR #113 remains open and draft; +- exact-head `Commercial Research Beta / local-engineering-gate` succeeds; +- branch divergence is `0 0`; +- no merge or deployment occurs. + +--- + +## Plan Completion Audit + +Before calling the local implementation slice complete, verify each approved +spec requirement against direct evidence: + +| Requirement | Direct evidence | +| --- | --- | +| Immutable package identity | Manifest hash, row-count, registry-digest, path, and schema tests | +| Stable security identity | Ticker-change, ticker-reuse, overlap, and missing-identity tests | +| Historical membership | Complete-snapshot/event-history, effective interval, declared universe, and kind tests | +| Revision lineage | Duplicate, parent, scope, root, fork, cycle, order, and cutoff tests | +| Corporate actions | Split ratio, successor, policy, suspension/reactivation tests | +| Delistings/survivorship | Explicit delisting state and historical-member retention tests | +| Source rights | Approved/unverified/unknown/missing-scope independent-state tests | +| Cutoff/leakage | Publication, retrieval, observation, event, evaluation, and later-revision tests | +| Reproduction | Repeated count/digest/state/exclusion equality tests | +| Raw/normalized/excluded/eligible | Packet counts, exclusions, reason families, and eligibility tests | +| Read-only operation | Whole-root byte snapshots across library, CLI, and Make | +| No current fallback | Result unchanged after current universe files are created or changed | +| Synthetic test-only boundary | Rendered boundary and documentation tests | +| Release safety | Focused/full/release/hygiene/exact-head CI | +| Real-data exit gate remains open | ROADMAP, methodology, prompt, PR, and rendered boundary | + +Do not mark Priority 4 complete unless one independently reviewed permitted +real dataset later passes every applicable gate and reproduces the expected +membership count and digest. Completing this plan proves only the local +validator implementation. diff --git a/docs/superpowers/plans/2026-07-26-provider-neutral-workspace-authorization.md b/docs/superpowers/plans/2026-07-26-provider-neutral-workspace-authorization.md new file mode 100644 index 00000000..3ef6399f --- /dev/null +++ b/docs/superpowers/plans/2026-07-26-provider-neutral-workspace-authorization.md @@ -0,0 +1,1154 @@ +# Provider-Neutral Workspace Authorization 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:** Build one pure, provider-neutral, deny-by-default workspace authorization decision that enforces principal/workspace isolation, least privilege, append-only research records, and privacy-safe audit obligations. + +**Architecture:** Add a single standard-library policy module whose frozen domain types form the supported trust boundary and whose evaluator returns an immutable allow/deny decision for every expected request. Keep the first slice isolated from Streamlit, ledgers, readiness, providers, persistence, and hosted infrastructure; update only truthful architecture and roadmap evidence after the policy tests pass. + +**Tech Stack:** Python 3, standard-library `dataclasses`, `enum`, and `unicodedata`; pytest; Markdown contract tests; existing Make release and hygiene gates. + +## Global Constraints + +- The authoritative design is `docs/superpowers/specs/2026-07-26-provider-neutral-workspace-authorization-design.md`. +- The evaluator must be pure, provider-neutral, deterministic, and deny by default. +- The module must depend only on the Python standard library. +- Identifiers must be strings of 1–128 Unicode scalar values with no leading/trailing whitespace, C0/C1 controls, Unicode line/paragraph separators, or surrogate code points. +- `authenticated` and `active` must be exact booleans; role, action, and resource must be members of their declared enums. +- Expected authorization failures return immutable denied decisions and do not raise. +- Research records are append-only: `update` and `delete` are denied for every role. +- Every decision carries a privacy-safe audit obligation; the evaluator never persists an audit event. +- No dashboard, ledger, readiness, source-rights, provider, file, environment, network, or generated-data integration is permitted in this slice. +- Do not claim runtime authentication, hosted isolation, persistence, audit storage, retention, monitoring, rollback, incident response, operated capacity, or market validation. +- Do not run readiness rebuilds, broad refreshes, or generated CSV/JSON/report commands. +- Keep the 18 existing generated CSV/report changes unstaged and preserve their diff fingerprint `a2c2f428b489dbb291dd54fd8a6e1e7f4ad9481414320ca248c54be89f4062b9`. +- Stage or synchronize exact intentional paths only; never use `git add -A`. +- Push only to `codex/personal-research-mode-mvp`; keep PR #113 open and draft; do not merge or deploy. + +## File Map + +- Create `src/hosted_access_control.py`: frozen authorization domain types, structural validation, static policy matrix, audit-obligation factory, and pure evaluator. +- Create `tests/test_hosted_access_control.py`: exhaustive policy, denial-order, malformed-input, Unicode, immutability, audit, privacy, and side-effect tests. +- Modify `docs/PRIVATE_BETA_ARCHITECTURE.md`: describe the local policy contract without promoting hosted readiness. +- Modify `ROADMAP.md`: record the completed local Priority 6 slice and preserve the external hosted exit gate. +- Modify `docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md`: add the exact local lineage and next safe lane while preserving external blockers. +- Modify `tests/test_public_v1_release_docs.py`: lock the provider-neutral, deny-by-default, append-only, audit, and non-claim language. + +--- + +### Task 1: Define The Frozen Trust-Boundary Types And Structural Validation + +**Files:** +- Create: `src/hosted_access_control.py` +- Create: `tests/test_hosted_access_control.py` + +**Interfaces:** +- Consumes: no project modules; Python standard library only. +- Produces: `WorkspaceRole`, `WorkspaceAction`, `WorkspaceResource`, `PrincipalContext`, `WorkspaceMembership`, `WorkspaceAccessRequest`, `AuditObligation`, `WorkspaceAccessDecision`, and `evaluate_workspace_access(principal: object, membership: object, request: object) -> WorkspaceAccessDecision`. + +- [ ] **Step 1: Write failing tests for malformed trust-boundary inputs** + +Create `tests/test_hosted_access_control.py` with the imports, valid fixture factory, and structural-denial cases: + +```python +from dataclasses import FrozenInstanceError + +import pytest + +from src.hosted_access_control import ( + AuditObligation, + PrincipalContext, + WorkspaceAccessDecision, + WorkspaceAccessRequest, + WorkspaceAction, + WorkspaceMembership, + WorkspaceResource, + WorkspaceRole, + evaluate_workspace_access, +) + + +def _valid_inputs( + *, + principal_id: object = "principal-1", + membership_principal_id: object = "principal-1", + membership_workspace_id: object = "workspace-1", + request_workspace_id: object = "workspace-1", + request_id: object = "request-1", + authenticated: object = True, + active: object = True, + role: object = WorkspaceRole.EDITOR, + action: object = WorkspaceAction.READ, + resource: object = WorkspaceResource.RESEARCH_RECORD, +) -> tuple[PrincipalContext, WorkspaceMembership, WorkspaceAccessRequest]: + return ( + PrincipalContext( + principal_id=principal_id, + authenticated=authenticated, + ), + WorkspaceMembership( + principal_id=membership_principal_id, + workspace_id=membership_workspace_id, + role=role, + active=active, + ), + WorkspaceAccessRequest( + request_id=request_id, + workspace_id=request_workspace_id, + action=action, + resource=resource, + ), + ) + + +@pytest.mark.parametrize( + ("principal", "membership", "request"), + [ + (object(),) + _valid_inputs()[1:], + (_valid_inputs()[0], object(), _valid_inputs()[2]), + _valid_inputs()[:2] + (object(),), + ], +) +def test_malformed_boundary_objects_fail_closed(principal, membership, request): + decision = evaluate_workspace_access(principal, membership, request) + + assert decision.allowed is False + assert decision.reason_code == "invalid_request" + + +@pytest.mark.parametrize( + ("principal", "membership", "request"), + [ + (object.__new__(PrincipalContext),) + _valid_inputs()[1:], + ( + _valid_inputs()[0], + object.__new__(WorkspaceMembership), + _valid_inputs()[2], + ), + _valid_inputs()[:2] + (object.__new__(WorkspaceAccessRequest),), + ], +) +def test_incomplete_exact_type_objects_fail_closed( + principal, membership, request +): + decision = evaluate_workspace_access(principal, membership, request) + + assert decision.allowed is False + assert decision.reason_code == "invalid_request" + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("principal_id", None), + ("principal_id", ""), + ("principal_id", " leading"), + ("principal_id", "trailing "), + ("principal_id", "line\nbreak"), + ("principal_id", "c1\u0085control"), + ("principal_id", "line\u2028separator"), + ("principal_id", "paragraph\u2029separator"), + ("principal_id", "\ud800"), + ("principal_id", "x" * 129), + ("membership_principal_id", 7), + ("membership_workspace_id", "\x00"), + ("request_workspace_id", "\tworkspace"), + ("request_id", False), + ("authenticated", 1), + ("active", "true"), + ("role", "editor"), + ("action", "read"), + ("resource", "research_record"), + ], +) +def test_malformed_fields_fail_closed(field, value): + decision = evaluate_workspace_access(*_valid_inputs(**{field: value})) + + assert decision == WorkspaceAccessDecision( + allowed=False, + reason_code="invalid_request", + audit=AuditObligation( + event_type="workspace_access_decision", + outcome="denied", + reason_code="invalid_request", + required_fields=( + "request_id", + "principal_id", + "workspace_id", + "resource", + "action", + "outcome", + "reason_code", + "occurred_at", + ), + ), + ) + + +def test_frozen_inputs_and_decisions_cannot_be_mutated(): + principal, membership, request = _valid_inputs() + decision = evaluate_workspace_access(principal, membership, request) + + with pytest.raises(FrozenInstanceError): + principal.principal_id = "other" + with pytest.raises(FrozenInstanceError): + membership.role = WorkspaceRole.OWNER + with pytest.raises(FrozenInstanceError): + request.workspace_id = "other" + with pytest.raises(FrozenInstanceError): + decision.allowed = True +``` + +- [ ] **Step 2: Run the new structural tests and confirm the missing-module failure** + +Run: + +```bash +python3 -m pytest tests/test_hosted_access_control.py -q +``` + +Expected: test collection fails with `ModuleNotFoundError: No module named 'src.hosted_access_control'`. + +- [ ] **Step 3: Implement enums, frozen dataclasses, identifier validation, and invalid-request decisions** + +Create `src/hosted_access_control.py`: + +```python +"""Provider-neutral, deny-by-default workspace authorization policy.""" + +from __future__ import annotations + +import unicodedata +from dataclasses import dataclass +from enum import Enum + + +class WorkspaceRole(str, Enum): + VIEWER = "viewer" + EDITOR = "editor" + OWNER = "owner" + + +class WorkspaceAction(str, Enum): + READ = "read" + APPEND = "append" + UPDATE = "update" + DELETE = "delete" + EXPORT = "export" + MANAGE = "manage" + + +class WorkspaceResource(str, Enum): + RESEARCH_RECORD = "research_record" + SAVED_WORKSPACE_STATE = "saved_workspace_state" + WORKSPACE_MEMBERSHIP = "workspace_membership" + WORKSPACE_AUDIT = "workspace_audit" + + +@dataclass(frozen=True) +class PrincipalContext: + principal_id: object + authenticated: object + + +@dataclass(frozen=True) +class WorkspaceMembership: + principal_id: object + workspace_id: object + role: object + active: object + + +@dataclass(frozen=True) +class WorkspaceAccessRequest: + request_id: object + workspace_id: object + action: object + resource: object + + +@dataclass(frozen=True) +class AuditObligation: + event_type: str + outcome: str + reason_code: str + required_fields: tuple[str, ...] + + +@dataclass(frozen=True) +class WorkspaceAccessDecision: + allowed: bool + reason_code: str + audit: AuditObligation + + +_AUDIT_FIELDS = ( + "request_id", + "principal_id", + "workspace_id", + "resource", + "action", + "outcome", + "reason_code", + "occurred_at", +) + + +def _valid_identifier(value: object) -> bool: + if not isinstance(value, str) or not 1 <= len(value) <= 128: + return False + if value != value.strip(): + return False + return all( + unicodedata.category(character) not in {"Cc", "Cs", "Zl", "Zp"} + for character in value + ) + + +def _decision(*, allowed: bool, reason_code: str) -> WorkspaceAccessDecision: + outcome = "allowed" if allowed else "denied" + return WorkspaceAccessDecision( + allowed=allowed, + reason_code=reason_code, + audit=AuditObligation( + event_type="workspace_access_decision", + outcome=outcome, + reason_code=reason_code, + required_fields=_AUDIT_FIELDS, + ), + ) + + +_MISSING_FIELD = object() + + +def _structurally_valid( + principal: object, + membership: object, + request: object, +) -> bool: + if ( + type(principal) is not PrincipalContext + or type(membership) is not WorkspaceMembership + or type(request) is not WorkspaceAccessRequest + ): + return False + + principal_id = getattr(principal, "principal_id", _MISSING_FIELD) + authenticated = getattr(principal, "authenticated", _MISSING_FIELD) + membership_principal_id = getattr( + membership, + "principal_id", + _MISSING_FIELD, + ) + membership_workspace_id = getattr( + membership, + "workspace_id", + _MISSING_FIELD, + ) + role = getattr(membership, "role", _MISSING_FIELD) + active = getattr(membership, "active", _MISSING_FIELD) + request_id = getattr(request, "request_id", _MISSING_FIELD) + request_workspace_id = getattr(request, "workspace_id", _MISSING_FIELD) + action = getattr(request, "action", _MISSING_FIELD) + resource = getattr(request, "resource", _MISSING_FIELD) + + return ( + _valid_identifier(principal_id) + and type(authenticated) is bool + and _valid_identifier(membership_principal_id) + and _valid_identifier(membership_workspace_id) + and isinstance(role, WorkspaceRole) + and type(active) is bool + and _valid_identifier(request_id) + and _valid_identifier(request_workspace_id) + and isinstance(action, WorkspaceAction) + and isinstance(resource, WorkspaceResource) + ) + + +def evaluate_workspace_access( + principal: object, + membership: object, + request: object, +) -> WorkspaceAccessDecision: + """Return one immutable policy decision without side effects.""" + if not _structurally_valid(principal, membership, request): + return _decision(allowed=False, reason_code="invalid_request") + return _decision(allowed=False, reason_code="role_action_denied") +``` + +- [ ] **Step 4: Run the structural tests** + +Run: + +```bash +python3 -m pytest tests/test_hosted_access_control.py -q +``` + +Expected: all structural and immutability tests pass. + +- [ ] **Step 5: Run a focused style and whitespace check** + +Run: + +```bash +python3 -m ruff check src/hosted_access_control.py tests/test_hosted_access_control.py +git diff --check -- src/hosted_access_control.py tests/test_hosted_access_control.py +``` + +Expected: both commands exit 0. + +- [ ] **Step 6: Synchronize the exact Task 1 paths as one coherent commit** + +Because the original worktree index is read-only and stale, do not use it to recommit the already synchronized Priority 4 package. Create a controlled writable clone from the current remote head or use the GitHub Git Data API, include only: + +```text +src/hosted_access_control.py +tests/test_hosted_access_control.py +``` + +Commit message: + +```text +Add workspace authorization trust boundary +``` + +Verify the remote compare shows exactly those two paths and zero generated artifacts. + +--- + +### Task 2: Implement Deterministic Isolation, Least Privilege, And Audit Decisions + +**Files:** +- Modify: `src/hosted_access_control.py` +- Modify: `tests/test_hosted_access_control.py` + +**Interfaces:** +- Consumes: the Task 1 enums, frozen dataclasses, `_decision`, and `_structurally_valid`. +- Produces: the complete `evaluate_workspace_access(principal: object, membership: object, request: object) -> WorkspaceAccessDecision` policy. + +- [ ] **Step 1: Add failing tests for the exact allow matrix** + +Append: + +```python +_ALLOWED_CASES = ( + (WorkspaceRole.VIEWER, WorkspaceResource.RESEARCH_RECORD, WorkspaceAction.READ), + (WorkspaceRole.EDITOR, WorkspaceResource.RESEARCH_RECORD, WorkspaceAction.READ), + (WorkspaceRole.EDITOR, WorkspaceResource.RESEARCH_RECORD, WorkspaceAction.APPEND), + (WorkspaceRole.OWNER, WorkspaceResource.RESEARCH_RECORD, WorkspaceAction.READ), + (WorkspaceRole.OWNER, WorkspaceResource.RESEARCH_RECORD, WorkspaceAction.APPEND), + (WorkspaceRole.OWNER, WorkspaceResource.RESEARCH_RECORD, WorkspaceAction.EXPORT), + (WorkspaceRole.VIEWER, WorkspaceResource.SAVED_WORKSPACE_STATE, WorkspaceAction.READ), + (WorkspaceRole.EDITOR, WorkspaceResource.SAVED_WORKSPACE_STATE, WorkspaceAction.READ), + (WorkspaceRole.EDITOR, WorkspaceResource.SAVED_WORKSPACE_STATE, WorkspaceAction.APPEND), + (WorkspaceRole.EDITOR, WorkspaceResource.SAVED_WORKSPACE_STATE, WorkspaceAction.UPDATE), + (WorkspaceRole.OWNER, WorkspaceResource.SAVED_WORKSPACE_STATE, WorkspaceAction.READ), + (WorkspaceRole.OWNER, WorkspaceResource.SAVED_WORKSPACE_STATE, WorkspaceAction.APPEND), + (WorkspaceRole.OWNER, WorkspaceResource.SAVED_WORKSPACE_STATE, WorkspaceAction.UPDATE), + (WorkspaceRole.OWNER, WorkspaceResource.SAVED_WORKSPACE_STATE, WorkspaceAction.DELETE), + (WorkspaceRole.OWNER, WorkspaceResource.SAVED_WORKSPACE_STATE, WorkspaceAction.EXPORT), + (WorkspaceRole.OWNER, WorkspaceResource.WORKSPACE_MEMBERSHIP, WorkspaceAction.READ), + (WorkspaceRole.OWNER, WorkspaceResource.WORKSPACE_MEMBERSHIP, WorkspaceAction.MANAGE), + (WorkspaceRole.OWNER, WorkspaceResource.WORKSPACE_AUDIT, WorkspaceAction.READ), + (WorkspaceRole.OWNER, WorkspaceResource.WORKSPACE_AUDIT, WorkspaceAction.EXPORT), +) + + +@pytest.mark.parametrize(("role", "resource", "action"), _ALLOWED_CASES) +def test_exact_policy_matrix_allows_only_declared_entries(role, resource, action): + decision = evaluate_workspace_access( + *_valid_inputs(role=role, resource=resource, action=action) + ) + + assert decision.allowed is True + assert decision.reason_code == "allowed" + assert decision.audit.outcome == "allowed" + assert decision.audit.reason_code == "allowed" +``` + +- [ ] **Step 2: Add failing exhaustive deny-matrix and append-only tests** + +Append: + +```python +_ALLOWED_SET = set(_ALLOWED_CASES) +_DENIED_CASES = tuple( + (role, resource, action) + for role in WorkspaceRole + for resource in WorkspaceResource + for action in WorkspaceAction + if (role, resource, action) not in _ALLOWED_SET +) + + +@pytest.mark.parametrize(("role", "resource", "action"), _DENIED_CASES) +def test_every_absent_policy_entry_is_denied(role, resource, action): + decision = evaluate_workspace_access( + *_valid_inputs(role=role, resource=resource, action=action) + ) + + expected = ( + "append_only_mutation_denied" + if resource is WorkspaceResource.RESEARCH_RECORD + and action in {WorkspaceAction.UPDATE, WorkspaceAction.DELETE} + else "role_action_denied" + ) + assert decision.allowed is False + assert decision.reason_code == expected + assert decision.audit.outcome == "denied" + assert decision.audit.reason_code == expected + + +@pytest.mark.parametrize("role", tuple(WorkspaceRole)) +@pytest.mark.parametrize( + "action", + (WorkspaceAction.UPDATE, WorkspaceAction.DELETE), +) +def test_no_role_can_mutate_append_only_research_records(role, action): + decision = evaluate_workspace_access( + *_valid_inputs( + role=role, + resource=WorkspaceResource.RESEARCH_RECORD, + action=action, + ) + ) + + assert decision.allowed is False + assert decision.reason_code == "append_only_mutation_denied" +``` + +- [ ] **Step 3: Add failing tests for deterministic denial precedence** + +Append: + +```python +@pytest.mark.parametrize( + ("changes", "reason_code"), + [ + ({"authenticated": False}, "authentication_required"), + ({"membership_principal_id": "principal-2"}, "principal_mismatch"), + ({"active": False}, "membership_inactive"), + ({"request_workspace_id": "workspace-2"}, "workspace_mismatch"), + ], +) +def test_isolation_denials_follow_declared_order(changes, reason_code): + decision = evaluate_workspace_access(*_valid_inputs(**changes)) + + assert decision.allowed is False + assert decision.reason_code == reason_code + + +def test_earlier_denial_cannot_be_overridden_by_later_allow_rule(): + decision = evaluate_workspace_access( + *_valid_inputs( + authenticated=False, + membership_principal_id="principal-2", + active=False, + request_workspace_id="workspace-2", + role=WorkspaceRole.OWNER, + resource=WorkspaceResource.SAVED_WORKSPACE_STATE, + action=WorkspaceAction.DELETE, + ) + ) + + assert decision.reason_code == "authentication_required" + + +def test_workspace_mismatch_precedes_append_only_policy_denial(): + decision = evaluate_workspace_access( + *_valid_inputs( + request_workspace_id="workspace-2", + role=WorkspaceRole.OWNER, + resource=WorkspaceResource.RESEARCH_RECORD, + action=WorkspaceAction.DELETE, + ) + ) + + assert decision.reason_code == "workspace_mismatch" +``` + +- [ ] **Step 4: Run the policy tests and confirm they fail against the Task 1 fallback** + +Run: + +```bash +python3 -m pytest tests/test_hosted_access_control.py -q +``` + +Expected: allow-matrix and ordered-denial assertions fail because the evaluator still returns `role_action_denied` for all structurally valid inputs. + +- [ ] **Step 5: Implement the static matrix and deterministic decision order** + +Insert above the evaluator: + +```python +_ALLOWED_ACTIONS = { + WorkspaceResource.RESEARCH_RECORD: { + WorkspaceRole.VIEWER: frozenset({WorkspaceAction.READ}), + WorkspaceRole.EDITOR: frozenset( + {WorkspaceAction.READ, WorkspaceAction.APPEND} + ), + WorkspaceRole.OWNER: frozenset( + { + WorkspaceAction.READ, + WorkspaceAction.APPEND, + WorkspaceAction.EXPORT, + } + ), + }, + WorkspaceResource.SAVED_WORKSPACE_STATE: { + WorkspaceRole.VIEWER: frozenset({WorkspaceAction.READ}), + WorkspaceRole.EDITOR: frozenset( + { + WorkspaceAction.READ, + WorkspaceAction.APPEND, + WorkspaceAction.UPDATE, + } + ), + WorkspaceRole.OWNER: frozenset( + { + WorkspaceAction.READ, + WorkspaceAction.APPEND, + WorkspaceAction.UPDATE, + WorkspaceAction.DELETE, + WorkspaceAction.EXPORT, + } + ), + }, + WorkspaceResource.WORKSPACE_MEMBERSHIP: { + WorkspaceRole.VIEWER: frozenset(), + WorkspaceRole.EDITOR: frozenset(), + WorkspaceRole.OWNER: frozenset( + {WorkspaceAction.READ, WorkspaceAction.MANAGE} + ), + }, + WorkspaceResource.WORKSPACE_AUDIT: { + WorkspaceRole.VIEWER: frozenset(), + WorkspaceRole.EDITOR: frozenset(), + WorkspaceRole.OWNER: frozenset( + {WorkspaceAction.READ, WorkspaceAction.EXPORT} + ), + }, +} +``` + +Replace the evaluator body with: + +```python + if not _structurally_valid(principal, membership, request): + return _decision(allowed=False, reason_code="invalid_request") + if principal.authenticated is not True: + return _decision( + allowed=False, + reason_code="authentication_required", + ) + if membership.principal_id != principal.principal_id: + return _decision(allowed=False, reason_code="principal_mismatch") + if membership.active is not True: + return _decision(allowed=False, reason_code="membership_inactive") + if request.workspace_id != membership.workspace_id: + return _decision(allowed=False, reason_code="workspace_mismatch") + if ( + request.resource is WorkspaceResource.RESEARCH_RECORD + and request.action in {WorkspaceAction.UPDATE, WorkspaceAction.DELETE} + ): + return _decision( + allowed=False, + reason_code="append_only_mutation_denied", + ) + allowed_actions = _ALLOWED_ACTIONS[request.resource][membership.role] + if request.action not in allowed_actions: + return _decision(allowed=False, reason_code="role_action_denied") + return _decision(allowed=True, reason_code="allowed") +``` + +- [ ] **Step 6: Run the complete focused authorization suite** + +Run: + +```bash +python3 -m pytest tests/test_hosted_access_control.py -q +``` + +Expected: all tests pass. + +- [ ] **Step 7: Add audit privacy, Unicode, determinism, and side-effect tests** + +Append: + +```python +def test_valid_unicode_scalar_identifiers_are_accepted_deterministically(): + inputs = _valid_inputs( + principal_id="研究员-😀", + membership_principal_id="研究员-😀", + membership_workspace_id="工作区-α", + request_workspace_id="工作区-α", + request_id="请求-1", + ) + + first = evaluate_workspace_access(*inputs) + second = evaluate_workspace_access(*inputs) + + assert first == second + assert first.allowed is True + + +@pytest.mark.parametrize( + "changes", + [ + {}, + {"authenticated": False}, + {"membership_principal_id": "principal-2"}, + {"active": False}, + {"request_workspace_id": "workspace-2"}, + {"action": WorkspaceAction.MANAGE}, + { + "resource": WorkspaceResource.RESEARCH_RECORD, + "action": WorkspaceAction.DELETE, + "role": WorkspaceRole.OWNER, + }, + ], +) +def test_every_decision_has_the_exact_privacy_safe_audit_obligation(changes): + decision = evaluate_workspace_access(*_valid_inputs(**changes)) + + assert decision.audit.event_type == "workspace_access_decision" + assert decision.audit.outcome == ("allowed" if decision.allowed else "denied") + assert decision.audit.reason_code == decision.reason_code + assert decision.audit.required_fields == ( + "request_id", + "principal_id", + "workspace_id", + "resource", + "action", + "outcome", + "reason_code", + "occurred_at", + ) + assert "principal-1" not in decision.reason_code + assert "workspace-1" not in decision.reason_code + + +def test_public_contract_contains_no_secret_or_research_content_fields(): + public_fields = { + *PrincipalContext.__dataclass_fields__, + *WorkspaceMembership.__dataclass_fields__, + *WorkspaceAccessRequest.__dataclass_fields__, + *AuditObligation.__dataclass_fields__, + *WorkspaceAccessDecision.__dataclass_fields__, + } + forbidden = { + "credential", + "token", + "cookie", + "api_key", + "session_secret", + "thesis", + "evidence", + "catalyst", + "research_outcome", + "forecast", + "probability", + "recommendation", + "position", + "transaction", + } + + assert public_fields.isdisjoint(forbidden) + + +def test_evaluation_does_not_mutate_inputs_or_write_files(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + principal, membership, request = _valid_inputs() + before = (principal, membership, request) + + evaluate_workspace_access(principal, membership, request) + + assert (principal, membership, request) == before + assert list(tmp_path.iterdir()) == [] +``` + +- [ ] **Step 8: Add an import-boundary test** + +Append: + +```python +def test_module_uses_only_the_approved_standard_library_imports(): + import ast + import inspect + + import src.hosted_access_control as module + + tree = ast.parse(inspect.getsource(module)) + imported_roots = { + alias.name.split(".", 1)[0] + for node in ast.walk(tree) + if isinstance(node, ast.Import) + for alias in node.names + } + imported_roots.update( + node.module.split(".", 1)[0] + for node in ast.walk(tree) + if isinstance(node, ast.ImportFrom) and node.module != "__future__" + ) + + assert imported_roots == {"dataclasses", "enum", "unicodedata"} +``` + +- [ ] **Step 9: Run focused authorization and existing private-beta tests** + +Run: + +```bash +python3 -m pytest \ + tests/test_hosted_access_control.py \ + tests/test_private_beta_readiness.py \ + -q +python3 -m ruff check src/hosted_access_control.py tests/test_hosted_access_control.py +git diff --check -- src/hosted_access_control.py tests/test_hosted_access_control.py +``` + +Expected: all tests and both static checks pass. Existing authentication, workspace, separation, audit, retention, entitlements, monitoring, health-check, incident, rollback, and owner-capacity states remain external or manually verified; none becomes hosted-ready. + +- [ ] **Step 10: Synchronize the exact Task 2 paths as one coherent commit** + +Include only: + +```text +src/hosted_access_control.py +tests/test_hosted_access_control.py +``` + +Commit message: + +```text +Enforce fail-closed workspace authorization +``` + +Verify the remote compare contains no dashboard, ledger, provider, readiness artifact, generated CSV/JSON/report, screenshot, or timing path. + +--- + +### Task 3: Reconcile Truthful Priority 6 Documentation And Regression Evidence + +**Files:** +- Modify: `docs/PRIVATE_BETA_ARCHITECTURE.md` +- Modify: `ROADMAP.md` +- Modify: `docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md` +- Modify: `tests/test_public_v1_release_docs.py` + +**Interfaces:** +- Consumes: the implemented `src.hosted_access_control.evaluate_workspace_access` contract and passing Task 2 tests. +- Produces: truthful local-evidence documentation that leaves all real hosted and operated states external. + +- [ ] **Step 1: Write the failing documentation regression test** + +Append to `tests/test_public_v1_release_docs.py`: + +```python +def test_provider_neutral_workspace_authorization_is_documented_without_hosted_claims(): + architecture = _read("docs/PRIVATE_BETA_ARCHITECTURE.md") + roadmap = _read("ROADMAP.md") + continuation = _read( + "docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md" + ) + + for document in (architecture, roadmap, continuation): + normalized = " ".join(document.split()) + assert "provider-neutral" in normalized + assert "deny-by-default" in normalized + assert "append-only" in normalized + assert "privacy-safe audit obligation" in normalized + assert "does not prove hosted authentication" in normalized + + assert "src.hosted_access_control.evaluate_workspace_access" in architecture + assert "actual hosted environment" in roadmap + assert "Do not create or change hosted accounts" in continuation +``` + +- [ ] **Step 2: Run the documentation test and confirm the missing-language failure** + +Run: + +```bash +python3 -m pytest \ + tests/test_public_v1_release_docs.py::test_provider_neutral_workspace_authorization_is_documented_without_hosted_claims \ + -q +``` + +Expected: FAIL because the three documents do not yet contain the exact local contract and non-claim language. + +- [ ] **Step 3: Add the local contract section to the private-beta architecture** + +In `docs/PRIVATE_BETA_ARCHITECTURE.md`, after `## Local Contract`, add: + +```markdown +### Provider-Neutral Authorization Policy + +`src.hosted_access_control.evaluate_workspace_access()` is a local, +provider-neutral, deny-by-default policy contract. It requires exact +authenticated-principal, active-membership, and workspace matches before an +explicit role/resource/action rule can allow a request. Thesis, evidence, +catalyst, and outcome research records remain append-only, and every allow or +deny result carries a privacy-safe audit obligation that a future approved +adapter must record. + +The evaluator performs no authentication, persistence, audit storage, +retention, monitoring, network, provider, dashboard, ledger, readiness, or +generated-artifact operation. This local contract does not prove hosted +authentication, private-workspace isolation in a deployed service, audit +storage, retention execution, monitoring, rollback, incident response, or +operated capacity. All such states remain external until directly verified in +the actual approved environment. +``` + +- [ ] **Step 4: Record the completed local slice and unchanged exit gate in ROADMAP** + +Under `### Priority 6 — Controlled hosted operating boundary`, insert after the current-lane paragraph: + +```markdown +**Implemented locally:** `src.hosted_access_control.evaluate_workspace_access` +now provides one pure, provider-neutral, deny-by-default policy decision. +Structural validation, authenticated-principal matching, active membership, +exact workspace matching, least-privilege role/resource/action rules, +append-only research-record protection, stable privacy-safe reasons, and a +privacy-safe audit obligation are independently tested. The module has no +dashboard, ledger, readiness, provider, persistence, environment, network, or +generated-artifact integration. This local contract does not prove hosted +authentication, deployed isolation, audit storage, retention, monitoring, +rollback, incident response, operated capacity, or market validation. +``` + +Retain the existing sentence: + +```markdown +**Exit gate:** the actual hosted environment directly proves every claimed control, including an observed rollback rehearsal and named owner. Local code, configuration, or a URL alone is insufficient. +``` + +- [ ] **Step 5: Reconcile the continuation contract with exact lineage and next lane** + +Under `Priority 6 — Controlled hosted operating boundary` in `docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md`, add: + +```markdown +- The first local slice implements + `src.hosted_access_control.evaluate_workspace_access`, a pure, + provider-neutral, deny-by-default contract for authenticated-principal, + active-membership, workspace, role/resource/action, append-only + research-record, stable reason, and privacy-safe audit-obligation decisions. + It does not prove hosted authentication, deployed isolation, persistence, + audit storage, retention, monitoring, rollback, incident response, operated + capacity, or market validation. +- Reverify the exact implementation commit and exact-head CI before relying on + the local contract. Do not create or change hosted accounts, choose a + provider, use credentials, deploy, or publish without explicit approval. +- After this local policy slice passes, the next provider-neutral executable + lane is a separately reviewed retention/deletion or append-only audit-event + interface design. Provider-specific integration remains blocked until the + exact identity, storage, logging, host, and operating environment are + explicitly approved. +``` + +During the synchronization step, replace “the exact implementation commit” with the actual remote SHA rather than a guessed or local stale SHA. + +- [ ] **Step 6: Run the focused documentation and private-beta regression suite** + +Run: + +```bash +python3 -m pytest \ + tests/test_public_v1_release_docs.py \ + tests/test_private_beta_readiness.py \ + tests/test_hosted_access_control.py \ + -q +git diff --check -- \ + docs/PRIVATE_BETA_ARCHITECTURE.md \ + ROADMAP.md \ + docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md \ + tests/test_public_v1_release_docs.py +``` + +Expected: all tests pass and whitespace is clean. + +- [ ] **Step 7: Synchronize the exact Task 3 documentation commit** + +Include only: + +```text +docs/PRIVATE_BETA_ARCHITECTURE.md +ROADMAP.md +docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md +tests/test_public_v1_release_docs.py +``` + +Commit message: + +```text +Document workspace authorization boundary +``` + +Verify the remote compare contains exactly those four files and zero generated artifacts. + +--- + +### Task 4: Run Full Release Verification And Update Draft PR Evidence + +**Files:** +- Verify only: all six Task 1–3 intentional paths plus this reviewed plan, + for seven paths total. +- Update externally: draft PR #113 description. +- Do not modify: generated CSV/JSON/report/sample-report/screenshot/timing artifacts. + +**Interfaces:** +- Consumes: the synchronized authorization module, tests, and documentation. +- Produces: current local and exact-head CI evidence for the isolated Priority 6 slice. + +- [ ] **Step 1: Run the focused authorization and documentation suites** + +Run: + +```bash +python3 -m pytest \ + tests/test_hosted_access_control.py \ + tests/test_private_beta_readiness.py \ + tests/test_public_v1_release_docs.py \ + -q +``` + +Expected: all focused tests pass. + +- [ ] **Step 2: Run the complete automated test suite** + +Run: + +```bash +python3 -m pytest tests -q +``` + +Expected: the complete suite passes, except only an already classified environment-limited skip or existing dependency warning may remain. + +- [ ] **Step 3: Run the required product and release gates** + +Run each command independently: + +```bash +make dashboard-smoke +make research-dashboard-render +make commercial-beta-check +make commercial-beta-performance-check +make public-wording-check +make browser-qa-evidence-check +make pilot-readiness-check TOP_N=10 +make diff-hygiene-summary +git diff --check +``` + +Expected: + +- dashboard, render, commercial-beta, performance, public wording, browser evidence, diff hygiene, and whitespace gates pass; +- pilot readiness may remain truthfully blocked because saved readiness is stale; +- no command writes or promotes readiness artifacts. + +- [ ] **Step 4: Confirm generated-artifact exclusion** + +Run: + +```bash +git diff -- \ + data/analyst_estimates_readiness.csv \ + data/dcf_readiness.csv \ + data/earnings_readiness.csv \ + data/price_coverage_report.csv \ + data/reports/analyst_estimates_readiness_report.csv \ + data/reports/data_source_status.csv \ + data/reports/dcf_readiness_report.csv \ + data/reports/earnings_readiness_report.csv \ + data/reports/feature_readiness_summary.csv \ + data/reports/fundamentals_coverage_report.csv \ + data/reports/peer_readiness_report.csv \ + data/reports/peer_unlock_worklist.csv \ + data/reports/price_coverage_report.csv \ + data/reports/ticker_readiness_report.csv \ + data/reports/universe_coverage_report.csv \ + data/universe_master.csv \ + outputs/feature_readiness_summary.csv \ + outputs/peer_unlock_worklist.csv | shasum -a 256 +``` + +Expected: + +```text +a2c2f428b489dbb291dd54fd8a6e1e7f4ad9481414320ca248c54be89f4062b9 - +``` + +- [ ] **Step 5: Verify the final remote compare and draft PR state** + +Confirm: + +```text +branch: codex/personal-research-mode-mvp +PR: #113 +state: open +draft: true +merged: false +generated paths in this slice: 0 +``` + +The authoritative reviewed slice from +`3c5a2bc8fc47ac144290087e9dad513bb683252c` must contain only these seven +paths: + +```text +src/hosted_access_control.py +tests/test_hosted_access_control.py +docs/PRIVATE_BETA_ARCHITECTURE.md +ROADMAP.md +docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md +tests/test_public_v1_release_docs.py +docs/superpowers/plans/2026-07-26-provider-neutral-workspace-authorization.md +``` + +- [ ] **Step 6: Require exact-head GitHub Actions success** + +Wait for the current PR head’s `Commercial Research Beta` workflow to complete. Require successful: + +```text +full test suite +dashboard startup +Personal Research route render +public wording +generated-artifact hygiene +whitespace +``` + +Do not reuse an earlier commit’s CI result. + +- [ ] **Step 7: Update draft PR #113 with truthful evidence** + +Record: + +- the exact implementation and documentation commit SHAs; +- focused and full test counts; +- each release-gate result; +- unchanged generated-artifact fingerprint; +- the provider-neutral, deny-by-default, cross-principal, cross-workspace, append-only, and audit-obligation behavior; +- that authentication, deployed isolation, audit storage, retention, monitoring, rollback, incident response, operated capacity, external validation, and market maturity remain unproven; +- the exact next safe executable lane. + +Keep the PR open and draft. Do not merge or deploy. + +- [ ] **Step 8: Hand off the verified slice** + +Report: + +- authoritative remote branch and PR status; +- product stage; +- files and behavior added; +- focused/full/release/CI evidence; +- generated artifacts excluded; +- external dependencies and exact unblock conditions; +- remaining maturity gates; +- exact next executable step; +- whether the branch is safe for review; +- whether the overall goal remains active. diff --git a/docs/superpowers/plans/2026-07-27-roadmap-truth-reconciliation.md b/docs/superpowers/plans/2026-07-27-roadmap-truth-reconciliation.md new file mode 100644 index 00000000..865c021c --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-roadmap-truth-reconciliation.md @@ -0,0 +1,125 @@ +# Roadmap Truth Reconciliation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Turn `ROADMAP.md` into a concise current-decision index while preserving every fail-closed boundary and routing detailed completion evidence to its canonical document. + +**Architecture:** Keep current commands, product stage, ordered executable work, external dependencies, later work, completion evidence, success gates, and permanent exclusions in `ROADMAP.md`. Remove duplicated implementation chronology when the same evidence already lives in `docs/COMPLETED_MILESTONES.md`, `docs/ACCESSIBILITY_EVIDENCE.md`, `docs/internal/POINT_IN_TIME_UNIVERSE_REVIEW_HISTORY.md`, or the continuation contract. Correct the stale next-stage conclusion in the capability audit. + +**Tech Stack:** Markdown, Python/pytest documentation contracts, existing Make release and hygiene gates. + +## Global Constraints + +- Start from current repository truth. +- Preserve independent readiness and every research-only, source-rights, explicit-Q4, EPS split-basis, synthetic-fixture, calibration, and no-fabrication boundary. +- Do not run readiness rebuilds or generate CSV, JSON, report, screenshot, or timing artifacts. +- Never stage generated working-data churn. +- Keep PR #113 open and draft. + +--- + +### Task 1: Define the active-roadmap contract + +**Files:** +- Modify: `tests/test_public_v1_release_docs.py` +- Modify: `tests/test_launchers.py` + +**Interfaces:** +- Consumes: the user-approved `Now / Next / Externally blocked / Later / Completed with evidence` structure. +- Produces: a behavioral documentation contract that rejects a missing section, stale current-priority statement, or oversized active roadmap. + +- [ ] **Step 1: Write the failing contract test** + +```python +def test_active_roadmap_is_a_concise_current_decision_index(): + roadmap = _read("ROADMAP.md") + for heading in ( + "## Now", + "## Next", + "## Externally blocked", + "## Later", + "## Completed with evidence", + ): + assert heading in roadmap + assert len(roadmap.splitlines()) <= 320 +``` + +- [ ] **Step 2: Run the contract test and verify the current roadmap fails** + +Run: `python3 -m pytest tests/test_public_v1_release_docs.py::test_active_roadmap_is_a_concise_current_decision_index -q` + +Expected: failure because the exact new headings and line budget are not yet satisfied. + +- [ ] **Step 3: Remove obsolete assertions that require completed performance work to remain in the active-priority order** + +Replace the old performance-before-hosting index assertion with checks that the performance release evidence is linked under `Completed with evidence` and that hosted validation stays external. + +- [ ] **Step 4: Run the focused documentation tests** + +Run: `python3 -m pytest tests/test_public_v1_release_docs.py tests/test_launchers.py -q` + +Expected: failures name only roadmap phrases that must be preserved, intentionally rerouted, or updated. + +### Task 2: Reconcile the active roadmap and stale capability audit + +**Files:** +- Modify: `ROADMAP.md` +- Modify: `docs/analysis_capability_audit.md` +- Modify: `docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md` + +**Interfaces:** +- Consumes: current HEAD, exact-head CI evidence, roadmap priorities 1–10, and the external-dependency classifications already recorded in the continuation contract. +- Produces: a concise active roadmap, a current capability conclusion, and a continuation contract pointing to the reconciled roadmap. + +- [ ] **Step 1: Rewrite `ROADMAP.md` as the current decision index** + +Keep: + +```text +Current Truth +Now +Next +Externally blocked +Later +Completed with evidence +Success gates +Permanently out of scope +``` + +Preserve exact unblock conditions for point-in-time universe data, consensus, reviewed peers, hosted controls, accessibility environments, independent reviewers, and calibration. + +- [ ] **Step 2: Correct the stale capability-audit conclusion** + +Replace the completed performance-release recommendation with observation-recency truth, accessibility remediation, permitted point-in-time data, and independent beta validation as the next maturity work. + +- [ ] **Step 3: Update the continuation contract** + +Record that Stage 0 roadmap reconciliation is complete only after the focused/full suite, release checks, hygiene, commit, push, PR update, and exact-head CI pass. + +- [ ] **Step 4: Run focused tests and correct only truthful compatibility failures** + +Run: `python3 -m pytest tests/test_public_v1_release_docs.py tests/test_launchers.py tests/test_pilot_review_feedback_template.py -q` + +Expected: pass with no stale priority assertion. + +- [ ] **Step 5: Run the complete release contract** + +Run: + +```text +python3 -m pytest tests -q +make dashboard-smoke +make research-dashboard-render-smoke +make public-wording-check +make public-check +make commercial-beta-release-check +make pilot-readiness-check TOP_N=10 +make diff-hygiene-summary +git diff --check +``` + +Expected: all executable gates pass; generated churn remains excluded. + +- [ ] **Step 6: Stage, verify, commit, push, and update the draft PR** + +Stage only the intentional plan, roadmap, capability-audit, continuation-contract, and test files. Run `make staged-hygiene-check` and `git diff --cached --check`, commit coherently, push only `codex/personal-research-mode-mvp`, update PR #113, and require exact-head CI. diff --git a/docs/superpowers/specs/2026-07-15-sec-quarterly-actuals-lineage-design.md b/docs/superpowers/specs/2026-07-15-sec-quarterly-actuals-lineage-design.md new file mode 100644 index 00000000..ff82f463 --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-sec-quarterly-actuals-lineage-design.md @@ -0,0 +1,274 @@ +# SEC Quarterly Actuals Lineage Design + +## Status + +Approved direction: **Option 2, versioned fiscal-quarter lineage plus explicit Q4 earnings-release ingestion**. + +This design extends the Earnings Nowcast evidence workflow. It does not make a forecast, create consensus data, apply imports, or change readiness by itself. + +## Problem + +SEC Companyfacts contains useful Revenue and diluted-EPS facts, but a naive `fy` / `fp` extraction is not safe for quarterly history: + +- A 10-Q contains current and prior-year comparative facts carrying the filing's current `fy` and `fp` labels. +- Q2 and Q3 filings contain both single-quarter and cumulative year-to-date facts. +- Companyfacts frequently omits `frame` on older valid quarter facts. +- 10-Q filings do not provide a standalone Q4 result. +- Later filings can restate or split-adjust prior-period EPS. +- Missing Q4 rows make a Q3-to-next-Q1 pair look sequential when it is not. + +The five-company pilot must therefore build period identity from source lineage, preserve revisions, reject cumulative facts, and fail closed when quarter continuity or EPS comparability cannot be proven. + +## Goals + +1. Stage source-backed quarterly Revenue and diluted GAAP EPS actuals for NVDA, AMD, AVGO, MU, and QCOM. +2. Assign each fact to the correct company fiscal quarter without using SEC calendar frames as the fiscal-period label. +3. Preserve source-visible revisions and split-adjusted presentations as append-only evidence. +4. Ingest Q4 only from an explicit result table in an SEC-filed earnings release or equivalent primary filing exhibit. +5. Prevent sequential calculations across a missing fiscal quarter. +6. Produce deterministic validate/preview evidence with no automatic apply path. + +## Non-Goals + +- Historical or current analyst consensus acquisition. +- Company guidance extraction beyond preserving an explicit source record for a later reviewed lane. +- Numerical Beat/Miss probability. +- Post-earnings price prediction. +- Annual-minus-nine-month Q4 derivation. +- Text-derived or inferred Revenue, EPS, peers, adjustments, or recommendations. +- Broad-universe SEC extraction in the pilot. + +## Source Hierarchy + +### Q1-Q3 + +Use SEC Companyfacts records from `10-Q` or `10-Q/A` filings. + +Accepted Revenue concepts, in deterministic priority order: + +1. `RevenueFromContractWithCustomerExcludingAssessedTax` +2. `Revenues` +3. `SalesRevenueNet` + +Accepted EPS concept: + +- `EarningsPerShareDiluted` + +A candidate metric pair must share the same accession, filing date, start date, end date, fiscal year, fiscal period, and duration class. + +### Q4 + +Use an explicit quarterly Revenue and diluted GAAP EPS result table in a primary SEC-filed exhibit, normally an `8-K` exhibit for domestic issuers or an equivalent filed result document for a foreign issuer. + +The source must state the quarter or quarter-ended date and the reported Revenue/EPS values. If the source table, metric basis, or fiscal-period alignment is ambiguous, Q4 remains unavailable. The system never derives Q4 by subtracting nine-month values from annual values. + +## Architecture + +Add one focused module, `src/earnings_nowcast_sec_actuals.py`, with four boundaries: + +1. **Raw fact normalization** + - Retain taxonomy, concept, unit, value, start, end, filed date, form, accession, SEC `fy` / `fp`, and optional frame. + - Preserve the raw metadata needed for audit; do not reduce a fact to value and filing year prematurely. + +2. **Fiscal-quarter lineage** + - Identify the current-quarter fact in each 10-Q accession. + - Establish the canonical `YYYY-QN` identity from that current-quarter filing and period end. + - Map later comparative presentations back to the canonical period by period end, never by the later filing's `fy` / `fp` alone. + +3. **Revision and comparability handling** + - Emit the first valid presentation as the initial evidence row. + - Emit a later changed presentation as an append-only revision with `supersedes_source_ref`. + - Keep Revenue and EPS readiness independent when only one metric changes or remains comparable. + - Label split adjustment only when the filing explicitly supports that label. + +4. **Staging and audit output** + - Write the existing `quarterly_actuals.csv` schema to an explicitly supplied output directory. + - Write a generated audit JSON and rejected-row CSV beside it. + - Create header-only consensus/signals templates so the existing onboarding preview can truthfully report `point_in_time_consensus_missing`. + - Never write to the canonical data files or call an apply command. + +## Q1-Q3 Selection Rules + +For every accession and metric concept: + +1. Keep only `10-Q` / `10-Q/A` records with numeric values and complete start/end/filed/accession metadata. +2. Calculate duration from start and end dates. +3. A single-quarter candidate must have a duration from 60 through 120 days. +4. Reject duration records outside that range as instant, cumulative, annual, or ambiguous. +5. Within an accession and SEC `fp`, use the latest period end as the current-quarter candidate. This prevents the prior-year comparative quarter from inheriting the current filing's fiscal label. +6. Revenue and EPS must align on accession, start, end, filed date, SEC `fy`, and SEC `fp` before they form a combined row. +7. A non-empty SEC frame is supporting evidence only. Missing frame does not invalidate an otherwise uniquely aligned quarter; a conflicting frame does. +8. If multiple accepted Revenue concepts produce different values for the same lineage, reject the Revenue metric as ambiguous instead of choosing the larger or newer value. +9. If Revenue and EPS cannot be paired, retain only the individually proven metric and keep the other metric empty. + +## Fiscal-Period Identity + +The canonical period key is anchored by the current-quarter record from its original filing: + +- `fiscal_year = current filing fy` +- `fiscal_quarter = current filing fp` +- `period_end = current-quarter end date` + +Later filings may present the same `period_end` with different SEC `fy` / `fp` values because it is a comparative period. Those later records inherit the canonical identity already established for that end date. + +If two original current-quarter filings assign incompatible fiscal identities to the same period end, or one fiscal identity maps to multiple period ends, every colliding row is rejected and reported as `fiscal_period_conflict`. + +## Revision Lineage + +Evidence remains point-in-time and append-only: + +- `source_ref` identifies the exact SEC accession/document presentation. +- `reported_at` is the filing publication date, not the quarter end. +- When SEC Companyfacts supplies only a filing date, `reported_at` is conservatively set to `23:59:59Z` on that date so same-day historical cutoffs cannot see the filing early. +- `retrieved_at` records collection time. +- A later changed presentation references the prior `source_ref` through `supersedes_source_ref`. +- Preview retains both rows and lets cutoff-aware canonicalization select only evidence published by the forecast cutoff. + +No later presentation overwrites an earlier snapshot. A revision published after a historical forecast cutoff is unavailable to that backtest event. + +## EPS Split Safety + +EPS is separate from Revenue readiness. + +1. Q1-Q3 Companyfacts EPS is labeled `companyfacts_split_basis_unverified` because Companyfacts alone does not prove split comparability; it cannot match an `as_reported` or split-adjusted consensus basis. +2. A filed result document may use `as_reported` only when that is the explicit presentation, while a source-supported retrospective presentation is labeled `split_adjusted_`. +3. Actual history and consensus must share the same EPS, share, operations, and split-adjustment definitions. +4. If the five-quarter model window crosses incompatible split bases, EPS is withheld while Revenue may remain ready. +5. Provider-assisted split history may route review, but it cannot establish the trusted split basis without a primary source reference. + +## Q4 Ingestion + +Q4 ingestion is a separate parser boundary because Companyfacts 10-Q duration facts cannot supply it. + +The Q4 parser must: + +1. Resolve a primary SEC filing and exact earnings-release exhibit. +2. Require an explicit quarter-ended label or an unambiguous fiscal Q4 label. +3. Require explicit Revenue and/or diluted GAAP EPS labels in a structured table. +4. Require the selected Q4 value column to state one explicit period-end date; filing timestamps and submission `reportDate` metadata are never substitutes. +5. Preserve the filing URL, accession, filed timestamp, period end, metric labels, units, and split note. +6. Reject non-GAAP-only EPS, annual-only totals, guidance tables, prose estimates, and arithmetic derivations. +7. Emit a partial row when only one metric is explicit and source-backed. + +Issuer-specific table aliases may be configured for the five-company pilot, but numeric values and periods must still come from the filing itself. The parser must not contain hard-coded company results. + +## Quarter Continuity Gate + +The model must not treat sorted rows as consecutive by default. + +For every forecast metric: + +- Require at least five source-backed prior quarters. +- Require fiscal-quarter adjacency across the model window, including Q4-to-next-Q1. +- Require the prior-year target quarter. +- If any intervening quarter is absent, mark the metric `quarter_history_gap` and withhold its forecast. +- Revenue and EPS continuity are evaluated independently. + +This gate applies to direct pilot forecasts and walk-forward backtests. + +## Command Contract + +Add a read-only staging command: + +```text +make earnings-nowcast-sec-actuals-stage \ + TICKERS=NVDA,AMD,AVGO,MU,QCOM \ + OUTPUT_DIR=/tmp/stock-nowcast-five-company/sec-actuals \ + AS_OF= +``` + +The command: + +- requires an identifying `SEC_USER_AGENT`; +- uses existing SEC cache/network boundaries; +- accepts a narrow ticker scope; +- rejects canonical `data/` and `data/imports/` destinations plus existing directories that are not marked generated SEC review output; +- performs no import or apply; +- never writes credentials; +- returns nonzero only for command/schema failure, not for a truthfully withheld ticker; +- reports per-ticker accepted rows, rejected rows, missing Q4, metric gaps, and source references. + +Follow it with the existing commands: + +```text +make earnings-nowcast-validate INPUT_DIR= AS_OF= +make earnings-nowcast-preview INPUT_DIR= AS_OF= +make earnings-nowcast-readiness INPUT_DIR= TICKER= AS_OF= +``` + +Absent historical consensus must continue to yield `point_in_time_consensus_missing` and `ready_for_packet=false`. + +## Failure States + +The audit output uses explicit states: + +- `accepted_explicit_quarter` +- `accepted_revision` +- `accepted_explicit_q4` +- `metric_partial` +- `cumulative_fact_rejected` +- `comparative_period_relabelled` +- `ambiguous_concept` +- `fiscal_period_conflict` +- `quarter_history_gap` +- `split_basis_unverified` +- `period_end_missing` +- `period_end_ambiguous` +- `q4_source_unavailable` +- `post_cutoff_rejected` +- `source_unavailable` + +A withheld state is a valid extraction result and does not stop other tickers. + +## Testing Strategy + +Tests use local fixtures and mocked SEC responses. No test depends on live SEC availability. + +Required cases: + +1. Current and prior-year comparative quarters in one 10-Q. +2. Single-quarter and cumulative Q2/Q3 facts in one accession. +3. Missing frame with uniquely aligned 90-day facts. +4. Conflicting frames or Revenue concepts. +5. Revenue-only and EPS-only partial rows. +6. Amended filing and append-only revision lineage. +7. Later split-adjusted comparative EPS with explicit split source. +8. Post-cutoff revision exclusion. +9. Explicit Q4 earnings-release table. +10. Annual-only, guidance, non-GAAP-only, and derived-Q4 rejection. +11. Missing Q4 causing `quarter_history_gap`. +12. Revenue-ready/EPS-withheld split-basis behavior. +13. ETF/fund exclusion remains unchanged. +14. Staging writes only the explicit output directory and never applies data. + +After fixture tests pass, run one live, read-only five-company smoke and inspect every accepted/rejected summary. Live output remains generated and unstaged. + +## Acceptance Criteria + +1. Every accepted Q1-Q3 fact is a uniquely aligned 60-120-day SEC filing fact. +2. Comparative facts never inherit the later filing's fiscal identity. +3. Cumulative values never appear as single-quarter actuals. +4. Q4 is explicit and source-backed or remains unavailable; it is never derived. +5. Historical revisions remain append-only and cutoff-safe. +6. Sequential model inputs are quarter-contiguous. +7. EPS is withheld across an unverified split basis while Revenue remains independently eligible. +8. The five-company staging command produces valid onboarding rows with zero fabricated values. +9. Missing consensus still blocks packet generation and numerical probability. +10. Full tests, public checks, diff hygiene, and staged hygiene pass before commit. + +## Rollout + +1. Implement raw fact normalization and Q1-Q3 lineage with fixtures. +2. Add quarter-continuity and EPS split-basis gates. +3. Add explicit Q4 filing-exhibit ingestion for the five-company cohort. +4. Run the live read-only staging smoke. +5. Validate and preview the generated evidence. +6. Keep real-company Nowcast output blocked until historical point-in-time consensus is separately sourced and validated. + +## Boundaries + +- Research-only; no investment advice or trade instruction. +- Candidate data never becomes trusted merely because parsing succeeds. +- Generated extraction artifacts remain outside default staging. +- No broker, account, order, recommendation, or auto-trading behavior. +- Numerical Beat/Miss probability remains unavailable until at least 100 valid events pass calibration gates. diff --git a/docs/superpowers/specs/2026-07-17-commercial-research-beta-design.md b/docs/superpowers/specs/2026-07-17-commercial-research-beta-design.md new file mode 100644 index 00000000..401f999e --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-commercial-research-beta-design.md @@ -0,0 +1,69 @@ +# Commercial Research Beta Foundation Design + +## Purpose + +Commercial Research Beta turns Personal Research Mode into a controlled, source-backed workflow for repeated company research. It helps a reviewer find changed evidence, understand verified business trends, inspect valuation assumptions, test bounded forward scenarios, maintain a thesis, and choose one next research task. It does not predict stock prices, rank winners, or provide investment instructions. + +## Product Contract + +The primary path remains **Research Desk -> Discover -> Company Workbench -> Monitor**. Data Health and Proof History remain supporting evidence surfaces. Each primary page must answer one question before exposing detail: + +- Research Desk: what changed and what deserves review? +- Discover: which focused-cohort company has usable evidence? +- Company Workbench: what is verified, what is plausible, and what is withheld? +- Monitor: what evidence event or wait condition should be followed? + +The beta jobs are daily change review, quarterly business-trend review, valuation scenario review, earnings preparation, thesis/invalidation review, and weekly research closeout. + +## Architecture + +The beta adds small deterministic contracts around existing systems rather than a second data or analysis stack: + +1. A commercial source-rights registry controls which sources may be used in commercial mode. +2. A focused-cohort coverage matrix composes existing readiness into truthful per-company lane states. +3. Existing append-only quarterly actual onboarding remains the canonical Revenue/EPS path; missing real rows fail closed. +4. A Forward View composer combines verified trend, valuation assumptions, reviewed thesis evidence, trusted-peer state, and Earnings Outlook state. It never lets qualitative context mutate numerical inputs. +5. Point-in-time diagnostics preserve current leakage, benchmark, and calibration gates. +6. A refresh-operations contract describes fetch-through-publish state, retry limits, quarantine, freshness, and immutable snapshots without automatically applying or publishing data. +7. Private-beta architecture and pilot measurement remain explicit readiness contracts until external hosting, accounts, and users exist. + +## Commercial Source Rights + +Every source record contains permitted use, commercial-use state, redistribution state, storage limits, attribution, rate limits, authentication, expected freshness, supported fields, and fallback priority. Commercial mode refuses any source whose commercial-use state is not explicitly approved. Credentials and license documents remain outside Git. SEC evidence remains subject to fair-access requirements; Yahoo/yfinance remains research-only unless separate rights are verified. + +## Focused Cohort Truth + +The deterministic cohort remains capped at 25 by default and must never exceed 50. A coverage matrix reports price, Revenue, EPS, margins, free cash flow, cash/debt, shares, trusted peers, filing dates, earnings dates, and point-in-time consensus as `usable_now`, `partial`, `candidate_context_only`, `blocked`, or `excluded`. Missing lanes are never padded or converted to an overall ready claim. + +## Quarterly Actuals + +Only explicit source-backed Revenue and EPS observations are accepted. Each row preserves ticker, fiscal period, period end, report and retrieval timestamps, source reference, currency, scale, accounting basis, and revision lineage. Ambiguous periods, incompatible definitions, post-cutoff observations, unresolved revisions, and inferred quarter values are rejected. Q4 is never annual minus nine months. Operating margin and free cash flow remain withheld until separately versioned source contracts exist. + +## Forward View V1 + +Forward View is an evidence-bound research summary, not a forecast engine. It presents: + +1. verified historical trend; +2. supported valuation assumptions and bounded bull/base/bear scenarios; +3. trusted peer context, if available; +4. reviewer-authored catalysts, risks, and invalidation evidence; +5. Earnings Outlook readiness and any calibrated output already allowed by its own gates; +6. withheld fields and one next research task. + +Business forecast ranges and valuation scenarios stay separate. Candidate peers, news, generated prose, and model output cannot change Revenue, EPS, DCF, or probability inputs. No numerical Beat/Miss probability appears without the existing calibration gate. + +## Operations And Failure States + +The refresh lifecycle is `fetch -> normalize -> validate -> quarantine -> preview -> publish snapshot -> rebuild readiness -> detect changes`. Each job records deterministic provider order, schema identity, batch limit, freshness policy, attempt count, status, and failure reason. Identical failed provider paths are not retried within the same session. Automatic application is disabled unless a separately tested lane gate explicitly permits it; this beta foundation does not enable such a gate. + +## Private Beta Boundary + +Repository-side architecture covers authentication, private workspaces, per-user watchlists and journals, saved scenarios, data separation, secret management, usage/error monitoring, audit logging, retention/deletion, entitlements, and health checks. These are readiness requirements, not implemented claims. Hosting and accounts remain `external_account_required`; real user evidence remains `awaiting_external_review`. + +## Validation + +Tests must prove commercial-mode source refusal, truthful cohort states, quarterly rejection and preview behavior, Forward View withholding, point-in-time leakage gates, refresh retry/quarantine behavior, and private-beta readiness classification. Desktop and mobile QA must preserve the primary answer, one next action, collapsed Advanced evidence, no horizontal overflow, and visible research-only boundaries. + +## Completion Boundary + +This foundation is complete when all local contracts, UI integration, docs, and tests pass; external data, rights, hosting, calibration, reviewed peers, and pilot users are precisely classified. It is not commercial launch readiness and must not be described as such. diff --git a/docs/superpowers/specs/2026-07-17-focused-cohort-research-workflow-design.md b/docs/superpowers/specs/2026-07-17-focused-cohort-research-workflow-design.md new file mode 100644 index 00000000..9987737e --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-focused-cohort-research-workflow-design.md @@ -0,0 +1,48 @@ +# Focused Cohort Research Workflow Design + +## Purpose + +Personal Research Mode should support a repeated owner workflow, not another broad coverage dashboard. A deterministic cohort of 25-50 operating companies will connect Research Desk, Discover, Company Workbench, Monitor, and a weekly summary while preserving every existing readiness and research-only gate. + +## Product Contract + +The cohort is selected by evidence availability and reviewability, never expected return. Eligible rows must be operating companies or ADRs, active listings when that field is known, and price-ready. DCF, fundamentals, peer, and active-universe readiness improve review priority without becoming a score or recommendation. Stable alphabetical tie-breaking makes the same input snapshot produce the same cohort. + +The cohort may contain companies without quarterly trend evidence. Those members remain useful for price/setup or valuation-boundary review, while Business Trend fails closed until comparable source-backed quarterly observations exist. The product reports the verified cohort size and never pads it with excluded or invalid securities. + +## Architecture + +### Focused cohort + +`src/focused_research_cohort.py` owns cohort eligibility, deterministic ordering, member state, and tabular presentation. It consumes the existing ticker-readiness report and universe master data without writing either source. + +Each member exposes ticker, company name, source-backed sector and industry when present, cohort rationale, usable lanes, blocked lanes, freshness, last review date, and next review reason. The selection contract contains no recommendation score or performance forecast. + +### Quarterly business trend + +`src/quarterly_business_trend.py` consumes the existing `QuarterlyActual` contract. It groups versioned rows by ticker and fiscal period, resolves only explicit revision chains, compares only matching units and accounting definitions, and calculates sequential and year-over-year changes only when the required periods are present. Conflicting or ambiguous rows withhold the affected metric. + +Revenue and EPS are supported by the current point-in-time actuals contract. Operating margin and free-cash-flow metrics remain unavailable until an equally explicit versioned source contract exists; they are not inferred from annual fundamentals or unrelated fields. Q4 is never derived from nine-month values. + +### Change monitor and weekly summary + +The existing event identity remains the deduplication key. `research_monitor_frame` will expose prior/current state, source/effective timestamps, affected section, task, and wait condition. `src/weekly_research_summary.py` will group unique cohort events and reviewer-authored journal state into traceable weekly sections without rankings or generated investment narrative. + +### Dashboard composition + +Research Desk shows cohort/change/staleness answers and a weekly summary before Advanced Evidence. Discover filters the focused cohort by readiness rather than presenting the whole universe as equally usable. Company Workbench answers what changed, Business Trend, Valuation, Forward View, withheld evidence, conclusion state, and one next task. Monitor remains a research task queue, not an alert feed. + +Public and Operator workspaces retain their existing route and evidence contracts. + +## Failure and Boundary Behavior + +- Missing cohort inputs return an empty, unavailable contract rather than guessed members. +- Fewer than 25 eligible companies produce the truthful maximum and `awaiting_reviewed_source`. +- Missing or non-comparable quarterly periods withhold changes per metric. +- Duplicate identical events collapse by event identity; unresolved changed identities remain visible. +- Empty weeks render a monitoring answer, not fabricated narrative. +- Generated summaries remain local and unstaged by default. + +## Verification + +Focused tests cover deterministic selection, exclusions, duplicate handling, missing inputs, quarterly revisions, incompatible definitions, missing comparison periods, monitor deduplication, empty weeks, and recommendation-free wording. Full tests, public checks, render smoke, browser QA, desktop/mobile review, staged hygiene, and whitespace checks remain release gates. diff --git a/docs/superpowers/specs/2026-07-18-company-workbench-answer-first-design.md b/docs/superpowers/specs/2026-07-18-company-workbench-answer-first-design.md new file mode 100644 index 00000000..1c04fa14 --- /dev/null +++ b/docs/superpowers/specs/2026-07-18-company-workbench-answer-first-design.md @@ -0,0 +1,39 @@ +# Company Workbench Answer-First Design + +## Purpose + +Company Workbench should show the selected company's usable research answer before technical lane-coverage cards. The current route renders two selected-ticker coverage cards between the `Selected Company` heading and the existing report renderer. Those cards are truthful, but they delay the report's compact supported, withheld, and next-action answer. This slice changes presentation hierarchy only; it does not change report construction, readiness, coverage, data, routes, or evidence. + +## Product Contract + +The Company Workbench route will render in this order: + +1. Personal Research workspace header, review path, and `Selected Company` heading. +2. One collapsed `Advanced: selected-company lane coverage` section containing the existing selected-ticker coverage cards. +3. The unchanged single-stock report renderer, whose compact selected-company answer remains the first expanded research content. +4. The existing route-level Advanced Evidence section and research-change evidence drawer. + +The collapsed section remains visible as a technical-evidence affordance, but its cards do not occupy the default first view. The report continues to fail closed for missing data and independently presents what is usable, withheld, changed, uncertain, and next. + +## Architecture + +`src/dashboard.py` remains the route-composition owner. `render_company_workbench()` will wrap the existing `focused_ticker_coverage_cards(coverage, ticker)` rendering call in a collapsed Streamlit expander immediately before the existing `render_single_stock_report()` call. No helper signature, provider call, session-state key, query parameter, report section, or navigation mapping changes. + +`tests/test_research_mode_dashboard_contract.py` will protect the route order and collapsed state. `src/public_performance_gate.py` will add the visible Advanced label to the Company Workbench full-settle marker contract, and `tests/test_public_performance_gate.py` will protect that marker set. + +## Failure And Boundary Behavior + +- Missing or unmatched ticker coverage still produces the existing blocked cards inside Advanced. +- The report does not infer coverage when the expander is closed. +- Actuals, consensus, Revenue, EPS, valuation, catalysts, outcomes, backtesting, and calibration remain independent. +- Candidate context cannot modify a scenario, readiness state, or trusted evidence. +- Empty valuation, catalyst, outcome, or coverage evidence stays empty or blocked. +- The change writes no data and creates no generated report artifact. + +## Documentation + +`docs/PERSONAL_RESEARCH_MODE.md` will document the answer-first Company Workbench order. `docs/DASHBOARD_QA.md` will record desktop and phone first-view evidence. `ROADMAP.md` will record the verified local workflow-hardening slice without changing external source, hosting, pilot, or calibration gates. + +## Verification + +The route-order and performance-marker tests must fail against the current expanded-card composition before implementation and pass afterward. Focused route/workspace tests, the full repository suite, dashboard smoke, research render smoke, the 48-sample commercial performance gate, public wording, public check, pilot readiness, diff hygiene, whitespace checks, and staged hygiene remain required. Live review must cover `1280x720` and `390x844`, keep the new Advanced section closed, and show no horizontal overflow. Screenshots, timing JSON, CSV, JSON, report, and sample-report churn remain excluded. diff --git a/docs/superpowers/specs/2026-07-18-discover-answer-first-design.md b/docs/superpowers/specs/2026-07-18-discover-answer-first-design.md new file mode 100644 index 00000000..a01c9333 --- /dev/null +++ b/docs/superpowers/specs/2026-07-18-discover-answer-first-design.md @@ -0,0 +1,40 @@ +# Discover Answer-First Design + +## Purpose + +Discover should let a researcher choose a readiness-backed company immediately after reading the route header. The current route renders focused-cohort and lane-coverage cards before the selector, which pushes the search control and company actions below the first desktop viewport. This slice changes presentation order only; it does not change cohort eligibility, readiness, ranking, data, routes, or company links. + +## Product Contract + +The Discover route will render in this order: + +1. Personal Research workspace header with freshness, research-only boundary, and one next action. +2. `Which stock can I review?` heading and the existing readiness-backed selector. +3. One collapsed `Advanced: cohort readiness context` section containing the existing focused-cohort scope and lane-coverage cards. + +The selector continues to limit rows to the deterministic focused cohort and continues to open Company Workbench in Personal Research mode. The reordering must not create a score, recommendation, expected-return ranking, inferred coverage, or trusted evidence. + +## Architecture + +`src/dashboard.py` remains the composition owner. The change reorders existing calls inside the Discover branch of `main()` and wraps the existing `focused_cohort_cards` and `focused_cohort_coverage_cards` output in one collapsed Streamlit expander. No helper signature, data contract, persisted artifact, query parameter, or navigation mapping changes. + +`tests/test_research_mode_dashboard_contract.py` owns the source-level route contract. A new regression test will require the selector heading and selector renderer to appear before the collapsed cohort context, and require both existing card groups to remain inside that context. + +`src/public_performance_gate.py` owns the real-browser visible-marker contract. Discover's full-settle markers must use visible answer-first text (`Search this review queue` and the collapsed Advanced label), not content hidden inside the closed cohort expander. `tests/test_public_performance_gate.py` protects that marker set. + +## Failure And Boundary Behavior + +- An empty focused cohort still produces an empty selector and truthful unavailable/blocked cohort context. +- The route does not broaden to the master universe when the cohort is empty. +- Cohort status and gated-lane counts remain available under Advanced; they are not removed or combined. +- Company links continue to target `?mode=research&page=company-workbench`. +- Technical evidence remains collapsed by default at desktop and phone widths. +- The change writes no data and creates no generated report artifact. + +## Documentation + +`docs/PERSONAL_RESEARCH_MODE.md` will document that Discover is selection-first and keeps cohort context under Advanced. `docs/DASHBOARD_QA.md` will record the first-viewport acceptance contract. `ROADMAP.md` will record the verified local workflow-hardening slice without changing the external source, hosting, pilot, or calibration gates. + +## Verification + +Verification requires both new regression tests to fail before implementation and pass afterward. Focused route/workspace tests, the full repository suite, dashboard smoke, research render smoke, the 48-sample commercial-beta performance gate, public wording, public check, pilot readiness, diff hygiene, whitespace checks, and staged hygiene remain required. Generated CSV, JSON, report, sample-report, screenshot, and timing churn remains excluded. diff --git a/docs/superpowers/specs/2026-07-18-mobile-research-first-action-design.md b/docs/superpowers/specs/2026-07-18-mobile-research-first-action-design.md new file mode 100644 index 00000000..8c42411e --- /dev/null +++ b/docs/superpowers/specs/2026-07-18-mobile-research-first-action-design.md @@ -0,0 +1,130 @@ +# Mobile Research First-Action Density Design + +**Date:** 2026-07-18 +**Status:** Approved by the active continuation contract + +## Purpose + +Make the Personal Research workflow faster to understand at a 390x844 phone viewport without hiding readiness, freshness, source, or research-only boundaries. + +The current desktop and phone audit confirms that Research Desk, Discover, Company Workbench, and Monitor render successfully and remain answer-first. The remaining local usability problem is repetition: the compact profile strip and the route workspace card both repeat freshness context before the route-specific task appears. On Company Workbench, the full review path adds another multi-line block before the selected-company answer. + +This slice improves first-action visibility. It does not change research data, readiness, source rights, forecasts, valuation, evidence, or navigation. + +## Current Audit Evidence + +Fresh screenshots were captured outside the repository at 1280x720 and 390x844 for all four Personal Research routes. They are audit evidence only and remain unstaged. + +The audit found: + +1. Desktop routes clearly show saved-readiness context, freshness, one next action, and the research-only boundary. +2. Discover presents search before advanced filters, but the first company result begins below the desktop viewport. +3. Company Workbench presents the selected-company answer after the route card and a long review-path caption; at phone width, the `Selected Company` heading falls below the first screen. +4. Monitor truthfully distinguishes an empty evidence queue from a market conclusion, but the weekly summary begins near the bottom of the phone viewport. +5. At phone width, the compact profile strip uses three rows and the route card repeats freshness already shown immediately above it. +6. No route showed a traceback, browser console error, fabricated evidence, ranking, recommendation, or missing research-only boundary. + +## Considered Approaches + +### 1. Compact the existing research chrome at phone width — selected + +Keep the current components and information architecture. Add semantic classes to existing fields, use responsive CSS to reduce repeated content, and collapse the Workbench review path. + +Benefits: + +- smallest behavioral surface; +- desktop profile and route metadata remain unchanged; Workbench keeps the same path content in a collapsed disclosure; +- no new route or component system; +- all readiness and source boundaries remain present; +- directly improves the audited phone bottleneck. + +### 2. Replace the profile strip and route card with one unified component + +This could remove more duplication, but it would broaden the slice across public, research, and operator shells and create unnecessary regression risk. + +### 3. Hide most status context on phone + +This would maximize visible content but would weaken the evidence-first contract. Source date, freshness, and research-only boundaries must remain discoverable before a research answer. + +## Design + +### Profile context + +The existing compact profile strip remains the authoritative profile context. At widths of 640 pixels or less: + +- use three columns instead of two so its five facts occupy two rows; +- reduce gaps and horizontal padding without reducing the existing text content; +- keep Data profile, Sources through, Freshness, Price-ready, and DCF-ready visible; +- preserve the existing stale/current/mixed/missing color states. + +No field is removed or moved into generated output. + +### Research route card + +The existing route workspace card remains the primary answer boundary. Add stable semantic classes to its freshness and next-action rows. + +At widths of 640 pixels or less: + +- reduce card padding and vertical margins; +- hide only the duplicated route-card freshness row because the authoritative compact profile strip is immediately above it; +- keep the route title, selected ticker or focused scope, next action, and research-only statement visible; +- use the existing visual language, colors, borders, typography, and spacing scale. + +Desktop rendering keeps both route freshness and next action unchanged. + +### Company Workbench review path + +Replace the always-visible full review-path caption with a collapsed `Review path` expander. Its content remains unchanged and in the same order: + +`Selected Company -> What Changed -> Business Trend -> Valuation -> Forward View -> What Remains Withheld -> Research Conclusion -> Next Research Task` + +The expander appears immediately after the `Selected Company` heading and before the detailed report. The path is guidance, not evidence required to understand the primary answer, so it stays collapsed while the first-read content follows directly beneath it. + +### Other routes + +Research Desk, Discover, and Monitor keep their current content order. They benefit automatically from the shared responsive compaction: + +- Research Desk exposes more of the weekly summary; +- Discover exposes the search control and more of the review queue; +- Monitor exposes the weekly state and change-monitor answer sooner. + +## Data And Readiness Boundaries + +This slice must not: + +- change profile selection, readiness counts, source dates, freshness calculation, or coverage; +- promote any actuals, consensus, Revenue, EPS, cash-generation, valuation, catalyst, outcome, peer, backtest, or calibration state; +- create or consume CSV, JSON, report, sample-report, committed screenshot, or timing artifacts; temporary audit screenshots remain outside the repository; +- alter candidate-context or synthetic-fixture treatment; +- add investment advice, rankings, recommendations, price predictions, broker actions, or trade instructions. + +## Accessibility + +The route card keeps a labelled `section` and semantic description list. Hiding the duplicated freshness row on phone removes it from the mobile accessibility tree, but the same freshness value remains in the preceding labelled profile strip. + +The Workbench path uses a native Streamlit expander, preserving a keyboard-operable disclosure control. The selected-company answer moves earlier in reading order; the path content remains available on request. + +Screenshot evidence can confirm visible hierarchy and responsive reflow. It cannot prove full keyboard, screen-reader, contrast, or WCAG compliance; existing automated and manual accessibility checks remain separate. + +## Testing + +Behavior changes use test-first coverage: + +1. Extend the research workspace HTML contract test to require semantic freshness and next-action classes. +2. Add a dashboard contract test proving the Workbench `Selected Company` heading precedes the collapsed review path and the report details remain after the disclosure. +3. Add a style contract test for phone-only three-column profile context, hidden duplicated route freshness, and compact route spacing. +4. Run focused research workspace and dashboard contract tests. +5. Run the full suite and all required dashboard, render, public, commercial-beta, release, pilot, hygiene, whitespace, and staged checks. +6. Re-capture all four Personal Research routes at 1280x720 and 390x844 outside the repository and compare before and after. + +## Acceptance Criteria + +- All four routes retain profile, source-date, freshness, next-action, and research-only context. +- At 390x844, the profile strip uses no more than two rows for its five facts. +- At 390x844, the route card does not repeat the freshness row. +- Discover shows its search control within the first phone viewport. +- Company Workbench shows the `Selected Company` first-read within the first phone viewport. +- Monitor shows at least the start of its weekly summary within the first phone viewport. +- Company Workbench keeps its complete review path in a collapsed disclosure after the `Selected Company` heading and before the detailed report. +- Desktop profile and route metadata remain unchanged; Company Workbench keeps the complete path available through the same collapsed disclosure. +- No readiness state, data input, generated artifact, or external dependency changes. diff --git a/docs/superpowers/specs/2026-07-18-monitor-answer-first-design.md b/docs/superpowers/specs/2026-07-18-monitor-answer-first-design.md new file mode 100644 index 00000000..93981846 --- /dev/null +++ b/docs/superpowers/specs/2026-07-18-monitor-answer-first-design.md @@ -0,0 +1,110 @@ +# Monitor Answer-First Design + +## Purpose + +Monitor should answer whether source-backed research work is queued before it +shows technical Earnings Nowcast readiness evidence. The current route renders +the weekly summary, then a five-company readiness card, and only afterward the +actual research-change answer. On both desktop and phone, this pushes the +truthful empty-queue state below the first view. The empty state also uses the +success treatment even though no queued change is a neutral wait condition, not +proof that no real-world event occurred. + +This slice changes presentation hierarchy and one navigation affordance only. +It does not change weekly-summary derivation, event identity, queue ordering, +deduplication, wait conditions, cohort membership, readiness calculations, or +evidence status. + +## Current Audit Evidence + +The default-profile Monitor route was captured on 2026-07-18 at `1280x720` +and `390x844`. Both widths showed the weekly summary before Earnings evidence +readiness, while `Research change monitor` and the empty-queue answer appeared +after the five-company readiness card. No details element was open and neither +viewport overflowed horizontally. Screenshots were saved outside the repository +and remain product-flow evidence only. + +## Approaches Considered + +1. Keep the current order and add an anchor to the monitor answer. This retains + the first-view interruption and makes the user navigate around evidence that + is not required for the primary answer. +2. Build a new combined Monitor summary component. This could create a compact + answer, but it would duplicate existing weekly and queue semantics and add a + second aggregation contract. +3. Reorder the existing components and collapse technical readiness evidence. + This is the selected approach because it reuses proven helpers, changes no + data flow, and follows the answer-first pattern already verified on Research + Desk, Discover, and Company Workbench. + +## Product Contract + +The Monitor route will render in this order: + +1. Personal Research workspace header with freshness, research-only boundary, + and the existing change-review or wait instruction. +2. Existing weekly research summary cards. +3. `Research change monitor` and the existing deduplicated queue frame or the + truthful empty-queue answer. +4. When the queue is empty, one `Open Discover` action. A non-empty queue keeps + the review rows as the next task and does not add a competing action. +5. One collapsed `Advanced: five-company Earnings Nowcast readiness` drawer + containing the existing readiness heading, cards, full cohort rows, and + blocker caption. +6. The existing collapsed Advanced Evidence note and separate research-change + evidence drawer. + +The empty-queue note uses the neutral treatment. It continues to say that the +state is not a stock ranking and that the researcher may use Discover or wait +for a comparable source-backed change. + +## Architecture And Data Flow + +`src/dashboard.py` remains the composition owner. `render_research_monitor()` +will compute `research_monitor_frame(...)` before loading and rendering the +Nowcast cohort. It will render the monitor answer immediately after the weekly +summary, add the existing Discover route only in the empty branch, then place +the unchanged readiness cards and dataframe inside the existing five-company +Advanced drawer. + +No helper signature, query parameter, loader, dataframe column, queue rule, or +readiness payload changes. Actuals, consensus, Revenue, EPS, valuation, +catalysts, outcomes, backtesting, and calibration remain independent. + +## Failure And Boundary Behavior + +- An empty queue means no saved comparable source-backed change is queued. It + never proves that nothing changed in the real world. +- A non-empty queue remains deduplicated and unranked, with evidence status, + effective date, review state, next research task, and wait condition intact. +- Missing consensus, Q4, split-basis, backtest, and calibration evidence remain + separately visible under Advanced and cannot unlock a forecast. +- Candidate context cannot alter a deterministic scenario or become trusted + evidence. +- Synthetic fixtures remain test-only. +- The route writes no source data and creates no generated report artifact. + +## Accessibility And Responsive Behavior + +The primary answer must appear before the technical readiness drawer in DOM and +visual order. The empty state must not rely on a success color to communicate +meaning. `Open Discover` uses the existing primary link-button pattern; its +nested Markdown text must preserve the button's white foreground instead of +inheriting the page's muted paragraph color. It must remain usable at desktop +and phone widths. At `1280x720` and `390x844`, Advanced stays closed by default +and the document width must match the viewport without horizontal scrolling. + +## Documentation And Verification + +`ROADMAP.md` will mark all four Personal Research routes answer-first and move +the next executable work to the permitted prospective consensus source path. +`docs/PERSONAL_RESEARCH_MODE.md` and `docs/DASHBOARD_QA.md` will record the new +order and live evidence. + +Tests will protect source order, the empty-state Discover action, the neutral +treatment, unchanged Nowcast evidence inside Advanced, and answer-based browser +performance markers. Verification includes focused and full pytest, dashboard +and research render smoke, the 48-sample commercial performance gate, public +wording and public share gates, pilot readiness, diff and staged hygiene, +whitespace checks, and fresh desktop/phone browser review. Screenshots, timing +JSON, CSV, JSON, report, and sample-report churn remain excluded. diff --git a/docs/superpowers/specs/2026-07-18-peer-evidence-quality-design.md b/docs/superpowers/specs/2026-07-18-peer-evidence-quality-design.md new file mode 100644 index 00000000..42678786 --- /dev/null +++ b/docs/superpowers/specs/2026-07-18-peer-evidence-quality-design.md @@ -0,0 +1,60 @@ +# Peer Evidence Quality Design + +## Problem + +The product currently treats a source-backed row in `data/peers.csv` as a trusted relationship and may use any such peer with sufficient local financial data in relative valuation. The row preserves a source, as-of date, peer group, sector, and industry, but it does not durably preserve a reviewer-assigned peer role, an explicit economic-comparability rationale, or a decision about whether the relationship may anchor valuation. Sector or industry similarity is therefore capable of becoming stronger valuation evidence than the documented methodology allows. + +## Decision + +Add a fail-closed peer evidence-quality contract that evaluates five states independently: + +1. relationship provenance; +2. reviewer-assigned peer role; +3. documented economic comparability; +4. result read-through evidence; and +5. valuation-anchor eligibility. + +The canonical peer row gains four optional evidence fields: + +- `peer_role`: one of `core_peer`, `secondary_peer`, `aspirational_peer`, `negative_peer`, `excluded_close_peer`, or `not_clean_comp`; +- `relationship_rationale`: the source-backed reason the companies are related; +- `comparability_basis`: the reviewed economic dimensions that support or limit comparison, such as business model, geography, size, growth, margin, leverage, cyclicality, liquidity, or accounting basis; +- `valuation_anchor_eligible`: explicit `yes` or `no`. + +Only `core_peer` and `secondary_peer` rows with relationship provenance, a non-placeholder relationship rationale, a non-placeholder comparability basis, and explicit `valuation_anchor_eligible=yes` may enter peer-median valuation calculations. Other reviewed roles remain context-only. Missing or invalid fields produce named blockers; legacy rows are never upgraded by inference. + +## Data Flow + +The peer source-review packet and template collect the four fields. The write-back guard preserves them in its exact CSV scaffold. Local schema validation recognizes them as optional columns, so older files remain readable while their valuation-anchor state stays withheld. + +A focused `peer_evidence_quality` module owns role validation and the deterministic anchor decision. Both the local provider and readiness engine call the same contract: + +- the provider exposes quality state with each trusted relationship and sends only eligible rows into relative valuation; +- the readiness engine keeps relationship/trend readiness separate from valuation-anchor readiness and reports explicit anchor blockers; +- the Peer Read-Through Map displays role, comparability, and valuation-anchor state while retaining its independent result/fiscal-timing gate. + +Candidate peer rows never enter this contract as trusted relationships and cannot become valuation anchors. + +## User Experience + +Company Workbench answers the primary question first: which peer results can be reviewed as business context, and which peer relationships are eligible to anchor valuation. The compact table adds `Peer Role`, `Comparability`, and `Valuation Anchor`. Raw sources, identity hashes, and full evidence objects remain under Advanced. + +When no rows meet the anchor contract, relative peer valuation is withheld and standalone multiples may still be shown. The product names the missing peer-role or comparability proof instead of suggesting a peer median is usable. + +## Compatibility And Migration + +Existing seven-column peer files remain schema-valid. Their rows retain source-backed relationship and trend context, but become `comparability_unreviewed` and `valuation_anchor_withheld` until an explicit review adds the new fields. Synthetic fixtures that exercise valuation readiness must declare the fields explicitly; test-only data remains visibly synthetic. + +No existing canonical row is automatically migrated or assigned a role. No broad peer refresh or source loop is part of this slice. + +## Safety Boundaries + +- Peer evidence remains research-only and cannot produce investment advice or transaction instructions. +- Candidate context cannot satisfy relationship provenance, comparability, or valuation-anchor eligibility. +- A peer role never supplies missing price, fundamentals, fiscal periods, result evidence, source rights, or freshness. +- Result read-through remains directional context and never changes Earnings Nowcast, DCF assumptions, deterministic forecasts, or probabilities. +- The slice does not fabricate roles, comparability evidence, peers, sources, or timestamps. + +## Verification + +Focused tests cover role validation, legacy-row fail-closed behavior, explicit context-only roles, eligible anchors, provider filtering, independent readiness states, source-review/write-back preservation, and Company Workbench presentation. Full repository and public-release gates must pass, followed by exact-path staging and generated-artifact hygiene checks. diff --git a/docs/superpowers/specs/2026-07-18-personal-research-evidence-detour-continuity-design.md b/docs/superpowers/specs/2026-07-18-personal-research-evidence-detour-continuity-design.md new file mode 100644 index 00000000..97b6345b --- /dev/null +++ b/docs/superpowers/specs/2026-07-18-personal-research-evidence-detour-continuity-design.md @@ -0,0 +1,104 @@ +# Personal Research Evidence Detour Continuity Design + +## Purpose + +Personal Research Mode has four healthy primary routes: Research Desk, Discover, Company Workbench, and Monitor. Data Health and Proof History are intentionally secondary evidence routes under Advanced. + +The current Company Workbench Advanced Evidence links break workspace continuity: Data Health opens Operator mode and Proof History opens Public mode. Both evidence pages already support Personal Research mode, including the research header and restricted non-operator presentation, but the existing links do not use that capability. The pages also state that the user should return to Company Workbench without exposing a direct return action. + +This slice keeps evidence inspection inside Personal Research mode and makes the detour explicitly reversible without adding routes, changing data, or moving technical evidence into the primary research answer. + +## Audit Evidence And Limits + +Current AppTest render smoke passed for Research Desk, Discover, Company Workbench, and Monitor. Source and contract inspection confirmed: + +- all four primary routes render the research-only header and one next action; +- Company Workbench keeps lane coverage and evidence links under collapsed Advanced sections; +- `research_path_options` already supports Data Health and Proof History as active evidence detours inside Personal Research mode; +- research-mode Data Health and Proof History already render a Personal Research header and use the non-operator presentation; +- `advanced_evidence_links` currently emits `mode=operator` for Data Health and `mode=public` for Proof History; +- neither evidence detour renders a direct return action. + +No new screenshots are permitted by the active continuation contract. This is therefore a route-continuity and render-contract audit, not a screenshot-based visual or accessibility audit. Visual spacing, focus order, contrast, and assistive-technology behavior remain outside this slice. + +## Approaches Considered + +### 1. Same-mode evidence detour with a direct return action — selected + +Change both Advanced Evidence links to `mode=research`. On research-mode Data Health and Proof History, show one direct return link immediately after the research header. Preserve the selected ticker and reopen Company Workbench with `open=1`. + +This uses existing routes and presentation boundaries, keeps operator commands out, and provides a clear reversible path. + +### 2. Same-mode evidence detour without a return action + +Change only the Advanced Evidence URLs and rely on the sidebar route rail to return. This is smaller but leaves the page's stated next action without a matching control and is weaker on phone-sized navigation. + +### 3. Keep cross-mode routing and explain the switch + +Add copy warning that Data Health opens Operator mode and Proof History opens Public mode. This preserves existing URLs but keeps an unnecessary workspace switch and exposes a more complex mental model. + +## Architecture + +### Research evidence links + +`advanced_evidence_links(ticker)` remains a pure read-only helper. Both URLs use `mode=research`: + +- `?mode=research&page=data-health&ticker=`; +- `?mode=research&page=proof-history&ticker=`. + +Ticker values remain uppercased and URL-encoded. Empty tickers omit the ticker query parameter. + +### Return destination + +Add a pure `research_evidence_return_link(ticker)` helper: + +- with a ticker, return `Return to Company Workbench` and `?mode=research&page=company-workbench&ticker=&open=1`; +- without a ticker, return `Return to Research Desk` and `?mode=research&page=research-desk`. + +The helper returns label, href, and purpose. It writes no state and never infers a ticker. + +### Evidence-page integration + +When Data Health or Proof History renders in Personal Research mode: + +1. render the existing research workspace header; +2. render the return link as the one primary action matching the header guidance; +3. render the existing evidence page unchanged. + +Public and Operator modes remain unchanged. The sidebar still includes the active evidence route plus the four primary Personal Research routes. + +## Behavior And Boundaries + +- No new page, route alias, session state, persistence layer, or data mutation. +- No readiness, source, valuation, catalyst, outcome, consensus, forecast, backtest, or calibration state changes. +- Data Health remains a secondary Advanced evidence route and retains its non-operator presentation in Personal Research mode. +- Proof History remains evidence review only and cannot refresh or unlock data. +- Candidate context remains separate from trusted evidence. +- The return link is navigation only; it does not record a review outcome or mark evidence resolved. +- Research-only wording remains visible on both detours. + +## Failure Behavior + +- Missing ticker: return to Research Desk; do not invent a company. +- Encoded ticker characters: preserve safe URL encoding. +- Directly opened evidence route: render the same return action from query state. +- Unknown or missing evidence: preserve the existing fail-closed evidence page; navigation remains available. + +## Testing + +Test first: + +1. Advanced Evidence links remain in `mode=research` and preserve encoded ticker state. +2. The return-link helper chooses Company Workbench for a supplied ticker and Research Desk otherwise. +3. Research-mode Data Health and Proof History render the return link after the workspace header and before evidence content. +4. Public and Operator routing remain unchanged. +5. AppTest render smoke covers both evidence detours in addition to the four primary routes. +6. Full dashboard, public, commercial-beta, pilot, whitespace, and hygiene gates remain green except for the already-truthful stale-readiness pilot block. + +## Completion Criteria + +- Company Workbench Advanced Evidence does not switch workspace modes. +- Both evidence detours expose one direct return action. +- Selected ticker context survives the round trip. +- No generated CSV, JSON, report, sample-report, screenshot, timing, or bytecode artifact changes. +- Documentation continues to describe Data Health and Proof History as secondary Advanced evidence, not primary Personal Research destinations. diff --git a/docs/superpowers/specs/2026-07-18-quarterly-cash-generation-adapter-acceptance-design.md b/docs/superpowers/specs/2026-07-18-quarterly-cash-generation-adapter-acceptance-design.md new file mode 100644 index 00000000..0f0da8c6 --- /dev/null +++ b/docs/superpowers/specs/2026-07-18-quarterly-cash-generation-adapter-acceptance-design.md @@ -0,0 +1,109 @@ +# Quarterly Cash-Generation Adapter Acceptance Design + +## Decision + +Add a pure, read-only acceptance harness for one company's in-memory quarterly cash-generation observations. The harness evaluates whether a proposed adapter batch is safe to review; it does not fetch data, read an input file, write an output file, change source rights, activate production output, or promote any readiness state. + +The existing `QuarterlyBusinessObservation` and `derive_quarterly_business_metrics` contracts remain authoritative for observation validity, explicit filed-Q4 evidence, cutoff handling, revision resolution, component compatibility, formulas, and source lineage. + +## Why This Is The Next Local Step + +The calculation contract is implemented, but production correctly supplies no supplemental observations because no reviewed adapter exists. The next locally executable gap is the boundary between an adapter candidate and the existing domain contract: one deterministic decision must explain whether a single-company batch has approved rights, supported fields, coherent identity, complete compatible components, and reviewable derived output. + +This increases adapter readiness without pretending that a real source payload, entitlement, or reviewer decision exists. The current checked-in SEC Companyfacts rights record does not list operating income, cash from operations, or capital expenditures, so it must remain blocked for this purpose until the registry is explicitly reviewed and changed through a separate rights decision. + +## Alternatives Considered + +### 1. Dedicated pure acceptance module — selected + +Create a small module that composes the source-rights registry and the existing cash-generation derivation. It returns one immutable result with stable blockers and an `accepted_for_review` state. This keeps source onboarding separate from formulas and makes the boundary directly testable without persistence. + +### 2. Add acceptance logic to `quarterly_cash_generation.py` — rejected + +This would mix adapter governance with metric validation and derivation. The current domain module has one clear purpose and should remain independent of commercial-source policy. + +### 3. Add a CSV validator or Make command — rejected + +A file-facing validator would introduce a premature input/output contract, invite generated churn, and imply an onboarding path before a reviewed source exists. This slice deliberately accepts only already-constructed in-memory observations. + +## Public Interface + +Create `src/quarterly_cash_generation_adapter.py` with: + +- `QuarterlyAdapterAcceptance`, an immutable result containing ticker, source ID, status, blockers, accepted observation count, reviewed metric names, derived point count, explicit-Q4 periods, rights status, `production_activation=False`, and `readiness_promotions=()`; +- `assess_quarterly_cash_generation_adapter(...)`, a pure function accepting one ticker, one source ID, an iterable of `QuarterlyBusinessObservation`, compatible Revenue actuals, an explicit source-rights registry, and an optional cutoff. + +The only success state is `accepted_for_review`. It means the in-memory candidate passed the local acceptance contract; it does not mean reviewed, production-ready, commercially activated, or source-backed in the repository. + +## Acceptance Rules + +The candidate is `accepted_for_review` only when all of these are true: + +1. The requested ticker and source ID are non-empty. +2. At least one observation exists. +3. Every observation belongs to exactly the requested ticker and source ID. +4. The source exists in the supplied registry and commercial use is explicitly approved. +5. The rights record explicitly supports `operating_income`, `cash_from_operations`, and `capital_expenditures`. +6. The existing observation constructor has already enforced finite values, timestamps, fiscal-period syntax, and explicit filed-Q4 state. +7. The existing derivation reports no post-cutoff, ambiguous-revision, missing-compatibility, or component-definition blocker. +8. At least one fiscal period produces operating margin, free cash flow, and FCF margin from compatible explicit components and Revenue. + +Any failed rule returns `blocked` with deterministic blockers. The function reports all safely knowable blockers in one call so operators do not need trial-and-error retries. + +## Source-Rights Boundary + +The harness receives an explicit immutable registry; it does not mutate or silently extend `config/source_rights.yml`. Unknown sources, unverified commercial rights, and approved sources whose `supported_fields` omit any required component all fail closed. + +Tests may construct an in-memory synthetic rights record that explicitly supports the three metrics. That registry is test-only and does not change the checked-in rights decision. The checked-in SEC record remains blocked for these fields unless a separate reviewed rights change is made. + +## Q4, Cutoff, Revision, And Compatibility Behavior + +- Q4 rows can reach the harness only with `q4_evidence_state=explicit_filed_quarter`; annual-minus-nine-month derivation remains forbidden. +- A publication time after the requested cutoff blocks acceptance and produces no accepted-for-review result. +- Exact duplicates may collapse through the existing derivation; one explicit revision leaf may supersede an older reference; conflicting unresolved leaves block acceptance. +- Currency, unit scale, accounting basis, duration basis, fiscal period, and period end must remain compatible under the existing derivation rules. +- Capital expenditures preserve the reported sign. Free cash flow remains cash from operations plus reported capital expenditures. + +## No-Activation And No-Persistence Boundary + +The result always sets `production_activation=False` and `readiness_promotions=()`. Acceptance cannot change Revenue, EPS, operating-margin, free-cash-flow, FCF-margin, consensus, valuation, catalyst, outcome, peer, backtest, or calibration readiness. + +The module adds no CLI, Make target, loader, writer, template, data directory, CSV, JSON, report, sample report, screenshot, timing output, or canonical row. It performs no network request and reads no credentials. + +## Tests + +Use synthetic in-memory dataclasses only. Cover: + +- one complete compatible one-company batch accepted for review; +- empty input; +- mixed tickers; +- source-ID mismatch; +- unknown source; +- unverified commercial rights; +- approved rights missing one or more required fields; +- explicit Q4 acceptance and constructor-level rejection without filed-quarter evidence; +- post-cutoff observations; +- explicit revision success and ambiguous revision failure; +- incompatible currency, scale, accounting basis, duration basis, or period end; +- missing Revenue and missing component behavior; +- immutable `production_activation=False` and empty readiness promotions; +- no persistence, network, CLI, Make, or generated-artifact surface. + +After implementation, run focused tests, the full suite, dashboard smoke, all four Research route render checks, public wording, public check, commercial-beta checks, pilot readiness, diff hygiene, whitespace checks, and staged hygiene. Stage exact code, documentation, and test paths only. + +## Documentation And PR Update + +Update methodology, provenance, Personal Research guidance, ROADMAP, and the persistent continuation prompt to distinguish: + +- `accepted_for_review`: local contract acceptance only; +- `external_source_and_review_required`: real adapter payload and reviewer evidence still absent; +- production activation: still false; +- market maturity: improved adapter governance, not real-company coverage or validation. + +Update draft PR #113 with the implementation, test evidence, no-file boundary, current source-rights blocker, and exact external resume condition. Keep the PR draft and do not merge or deploy. + +## Completion Boundary + +This slice is complete when the pure acceptance result, deterministic blocker coverage, documentation, and regression gates pass with a clean pushed branch and zero generated artifacts. + +It does not complete the external adapter gate. Real-company cash-generation output remains withheld until a permitted source supplies a reviewed one-company payload and the applicable rights record explicitly supports the required fields. diff --git a/docs/superpowers/specs/2026-07-18-quarterly-cash-generation-contract-design.md b/docs/superpowers/specs/2026-07-18-quarterly-cash-generation-contract-design.md new file mode 100644 index 00000000..8ffa5933 --- /dev/null +++ b/docs/superpowers/specs/2026-07-18-quarterly-cash-generation-contract-design.md @@ -0,0 +1,117 @@ +# Quarterly Cash-Generation Evidence Contract Design + +## Decision + +Add a supplemental in-memory quarterly business-metric contract for operating margin, free cash flow, and FCF margin. Do not add, generate, modify, or stage a CSV, JSON, report, sample report, screenshot, or timing artifact for this slice. + +The existing Earnings Nowcast `QuarterlyActual` Revenue/EPS contract remains unchanged. The new contract is descriptive business-trend evidence only and cannot affect Nowcast ranges, consensus readiness, DCF inputs, peer valuation, research rankings, or recommendations. + +## Why This Still Makes Sense + +The current Company Workbench explicitly withholds operating margin, free cash flow, and FCF margin because those metrics have no versioned quarterly contract. A narrow domain contract closes that methodology gap without pretending that real-company evidence exists. + +The repository currently has no canonical `data/earnings_nowcast/` evidence directory. Adding another generated file would therefore create synthetic-only persistence rather than activate reviewed evidence. Keeping the contract in memory preserves a future source-adapter boundary while honoring generated-artifact hygiene. + +This slice proves calculation and readiness behavior only. It does not prove SEC extraction, source rights, real-company metric availability, or broader evidence depth. + +## Alternatives Considered + +### Extend the existing Revenue/EPS `QuarterlyActual` + +Rejected. It would couple descriptive cash-generation analysis to the stable Earnings Nowcast evidence schema and force unrelated SEC staging and onboarding paths to carry optional fields. + +### Add `quarterly_business_metrics.csv` + +Rejected for this slice. It would introduce a new generated artifact and persistence workflow before a reviewed source path exists. + +### Derive quarterly metrics from annual fundamentals + +Rejected. Annual or trailing fundamentals cannot establish exact fiscal-quarter identity, cutoff availability, definition comparability, revision lineage, or explicit Q4 evidence. + +## Domain Contract + +Add an immutable `QuarterlyBusinessObservation` with: + +- ticker; +- fiscal period and explicit period-end date; +- metric: `operating_income`, `cash_from_operations`, or `capital_expenditures`; +- finite reported value; +- currency, positive unit scale, accounting basis, and duration basis; +- source and durable source reference; +- timezone-aware publication and retrieval timestamps; +- Q4 evidence state; +- optional superseded source reference. + +Capital expenditures retain their reported cash-flow sign. Free cash flow uses one explicit formula: `cash_from_operations + capital_expenditures`. No alternative issuer-defined FCF is silently substituted. + +The constructor rejects missing identity, invalid periods, naive timestamps, non-finite values, unsupported metrics, non-positive scale, and Q4 rows without explicit filed-quarter evidence. + +## Composition And Readiness + +`build_quarterly_trend_packet` accepts supplemental observations as an optional iterable. Existing callers remain valid and continue to receive withheld supplemental metrics when no observations are supplied. + +For each fiscal period: + +1. Deduplicate exact source references. +2. Resolve revisions only through explicit `supersedes_source_ref` lineage. +3. Mark conflicting unversioned leaves ambiguous for the affected component only. +4. Exclude observations published after the requested cutoff. +5. Keep each metric independent; a blocked cash-flow component does not block Revenue or EPS. + +Derived metrics require: + +- operating margin: compatible operating income and Revenue for the same fiscal period; +- free cash flow: compatible cash from operations and capital expenditures for the same fiscal period; +- FCF margin: compatible free cash flow and Revenue for the same fiscal period. + +Compatibility requires matching currency, unit scale, accounting basis, duration basis, fiscal period, and period-end date. Sequential and year-over-year changes use the existing exact-period comparison rules and remain withheld when a matching period or compatible definition is unavailable. + +Readiness states remain independent: + +- `ready`: latest value plus compatible sequential and year-over-year comparisons; +- `partial`: a source-backed latest value exists but one or both comparisons are unavailable; +- `blocked`: required components are missing, ambiguous, post-cutoff, or incompatible; +- `withheld`: no supplemental evidence was supplied. + +## Product Integration + +Company Workbench keeps the answer-first Business Trend section. When explicit observations are supplied by a future reviewed adapter, the existing trend table and cards may show operating margin, free cash flow, and FCF margin beside Revenue and EPS. + +Without such observations, production behavior remains unchanged: all three supplemental metrics display `withheld`, no numeric placeholder appears, and the next step states that a reviewed quarterly source adapter is required. + +Raw components, formula lineage, definitions, ambiguity reasons, and source references remain under Advanced Evidence. No new route, data writer, command, template, or generated artifact is added. + +## Failure Boundaries + +- Never derive Q4 from annual minus nine-month values. +- Never infer capital expenditure sign or issuer-defined FCF treatment. +- Never combine currencies, scales, period ends, duration bases, or accounting definitions. +- Never use retrieval time as publication time. +- Never let supplemental observations satisfy Earnings Nowcast, consensus, DCF, peer, catalyst, outcome, backtest, or calibration readiness. +- Synthetic observations remain test-only and must identify themselves as fixtures. +- Empty input remains visibly withheld rather than fabricated. + +## Testing + +Tests use in-memory synthetic dataclasses only; they do not write temporary or repository CSV/JSON files. + +Cover: + +- constructor validation and Q4 evidence enforcement; +- exact revision resolution and affected-component ambiguity; +- cutoff filtering; +- operating-margin, FCF, and FCF-margin formula lineage; +- currency, scale, accounting-basis, duration-basis, fiscal-period, and period-end incompatibility; +- independent ready, partial, blocked, and withheld states; +- sequential and year-over-year comparison behavior; +- unchanged Revenue/EPS results and Nowcast contracts; +- Company Workbench rows/cards for ready and withheld supplemental evidence; +- no writer, template, CLI target, or generated-artifact path introduced by the slice. + +After implementation, run the focused modules, full test suite, dashboard smoke, Research route render smoke, public wording, public check, pilot readiness, diff hygiene, whitespace checks, and staged hygiene. Stage exact code, documentation, and test paths only. + +## Completion Boundary + +This slice is complete when the in-memory contract, independent readiness, answer-first rendering, documentation, and regression tests pass with no generated artifact created or staged. + +Real-company operating margin, free cash flow, and FCF margin remain unproven and withheld until a separately reviewed source adapter supplies compatible observations with appropriate rights and lineage. diff --git a/docs/superpowers/specs/2026-07-18-readiness-change-cause-review-design.md b/docs/superpowers/specs/2026-07-18-readiness-change-cause-review-design.md new file mode 100644 index 00000000..196ff42d --- /dev/null +++ b/docs/superpowers/specs/2026-07-18-readiness-change-cause-review-design.md @@ -0,0 +1,62 @@ +# Readiness Change Cause Review Design + +## Purpose + +The no-write readiness preview reports 652 changed tickers. The promotion-evidence review explains 152 fundamentals promotions and 146 overlapping DCF promotions, but most remaining movement is still presented only as changed field names. Reviewers cannot readily distinguish a deliberate method exclusion from a partial-input transition or a new universe row. + +Add a semantic, reason-coded change-cause review to the same stdout-only command. It must explain the movement without altering readiness, source inputs, exclusion rules, or generated artifacts. + +## Approaches Considered + +1. **Semantic transition review with named exclusion reasons (selected).** Expose stable reason codes from the existing company-scope method and summarize newly ready, partial, excluded, added, and removed transitions independently. +2. **Raw field-signature counts.** Easier, but labels such as `blocked_features -> excluded_features` still require implementation knowledge. +3. **Documentation-only explanation.** Non-repeatable and likely to become stale when the universe or method changes. + +## Company DCF Exclusion Reasons + +Refactor the current ordered regular expressions into named, behavior-equivalent reasons: + +- `non_operating_asset_type`; +- `acquisition_or_spac`; +- `closed_end_fund`; +- `bank_or_bancorp`; +- `financial_insurance_or_mortgage`; +- `reit`; +- `realty_trust_or_bdc`; +- `capital_corporation`; +- `nonpositive_revenue_margin_model`. + +`company_dcf_exclusion_reasons(...)` returns every matching reason in deterministic order. Existing boolean helpers delegate to it so the current exclusion decision does not change. The preview uses the first reason as the primary mutually exclusive summary reason and reports any unexplained exclusion separately. + +## Change Review + +For saved versus proposed frames, summarize independently: + +- added and removed ticker rows; +- newly ready feature counts; +- newly partial feature counts; +- newly excluded feature counts; +- primary reasons for newly excluded DCF rows; +- unexplained newly excluded DCF rows. + +Counts are transition counts, not current readiness counts and not mutually exclusive ticker cohorts. The output says so explicitly. Promotion evidence remains a separate section because technical movement, provenance, commercial rights, and field scope answer different questions. + +## Failure Behavior + +- Missing metadata or fundamentals remains an explicit `unexplained` DCF exclusion; no reason is inferred. +- Duplicate fundamentals evidence remains fail-closed and cannot be selected arbitrarily. +- Unknown feature names remain visible in transition counts. +- Missing saved readiness preserves the existing failure behavior. +- No transition changes any exclusion decision or source row. + +## No-Write and Product Boundaries + +- Reuse the one existing in-memory production readiness build. +- Add no output path, writer, JSON mode, cache, screenshot, report, or timing artifact. +- Do not treat exclusions as negative company signals or investment conclusions. +- Do not convert partial inputs into ready inputs. +- Do not authorize the separately reviewed readiness rebuild. + +## Testing and Completion + +Test first for behavior-equivalent reason helpers, overlapping matches, transition counts, primary-reason totals, unresolved metadata, and filesystem preservation. Complete when the real preview explains all newly excluded DCF rows or reports an exact unexplained count; focused/full tests and all required product gates pass; exact files are committed and pushed; ROADMAP, methodology/provenance, continuation prompt, and draft PR #113 are updated; generated churn remains zero. diff --git a/docs/superpowers/specs/2026-07-18-readiness-impact-preview-design.md b/docs/superpowers/specs/2026-07-18-readiness-impact-preview-design.md new file mode 100644 index 00000000..1ee51814 --- /dev/null +++ b/docs/superpowers/specs/2026-07-18-readiness-impact-preview-design.md @@ -0,0 +1,105 @@ +# Readiness Impact Preview Design + +## Purpose + +The saved readiness snapshot is truthfully stale because declared source dates are newer than its build time. The repository's current unblock command, `make readiness`, rewrites multiple CSV reports. The active continuation contract explicitly forbids generating that churn without separate approval. + +Add a deterministic, read-only preview that computes the proposed readiness state in memory, compares it with the saved snapshot, and prints the expected impact without creating, modifying, or deleting any file. + +## Selected Approach + +Thread an explicit no-write option through the existing universe and readiness builders, then add a small preview module and Make target. + +This approach is preferred because it executes the same readiness logic that a later reviewed rebuild would use. A temporary-directory rebuild was rejected because it still generates CSV files. A metadata-only freshness explanation was rejected because it cannot reveal which readiness states would change. + +## Architecture + +### Pure universe preparation + +`ensure_universe_files(..., write_outputs=True)` keeps its current default behavior. When `write_outputs=False`, it may construct or repair the master and active frames in memory, but it must not write canonical universe files. + +`build_universe_coverage_report(..., write_output=True)` keeps its current default behavior. Its no-write mode calls the universe helper with `write_outputs=False` and returns the coverage frame without creating the reports directory or CSV. + +### Pure readiness build + +`build_ticker_readiness_report(..., write_outputs=True)` keeps its current default behavior. In no-write mode it: + +- loads the same selected-profile inputs; +- calls only no-write universe preparation; +- builds the same report frames; +- returns all frames in memory; +- skips every report, compatibility-copy, and output write; +- creates no directories. + +Default callers remain unchanged and continue writing their existing artifacts. + +### Impact preview + +A focused `src.readiness_preview` module will: + +1. load the saved ticker-readiness report; +2. call `build_ticker_readiness_report(..., write_outputs=False)`; +3. compare only stable readiness fields, excluding volatile timestamps and explanatory text that does not change readiness; +4. report saved and proposed counts for overall state plus price, fundamentals, DCF, peer, earnings, and analyst-estimates readiness; +5. report capped changed tickers and changed stable fields; +6. state explicitly that the preview wrote no files and did not make readiness current. + +The command is `make readiness-preview TOP_N=20`. It prints to stdout only. It has no output-path option and no JSON/file mode. + +## Stable Comparison Contract + +The preview compares: + +- `overall_readiness_state`; +- `price_ready`; +- `momentum_ready`; +- `fundamentals_ready`; +- `dcf_ready`; +- `peer_ready`; +- `earnings_ready`; +- `analyst_estimates_ready`; +- `ready_features`; +- `partial_features`; +- `blocked_features`; +- `excluded_features`. + +It does not compare `updated_at`, generated timestamps, command copy, source-status attempt times, or other volatile presentation fields. + +## Failure Behavior + +- Missing saved readiness: return `missing_saved_snapshot`; show that comparison is unavailable; do not write a replacement. +- Missing canonical inputs: preserve the readiness engine's current fail-closed result or error; do not create fallback files. +- In-memory build failure: return a non-zero CLI result with a concise error; leave the filesystem unchanged. +- No stable changes: report `no_readiness_changes`; stale freshness remains stale until a reviewed rebuild is intentionally run. +- Stable changes: report `changes_detected`; this is preview evidence only and does not authorize or perform a rebuild. + +## Safety Boundaries + +- No CSV, JSON, report, sample report, screenshot, timing output, or directory is created. +- No source row, readiness state, proof ledger, journal, consensus snapshot, valuation, catalyst, outcome, backtest, or calibration state is mutated. +- Candidate context cannot enter the build as trusted evidence. +- The preview does not prove data rights, source freshness, correctness, or pilot readiness. +- `make readiness` remains a separate intentional write action requiring generated-artifact review. +- Research-only language remains unchanged; the command provides no ranking, recommendation, forecast, probability, or transaction instruction. + +## Testing + +Test-first coverage will prove: + +1. no-write universe preparation returns expected frames without creating canonical files; +2. no-write universe coverage creates no report directory or CSV; +3. no-write readiness returns the expected frames while a before/after filesystem manifest remains identical; +4. preview comparison ignores timestamp-only differences; +5. preview reports stable readiness changes and caps ticker detail; +6. missing saved readiness fails closed without writing; +7. the Make target and documentation preserve the no-artifact and no-unlock boundaries. + +After focused tests, run the full repository, dashboard, Research render, public, commercial-beta, pilot, whitespace, and hygiene gates. The pilot verdict is expected to remain blocked by stale readiness because this slice intentionally does not rebuild it. + +## Completion Criteria + +- `make readiness-preview TOP_N=20` runs against the current repository without changing `git status` or any filesystem content. +- The command reports the saved-versus-proposed stable readiness impact. +- Existing write-mode behavior remains regression-tested and unchanged. +- No generated artifact is staged or committed. +- ROADMAP, data strategy, the continuation prompt, and draft PR #113 state that preview evidence is not a readiness rebuild or pilot unlock. diff --git a/docs/superpowers/specs/2026-07-18-readiness-promotion-evidence-review-design.md b/docs/superpowers/specs/2026-07-18-readiness-promotion-evidence-review-design.md new file mode 100644 index 00000000..1edea993 --- /dev/null +++ b/docs/superpowers/specs/2026-07-18-readiness-promotion-evidence-review-design.md @@ -0,0 +1,101 @@ +# Readiness Promotion Evidence Review Design + +## Purpose + +`make readiness-preview TOP_N=20` truthfully shows substantial proposed fundamentals and DCF readiness movement without writing generated artifacts. The current preview does not explain whether those promotions have complete provenance, an exact source-rights decision, or explicit field support in the checked-in rights registry. A reviewed readiness rebuild must not be authorized from numerical completeness alone. + +Extend the existing no-write preview with a fail-closed promotion-evidence review. The review remains inspection evidence only: it does not change technical readiness, source rights, canonical inputs, or the separate rebuild boundary. + +## Approaches Considered + +1. **Extend the existing preview (selected).** Keep `make readiness-preview` as the only stale-readiness continuation command and add an independently labeled evidence section. This preserves the operating contract and makes the large proposed movement explainable in one place. +2. **Add a second provenance-preview command.** This would keep modules smaller, but it would create two continuation commands and require every stale-state surface to explain their ordering. +3. **Document a manual join.** This avoids code but produces non-repeatable evidence and leaves reviewers to reconcile hundreds of rows by hand. + +## Evidence Model + +The review examines only tickers moving from false to true for `fundamentals_ready` or `dcf_ready` between the saved and proposed in-memory readiness frames. + +For each promoted ticker it records: + +- which technical readiness flags would promote; +- the exact canonical fundamentals `source` value; +- the canonical `as_of_date`; +- a durable SEC accession when present; +- the exact commercial-rights decision for the complete source value; +- required fundamentals fields not listed in that exact rights record; +- missing provenance fields; +- a capped sample for review. + +The source string is treated as one exact source identifier. Composite or unregistered values are not split, normalized into a different provider, or silently granted the rights of one component. That preserves the existing fail-closed registry contract. + +## Independent States + +The output keeps four questions separate: + +1. **Technical promotion:** would production readiness logic move the saved flag? +2. **Provenance completeness:** are source, as-of date, and a durable source reference present? +3. **Commercial rights:** is the exact source identifier explicitly approved? +4. **Field scope:** does that exact approved record list every required field for the promoted fundamentals gate? + +A technically complete row can therefore remain `evidence_review_required`. The review never changes the proposed readiness frame. DCF also depends on price evidence; because the canonical price table has no row-level provider/source columns, the output explicitly states that this review does not establish complete DCF commercial provenance. + +## Required Field Contract + +The current fundamentals readiness gate requires: + +- `revenue`; +- `free_cash_flow` (or `fcf`); +- `fcf_margin`; +- `shares_outstanding`. + +The evidence review uses the canonical names `revenue`, `free_cash_flow`, `fcf_margin`, and `shares_outstanding` when checking the registry. It reports missing registry support without editing the registry or inferring that a derived value is licensed. + +## Statuses + +- `no_promotions`: neither named readiness flag promotes. +- `evidence_review_complete`: every promoted row has complete provenance, approved exact-source rights, and complete registered field scope. +- `evidence_review_required`: at least one promoted row lacks one of those gates. + +Even `evidence_review_complete` is not rebuild authorization. Reviewer approval, full readiness review, price-source review for DCF, and the separately authorized write boundary remain open. + +## Failure Behavior + +- Missing canonical fundamentals columns fail closed as missing evidence; no fallback value is invented. +- Duplicate canonical ticker rows fail closed for that ticker instead of selecting an arbitrary row. +- A missing or invalid rights registry makes the command fail with its existing concise no-write error path. +- Missing saved readiness keeps the existing `missing_saved_snapshot` result and does not attempt the evidence review. +- `TOP_N` caps row details but never changes total counts. + +## Output + +The existing saved-versus-proposed counts remain first. A new `Promotion Evidence Review` section then prints: + +- total unique promotions, fundamentals promotions, and DCF promotions; +- exact source-value counts; +- rights-status counts; +- complete/incomplete provenance counts; +- complete/incomplete registered field-scope counts; +- capped ticker evidence rows and blockers; +- the separate rebuild, DCF price-provenance, research-only, and no-write boundaries. + +No JSON, CSV, report, screenshot, timing, cache, or output-path mode is added. + +## Testing + +Test-first coverage will prove: + +1. only false-to-true fundamentals/DCF changes enter the review; +2. exact registered approved sources remain distinct from composite and unknown labels; +3. missing as-of date, durable reference, required field support, duplicate ticker rows, and missing columns fail closed; +4. detail is capped without changing totals; +5. the integrated preview builds in memory and leaves the filesystem manifest unchanged; +6. wording never equates technical readiness movement with source correctness, commercial permission, current readiness, or rebuild approval. + +## Completion Criteria + +- `make readiness-preview TOP_N=20` explains the current proposed promotions with exact source/provenance/rights/field-scope evidence. +- The current repository remains byte-for-byte unchanged by the command. +- Technical readiness and commercial/evidence review states remain independent. +- ROADMAP, methodology/provenance documentation, the continuation prompt, and draft PR #113 record the verified boundary. +- No generated artifact is staged or committed. diff --git a/docs/superpowers/specs/2026-07-18-research-desk-answer-first-design.md b/docs/superpowers/specs/2026-07-18-research-desk-answer-first-design.md new file mode 100644 index 00000000..ce5d8837 --- /dev/null +++ b/docs/superpowers/specs/2026-07-18-research-desk-answer-first-design.md @@ -0,0 +1,41 @@ +# Research Desk Answer-First Design + +## Purpose + +Research Desk should answer the researcher's weekly questions before showing technical cohort scope and lane coverage. The current route renders four focused-cohort cards before the weekly summary, the four direct research answers, and the Discover action. Those cards are truthful, but they delay the primary review path. This slice changes presentation hierarchy only; it does not change cohort selection, coverage states, weekly-summary derivation, change events, readiness, or navigation. + +## Product Contract + +The Research Desk route will render in this order: + +1. Personal Research workspace header with freshness, research-only boundary, and the Discover next action. +2. `Weekly research summary` and its existing traceable summary cards. +3. The existing four direct answers: what changed, which companies need attention, what is blocked or stale, and what to review next. +4. The existing `Open Discover` primary action. +5. One collapsed `Advanced Evidence` drawer containing the existing focused-cohort cards, cohort-coverage cards, full cohort and coverage frames, weekly rows, and evidence links. +6. The existing research-change evidence drawer. + +The cohort cards remain available and unchanged. Closing the drawer does not broaden eligibility, infer lane coverage, or turn a no-change state into proof that no real-world event occurred. + +## Architecture + +`src/dashboard.py` remains the composition owner. `render_research_desk()` will move the two existing `render_signal_cards(...)` calls from above the weekly summary to the top of the existing collapsed `Advanced Evidence` block. No helper signature, card payload, state calculation, query parameter, or route mapping changes. + +`tests/test_research_mode_dashboard_contract.py` will protect the complete source order and confirm that concise and full cohort evidence remain inside Advanced. `src/public_performance_gate.py` will change Research Desk's first-useful marker from the route title to `Weekly research summary` and use only visible answer-first markers for full settle. `tests/test_public_performance_gate.py` will protect that contract. + +## Failure And Boundary Behavior + +- Empty weekly summaries retain their existing truthful no-traceable-change message. +- Empty or partial cohorts retain their existing blocked or partial cards under Advanced. +- Full company-by-lane rows remain available and are never replaced by an aggregate claim. +- Actuals, consensus, Revenue, EPS, valuation, catalysts, outcomes, backtesting, and calibration remain independent. +- Candidate context cannot alter a forecast, readiness state, or trusted evidence. +- The change writes no data and creates no generated report artifact. + +## Documentation + +`docs/PERSONAL_RESEARCH_MODE.md` will document the Research Desk answer-first order. `docs/DASHBOARD_QA.md` will record desktop and phone first-view evidence. `ROADMAP.md` will record the verified local slice and leave Monitor as the final Stage 1 route audit. + +## Verification + +The route-order and performance-marker contracts must fail before implementation and pass afterward. Focused route/workspace tests, the full repository suite, dashboard smoke, research render smoke, the 48-sample commercial performance gate, public wording, public check, pilot readiness, diff hygiene, whitespace checks, and staged hygiene remain required. Live review must cover `1280x720` and `390x844`, keep Advanced closed, retain the weekly answer and Discover action, and show no horizontal overflow. Screenshots, timing JSON, CSV, JSON, report, and sample-report churn remain excluded. diff --git a/docs/superpowers/specs/2026-07-18-stale-readiness-continuation-gate-design.md b/docs/superpowers/specs/2026-07-18-stale-readiness-continuation-gate-design.md new file mode 100644 index 00000000..dcc1d154 --- /dev/null +++ b/docs/superpowers/specs/2026-07-18-stale-readiness-continuation-gate-design.md @@ -0,0 +1,89 @@ +# Stale Readiness Continuation Gate Design + +## Problem + +The selected default profile is truthfully stale because declared source dates are newer than the saved readiness build. Pilot readiness correctly routes inspection to `make readiness-preview TOP_N=20`, but adjacent read-only operator surfaces still expose conflicting next actions: + +- `project-status-check` recommends broad price planning and trusted-data candidate ranking; +- `provider-setup-checklist` reports `sec_fundamentals_share_count` as runnable and sends the operator to `coverage-frontier` even while its own non-retry copy says not to repeat the fundamentals/share-count source ladder; +- `coverage-frontier` presents proof sequences containing `make readiness` without first stating that stale readiness makes those rows planning context only. + +These contradictions can restart exhausted provider or coverage loops and can lead an operator toward generated readiness churn despite the active no-write continuation boundary. + +## Decision + +Add one pure, shared continuation-gate contract derived from the selected-profile `ProfileContext`. Consume it in project status, provider setup, and coverage-frontier rendering. + +When readiness is `stale`, the gate reports: + +- state: `inspection_only`; +- next safe command: `make readiness-preview TOP_N=20`; +- rebuild boundary: `make readiness` remains a separate intentional reviewed write; +- stop rule: do not start broad refresh, proof, apply, or readiness rebuild work from stale counts. + +When readiness is `mixed` or `missing`, the gate remains fail-closed and routes to the same no-write preview, which reports the missing-snapshot condition without writing data. When readiness is `current`, the gate does not override existing source and coverage routing. + +## Alternatives Considered + +### Documentation-only warning + +Rejected because runtime commands would continue to contradict the documentation. + +### Project-status-only override + +Rejected because provider setup and coverage planning would still expose broader actions and `make readiness` proof sequences without the same gate. + +### Shared continuation gate + +Selected because one deterministic contract can be tested independently and consumed consistently without changing readiness calculations, source availability, provider rights, canonical data, or generated artifacts. + +## Architecture + +Create `src/continuation_gate.py` with an immutable `ContinuationGate` value and a pure `build_continuation_gate(ProfileContext)` function. + +The gate is presentation and routing evidence only. It does not run the preview, refresh sources, import rows, apply data, rebuild readiness, or claim that the preview makes readiness current. + +Consumers: + +1. `src/project_status.py` + - build the selected profile context once in the CLI; + - print a continuation-safe answer before coverage counts; + - while the gate is fail-closed, replace broad recommended local commands with the no-write preview and suppress top locked-input action commands that depend on stale readiness counts; + - keep saved counts visible and explicitly labeled as saved/stale evidence. +2. `src/source_activation_guide.py` + - override only the current-session action fields when the continuation gate is fail-closed; + - keep provider availability and external-key classifications visible; + - make `can_run_now`, `next_step`, and `next_step_reason` agree with the no-write inspection boundary. +3. `src/readiness_ops.py` + - add the continuation gate to coverage-frontier rendering; + - keep ranked lanes visible as planning context; + - state that refresh, apply, proof, and rebuild commands below the gate are not executable while readiness is stale. + +## Data And Readiness Boundaries + +- `ProfileContext.freshness_state` remains the authoritative selected-profile freshness signal. +- `make readiness-preview TOP_N=20` remains no-write inspection evidence only. +- `make readiness` remains the explicit write-producing rebuild boundary and is never executed by the gate. +- Actuals, consensus, Revenue, EPS, operating margin, free cash flow, FCF margin, valuation, peers, catalysts, outcomes, backtesting, and calibration stay independent. +- No source, provider, key, rights state, candidate, peer, event, forecast, probability, or readiness state is inferred or promoted. + +## Error Handling + +- Unknown freshness states fail closed to `inspection_required` and the no-write preview command. +- The gate never catches or converts source/data errors into readiness. +- Missing saved readiness remains missing; the preview may return its existing nonzero missing-snapshot status while preserving no-write behavior. + +## Testing + +1. Unit-test current, stale, mixed, missing, and unknown continuation-gate states. +2. Add project-status tests proving stale output presents the preview first and omits broad price/trusted-data recommendations. +3. Add provider-checklist tests proving stale selected-profile context overrides stale preflight action fields while preserving missing-key classifications. +4. Add coverage-frontier tests proving stale rendering labels ranked rows planning-only and names the separate reviewed rebuild boundary. +5. Run focused tests, the complete repository suite, dashboard and research render smoke, public wording/public checks, commercial-beta checks, pilot readiness, diff hygiene, and staged hygiene. + +## Non-Goals + +- No readiness rebuild. +- No CSV, JSON, report, sample-report, screenshot, timing, or bytecode output. +- No provider probe, source refresh, import, apply, proof record, or broad coverage run. +- No change to the hosted, reviewer, source-rights, trusted-peer, quarterly-adapter, calibration, or operated-platform external dependency classifications. diff --git a/docs/superpowers/specs/2026-07-19-cohort-price-history-scope-design.md b/docs/superpowers/specs/2026-07-19-cohort-price-history-scope-design.md new file mode 100644 index 00000000..16088fb7 --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-cohort-price-history-scope-design.md @@ -0,0 +1,46 @@ +# Cohort Price-History Scope Enforcement Design + +## Status + +Approved by the owner-supplied Priority 2 audit contract on 2026-07-19. This slice covers adjusted daily price history in focused-cohort Commercial Research coverage only. It does not change the direct refresh guard, staged-price apply, DCF lineage preview, canonical price schema, or quarterly actuals. + +## Problem + +`build_focused_cohort_coverage()` currently labels adjusted daily price history `usable_now` whenever saved readiness says `price_ready`. In Commercial Research mode, the dashboard passes no canonical price rows into the cohort evidence review, so the lane never checks technical row availability, row provenance, exact-source rights, or registered `prices` scope. + +The current canonical `data/prices.csv` has OHLCV fields but no `source`, `source_ref`, or `retrieved_at`. That history can remain useful for local research, but it cannot truthfully become commercially supported price evidence. + +## Design + +Add an optional `prices` frame to `derive_cohort_evidence()` and load it through the existing read-only dashboard path. For each cohort ticker: + +- technical history exists only when at least one row has a date and positive adjusted close or close; +- research mode preserves existing saved-readiness behavior; +- Commercial Research mode requires every technically retained history row to carry exact `source`, durable `source_ref`, and explicit `retrieved_at` provenance; +- every exact source must have approved commercial rights and literal `prices` scope; +- blank, unknown, composite, unapproved, scope-incomplete, or mixed-source rows fail the lane closed; +- the saved `price_ready` flag remains independent and necessary, but is no longer sufficient. + +The result adds a price evidence state and explanation to the existing evidence map. `build_focused_cohort_coverage()` conjuncts it only when supplied. This preserves compatibility for direct research-mode callers while making the Commercial dashboard fail closed. + +## Evidence Boundary + +Advanced cohort evidence names whether the blocker is missing technical rows, missing row provenance, unapproved exact-source rights, or missing `prices` scope. It does not expose raw price rows in the primary answer. + +This review does not infer a provider from a file name, adapter, warning, date, value shape, or earlier refresh. It does not validate retrieval chronology; timezone and cutoff enforcement belong to Priority 5. It does not approve a source, rewrite history, rebuild readiness, or invalidate local research use. + +## Testing + +Tests must prove: + +- saved `price_ready=true` plus missing price rows blocks Commercial Research price coverage; +- canonical-shaped rows without lineage remain blocked; +- an injected approved exact source with `prices` scope and complete lineage can pass; +- an approved source without `prices` scope remains blocked; +- one unproven row makes a mixed history blocked; +- research mode retains the existing readiness-backed lane; +- dashboard loading uses the canonical price frame read-only and shows the blocker under Advanced evidence. + +## Remaining Priority 2 Work + +Canonical quarterly Revenue and EPS still require their own field-specific rights/scope integration, including partial-ledger and EPS split-basis behavior. This price slice does not address them. diff --git a/docs/superpowers/specs/2026-07-19-cohort-quarterly-field-scope-design.md b/docs/superpowers/specs/2026-07-19-cohort-quarterly-field-scope-design.md new file mode 100644 index 00000000..87615c25 --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-cohort-quarterly-field-scope-design.md @@ -0,0 +1,42 @@ +# Cohort Quarterly Field-Scope Design + +## Problem + +Commercial focused-cohort coverage builds quarterly Revenue and EPS states from +technically valid trend packets. It does not yet require every retained source +row for each metric to pass the exact source-rights and supported-field review. +That can reuse a source's Revenue permission for EPS even when EPS is outside the +registered commercial scope. + +## Decision + +Derive independent commercial reviews for quarterly Revenue and quarterly EPS +from the accepted canonical `QuarterlyActual` rows. A metric is commercially +usable only when it has at least one populated row and every populated row has: + +- a source, source reference, and retrieval timestamp; +- an exact source whose commercial use is approved; and +- the matching registered field scope (`revenue` or `eps`). + +The commercial review is conjunctive with the existing trend-packet state. A +commercial blocker therefore fails the lane closed even when a technical packet +could otherwise render a comparison. Revenue and EPS remain independent. + +Research mode keeps the existing packet-only behavior. This slice does not +change EPS split-basis policy, canonical-loader rejection policy, Q4 policy, +consensus readiness, calibration, or any generated artifact. + +## Display boundary + +The primary cohort answer remains a compact lane state. Exact source-rights or +field-scope blockers remain in the evidence text shown under Advanced. No value, +forecast, probability, recommendation, or synthetic fallback is created. + +## Verification + +- Prove Revenue can remain usable when its exact source is approved for Revenue. +- Prove EPS blocks independently when that same source lacks EPS scope. +- Prove unknown/unapproved and mixed-source metric rows fail closed. +- Prove Research mode retains the existing packet behavior. +- Prove the dashboard passes accepted canonical actuals into the commercial + evidence derivation without writing or repairing the ledger. diff --git a/docs/superpowers/specs/2026-07-19-cohort-saved-row-field-scope-design.md b/docs/superpowers/specs/2026-07-19-cohort-saved-row-field-scope-design.md new file mode 100644 index 00000000..08f8c747 --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-cohort-saved-row-field-scope-design.md @@ -0,0 +1,89 @@ +# Cohort Saved-Row Field-Scope Enforcement Design + +## Status + +Approved by the owner-supplied independent-audit continuation contract on 2026-07-19. This is the first coherent slice of Priority 2. It covers saved fundamentals, earnings-date, consensus, and trusted-peer rows used by focused-cohort coverage. Price readiness and canonical quarterly-actual packets remain separate follow-up slices. + +## Problem + +`derive_cohort_evidence()` currently uses one source-level commercial approval as permission for every populated field on a row. The checked-in SEC Companyfacts record approves commercial use only for `revenue`, `shares_outstanding`, and `filing_dates`. If a saved SEC row also contains a margin, free-cash-flow, cash, or debt value, the current cohort code can label that unrelated lane commercially usable without registered field scope. + +The same source-level shortcut is reused for earnings dates, consensus, and trusted peers. Technical availability, provenance, commercial rights, and lane-specific registered scope are therefore collapsed into one boolean. + +## Selected Scope + +Replace the shared permission boolean for these saved-row lanes: + +- margins, using the exact populated field among `operating_margin`, `fcf_margin`, and `profit_margin`; +- free cash flow, using `free_cash_flow` or `fcf`; +- cash and debt, reviewed independently as `cash` and `debt`; +- shares outstanding, using `shares_outstanding`; +- filing dates, using `filing_dates`; +- earnings dates, using `earnings_dates`; +- point-in-time consensus, using each populated `revenue_consensus` and `eps_consensus` metric independently; +- trusted-peer evidence, using `trusted_peers` for every trusted relationship row. + +Aliases select a local value but never change the exact registered field required for commercial support. Composite or blank source IDs remain unknown. + +## Independent Decisions + +For every lane, retain four independent facts: + +1. technical value availability; +2. provenance presence (`source` plus a durable `source_ref` or SEC accession); +3. exact-source commercial-rights approval; +4. exact registered field scope. + +Commercial `usable_now` requires the conjunction applicable to that lane. Missing scope cannot erase technical availability or provenance, and technical availability cannot grant rights. Evidence text names the failed dimension and exact missing field under the existing Advanced cohort coverage surface. + +Research mode preserves its current source-backed behavior and does not inherit the commercial license gate. + +## Lane Rules + +### Margins and free cash flow + +Review only fields that are actually populated. A source approved for Revenue or shares cannot unlock a margin or free-cash-flow value. If multiple margin aliases are populated, any individually approved exact field can support the lane; unsupported siblings do not borrow permission. + +### Cash and debt + +Review cash and debt independently. Both supported values produce `usable_now`; exactly one supported value produces `partial`; neither produces `blocked`. An unsupported populated value remains technically present but commercially unavailable. + +### Shares and filing dates + +Use the checked-in `shares_outstanding` and `filing_dates` scopes. SEC Companyfacts may support these without supporting margins, FCF, cash, debt, EPS, consensus, earnings dates, or peers. + +### Earnings dates + +Require an explicit saved date, provenance, approved exact-source rights, and registered `earnings_dates` scope. + +### Consensus + +Require a fiscal period, a timestamp no later than the review cutoff, provenance, and at least one populated Revenue or EPS consensus value. Each populated metric requires its own registered scope. A Revenue-only source cannot unlock EPS and vice versa. The lane is usable only when every populated metric passes; an empty date-only row is blocked. + +### Trusted peers + +Every saved trusted relationship row must retain provenance and pass exact-source rights plus `trusted_peers` scope. Candidate rows remain `candidate_context_only` and never enter the trusted decision. + +## Output Contract + +Keep the existing cohort lane states and frame shape. Strengthen the evidence text for Commercial Research mode so Advanced coverage can distinguish technical, provenance, rights, and field-scope blockers without exposing raw registry records in the primary answer. + +No readiness flag, canonical row, source-rights record, forecast, valuation, peer role, or candidate state changes. + +## Testing + +Tests must prove: + +- the checked-in SEC registry supports shares and filing dates but blocks margin, FCF, cash, and debt from the same row; +- an injected source with one approved field cannot unlock sibling lanes; +- cash and debt remain independent; +- Revenue-only and EPS-only consensus scopes do not borrow from each other; +- date-only consensus is blocked; +- trusted peers require every saved row to have `trusted_peers` scope; +- candidate peers remain candidate-only; +- research mode compatibility remains intact; +- dashboard/Advanced cohort contracts retain the truthful lane states and no recommendation language. + +## Boundaries + +This slice does not approve or fetch a source, edit the registry, validate canonical quarterly actuals, change price readiness, rebuild readiness, generate artifacts, activate consensus, or prove market operation. Priority 2 remains open for adjusted price history and quarterly Revenue/EPS enforcement after this slice. diff --git a/docs/superpowers/specs/2026-07-19-commercial-field-scope-review-design.md b/docs/superpowers/specs/2026-07-19-commercial-field-scope-review-design.md new file mode 100644 index 00000000..54854f8f --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-commercial-field-scope-review-design.md @@ -0,0 +1,110 @@ +# Commercial Field-Scope Review Design + +## Purpose + +Consensus source validation and prospective consensus collection currently implement the same commercial-evidence decision independently. Both resolve an exact source ID, evaluate checked-in commercial rights, derive the fields required by populated Revenue/EPS values, compare those fields with the exact registry record, and combine rights plus scope into one evidence state. + +That duplication can drift even though the two paths must agree. Add one pure immutable field-scope decision in the source-rights module and make both consensus paths consume it while retaining their separate technical, cutoff, lineage, candidate/history, preview, and write contracts. + +This is a local decision-contract refactor. It does not edit the source-rights registry, approve a provider, fetch data, record a snapshot, rebuild readiness, or write generated artifacts. + +## Approaches Considered + +### Selected: domain-neutral structured decision + +Add a frozen `CommercialFieldScopeReview` and a pure `review_commercial_field_scope(...)` function to `commercial_source_rights.py`. The function returns exact-source rights, the ordered required fields, ordered missing fields, and the combined commercial-evidence state. + +It does not generate consensus-specific blocker strings. The source validator and collector continue to render their existing `commercial_rights:*` and `registered_consensus_scope_missing:*` evidence so their public contracts remain stable. + +### Rejected: consensus helper in the collector + +Putting shared logic in `earnings_consensus_collector.py` would make the read-only source validator depend on a mutation-oriented module and would place general registry semantics below a domain consumer. + +### Rejected: configurable blocker-string generator + +Passing prefixes or callbacks into the shared function could remove a few additional lines, but it would mix domain presentation with the underlying rights/scope decision and create an unnecessary configuration surface. + +## Shared Contract + +Add: + +```python +@dataclass(frozen=True) +class CommercialFieldScopeReview: + source_id: str + rights_status: str + commercial_rights_approved: bool + required_supported_fields: tuple[str, ...] + missing_supported_fields: tuple[str, ...] + commercial_evidence_ready: bool +``` + +And: + +```python +def review_commercial_field_scope( + registry: Mapping[str, SourceRights], + source_id: str, + required_fields: Sequence[str], +) -> CommercialFieldScopeReview: +``` + +The function will: + +1. trim the source ID without splitting, aliasing, or lowercasing it; +2. trim each required field while preserving supplied order; +3. reject blank or duplicate required fields as caller-contract errors; +4. reuse `commercial_eligibility` for the exact source-rights decision; +5. use only the exact registry record's `supported_fields`; +6. retain each required field absent from that record in stable order; +7. set `commercial_evidence_ready` only when rights are approved and no required field is missing. + +An empty required-field sequence is valid and represents a source-rights-only decision. Unknown and composite source IDs remain exact unknown values with every non-empty required field missing. + +## Consumer Integration + +### Prospective collector + +`_collection_preview` derives the populated Revenue/EPS field tuple exactly as it does today, calls the shared review once, and maps its fields into the existing `CollectionPreview`. Technical `write_allowed`, `commercial_write_allowed`, snapshot identity, reasons, blockers, batch behavior, and pre-mutation guard remain unchanged. + +### Upstream source validator + +The validator calls the shared review with no required fields for its source-level rights result, then once per technically accepted row with that row's populated Revenue/EPS fields. It maps the shared review into the existing row and aggregate result types. Technical rejections, review cutoff, history scope, candidate/history counts, row numbering, and `auto_apply=false` remain unchanged. + +Rows rejected technically never receive a row-level commercial review. Source-level exact rights remain visible even when every row is rejected. + +## Error And Boundary Behavior + +- Blank required field: raise `ValueError` before returning a decision. +- Duplicate required field: raise `ValueError`; do not silently deduplicate caller mistakes. +- Empty required fields: rights-only decision; ready only if rights are approved. +- Unknown source: `unknown_source`, rights false, all required fields missing. +- Registered unverified source: rights false; field-scope completeness remains independently reported. +- Approved scope-incomplete source: rights true; missing fields remain explicit; combined state false. +- Approved scope-complete source: combined state true. + +The helper reports registry metadata only. It cannot validate payloads, lineage, timestamps, comparability, reviewer intent, collection, activation, readiness, backtesting, calibration, hosting, or market validation. + +## Testing + +Test-first coverage will prove: + +1. the new result is frozen; +2. approved complete fields are ready in supplied order; +3. approved missing fields remain ordered and block the combined state; +4. unverified rights and supported scope remain independent; +5. unknown/composite exact sources remain unknown and miss all required fields; +6. empty required fields produce a rights-only decision; +7. blank and duplicate required fields fail explicitly; +8. collector outputs for Revenue-only, EPS-only, mixed, unknown, and unverified cases remain byte-for-byte equivalent at the dataclass field level; +9. source-validator outputs for the same cases remain unchanged; +10. the two consensus consumers import and call the shared decision rather than reimplement registry lookup and missing-scope calculation. + +## Completion Criteria + +- One pure source-rights function owns the exact-source rights plus required-field scope decision used by both consensus paths. +- Existing collector and validator public evidence remains behaviorally unchanged. +- Technical validation and commercial evidence remain independent. +- Consumer-specific blockers and write rules remain local. +- Other price, DCF, fundamentals, and cash-generation evidence models remain untouched because their domain contracts differ. +- The checked-in registry, canonical data, readiness outputs, ledgers, CSV/JSON/report assets, screenshots, and timing artifacts remain unchanged. diff --git a/docs/superpowers/specs/2026-07-19-commercial-price-apply-guard-design.md b/docs/superpowers/specs/2026-07-19-commercial-price-apply-guard-design.md new file mode 100644 index 00000000..4d57ba86 --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-commercial-price-apply-guard-design.md @@ -0,0 +1,50 @@ +# Commercial Price Apply Guard Design + +## Purpose + +Staged price validation and preview now report technical validity, lineage completeness, exact-source commercial rights, and registered `prices` scope independently. The final apply function still treats those review states as informational in every mode. That is correct for ordinary local research compatibility, but explicit Commercial Research mode must not write a staged batch whose commercial-evidence gates are incomplete. + +Add a fail-closed guard immediately before price mutation in explicit commercial mode. The guard uses the already computed preview evidence and returns a non-writing blocked result when any required state is incomplete. This slice uses temporary test directories only; it does not apply repository data or rebuild readiness. + +## Selected Contract + +- Research mode remains backward compatible: technically valid staged rows can still follow the existing separately reviewed local apply path even when commercial evidence is incomplete. +- Commercial mode requires all valid staged rows to be `lineage_complete`, `rights_approved`, and `price_scope_complete` before mutation. +- Existing missing-file and technical-invalid behavior remains unchanged and non-writing. +- The guard runs before backup creation or canonical-file writes. +- A blocked result returns `applied=false`, `backup_path=null`, `apply_status=commercial_evidence_review_required`, and deterministic `apply_blockers`. +- A permitted commercial batch still requires an explicit apply invocation; passing the guard does not trigger automatic apply or readiness rebuild. + +## Mode And Registry Inputs + +Production defaults to the existing `COMMERCIAL_RESEARCH_MODE` environment contract. Tests may pass an explicit boolean and injected immutable rights registry so behavior is deterministic and cannot edit the checked-in registry. + +The apply function passes the same injected/default registry through preview and validation. It never substitutes a different source decision between review and mutation. + +## Blockers + +The ordered blocker list may contain: + +- `price_lineage_review_required`; +- `commercial_rights_review_required`; +- `registered_price_scope_review_required`. + +Counts and distinct-source evidence stay in the returned preview summary. Unknown, blank, composite, unverified, or scope-incomplete sources therefore remain explainable without provider inference. + +## Testing + +Test-first temporary-fixture coverage will prove: + +1. research mode preserves the existing apply behavior for an unregistered source; +2. commercial mode blocks that same batch before backup or canonical mutation; +3. a lineage-incomplete but rights-approved batch remains blocked; +4. a rights-approved source without `prices` scope remains blocked; +5. a complete approved price source can pass the guard and preserve the existing merge/no-delete behavior; +6. no repository data, generated artifact, rights record, or readiness file changes. + +## Completion Criteria + +- Explicit commercial mode cannot apply a staged price batch until all three commercial-evidence states are complete. +- Research mode behavior remains compatible. +- Blocked outcomes are non-writing, deterministic, and explainable. +- Documentation and PR #113 record that guard passage is necessary but not proof of payload correctness, reviewer approval, freshness, readiness, or market validation. diff --git a/docs/superpowers/specs/2026-07-19-commercial-price-refresh-enforcement-design.md b/docs/superpowers/specs/2026-07-19-commercial-price-refresh-enforcement-design.md new file mode 100644 index 00000000..29116875 --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-commercial-price-refresh-enforcement-design.md @@ -0,0 +1,108 @@ +# Commercial Price Refresh Enforcement Design + +## Status + +Approved through the owner-supplied continuation goal on 2026-07-19. This design addresses Priority 1 of the independent internal audit without activating a provider, changing source rights, rebuilding readiness, or generating repository data artifacts. + +## Problem + +The staged-price import path already keeps technical validity, lineage, exact-source commercial rights, and registered `prices` scope separate. The direct price-refresh path does not use that contract. `make_price_source()` can construct any configured provider, `update_local_price_data()` accepts arbitrary supplied source objects, and successful rows can reach canonical `data/prices.csv` without an exact-source commercial decision. + +The root cause is older interface design: `PriceHistorySource` exposes only `fetch_history()`. Operational provider names are display strings, not immutable source identities, and the automatic ladder stores caller-supplied labels without proving they match the underlying source. + +## Guardrails + +- Research mode retains its current provider ladder, retry, status, and canonical-write behavior. +- Commercial mode fails closed before provider network access when exact-source rights or registered `prices` scope is incomplete. +- Provider aliases used by the CLI may select a provider implementation, but they cannot change or borrow the provider's exact source ID. +- Composite or ladder labels never receive borrowed rights. +- An injected source-rights registry is test and review evidence only; it does not edit `config/source_rights.yml`. +- No source is added to the checked-in rights registry in this slice. +- No provider is called, no broad refresh is run, and no readiness or canonical repository artifact is regenerated. + +## Considered Approaches + +### 1. Guard only the CLI and Make targets + +This would be a small change, but direct Python callers, scheduled orchestration, and injected source objects could still bypass the check. Rejected because the security boundary would remain outside the mutation function. + +### 2. Guard only immediately before writing `prices.csv` + +This would protect the canonical file, but an unapproved source could still receive credentials, make network calls, and create status output. Rejected because the continuation contract requires refusal before provider execution and before any output mutation. + +### 3. Exact identity with defense-in-depth checks + +Selected. Every concrete provider exposes one stable exact `source_id`. Commercial construction filters or blocks ladder legs before instantiation. `update_local_price_data()` validates supplied sources before fetch and revalidates the actual selected provider after fetch but before status or canonical mutation. This closes CLI, direct-call, automatic-ladder, and dynamic-source gaps while leaving research mode compatible. + +## Design + +### Provider identity + +Extend `PriceHistorySource` with a required `source_id` string. Concrete source IDs are exact implementation identities: + +- `stooq` +- `yahoo` +- `fmp` +- `alpha_vantage` +- `finnhub` +- `ibkr` + +These IDs are not inferred from class names, `provider_name`, source messages, environment variables, or aliases. The automatic ladder validates that every route label exactly equals the child's `source_id` and retains the selected child's ID separately from display status. + +### Shared commercial decision + +Add one price-source review helper that calls `review_commercial_field_scope(registry, source_id, ("prices",))`. Passage requires both: + +1. exact-source commercial rights are approved; and +2. the same source record lists `prices` in `supported_fields`. + +Failure raises a deterministic precondition error containing the exact source ID, rights status, and missing scope. It never edits registry state. + +### Construction boundary + +`make_price_source()` receives optional `commercial_mode` and `rights_registry` arguments. When Commercial Research mode is active: + +- a requested exact provider is reviewed before its constructor runs; +- an automatic ladder keeps only configured legs that independently pass exact rights and `prices` scope; +- if no leg passes, construction fails before any provider object or network path is used. + +Research mode follows the existing provider order and configuration rules. + +### Supplied-source boundary + +`update_local_price_data()` receives the same optional commercial arguments. Before loading tickers, fetching, or writing status output, it resolves all possible exact source IDs from the supplied source or ladder and requires every reachable leg to pass. A supplied object without a nonblank exact `source_id` fails closed in commercial mode. + +### Final selected-provider boundary + +After a fetch returns rows, the refresh path resolves the exact provider that produced them. The selected ID must belong to the pre-reviewed immutable set and must still pass the same rights and field-scope decision. This happens before the frame enters the merge set or a success status row is produced. A changed, missing, composite, or unreviewed selected ID raises and leaves canonical and status outputs absent. + +### Mutation behavior + +The slice does not change canonical file shape or invent row-level lineage. It only prevents unapproved commercial refresh mutation. Price-lineage preservation and atomic staged apply remain separate audit priorities. + +## Error Handling + +- Blank or missing supplied `source_id`: `commercial_price_source_id_required`. +- Unknown or unapproved exact source: `commercial_price_source_review_required` with the registry status. +- Missing registered `prices` field: `commercial_price_scope_review_required`. +- Ladder label/source mismatch: deterministic `ValueError` during ladder construction. +- Selected provider outside the pre-reviewed set: `commercial_price_source_changed` before mutation. + +Errors are local contract failures, not provider availability failures. They do not trigger retries or fallback to an unapproved leg. + +## Testing + +Tests use temporary directories and injected immutable registries. They must prove: + +1. every concrete provider has the expected exact `source_id`; +2. research-mode automatic ladder behavior is unchanged; +3. commercial exact-provider construction blocks unknown, unapproved, and scope-incomplete sources before construction/network use; +4. commercial automatic construction filters independently and fails when no permitted leg remains; +5. supplied sources without identity or approval fail before `fetch_history()` and before data/output directories are created; +6. an approved supplied source with `prices` scope retains the existing refresh result; +7. a source that changes selected identity after fetch fails before canonical or status mutation; +8. focused and full repository tests plus non-writing product and hygiene gates remain green. + +## Documentation Boundary + +ROADMAP, methodology/provenance documentation, the continuation prompt, and draft PR #113 will record only the verified capability: Commercial Research direct price refresh is blocked unless every reachable and selected exact provider independently passes approved rights and registered `prices` scope. This does not approve a provider, add lineage, activate commercial data, make readiness current, or prove market operation. diff --git a/docs/superpowers/specs/2026-07-19-consensus-batch-preflight-design.md b/docs/superpowers/specs/2026-07-19-consensus-batch-preflight-design.md new file mode 100644 index 00000000..82ab6b1a --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-consensus-batch-preflight-design.md @@ -0,0 +1,109 @@ +# Prospective Consensus Batch Preflight Design + +## Purpose + +The prospective consensus collector validates and appends one reviewed snapshot at a time. The CLI currently previews every proposed row only against the saved ledger, then records rows sequentially. A later duplicate, missing supersession target, same-period conflict, invalid row, or commercial-evidence failure can therefore appear only after earlier rows have already been appended. Preview and record also disagree about intra-batch lineage because preview does not simulate earlier proposed rows. + +Add one deterministic whole-batch preflight contract. Preview and record must evaluate the same ordered virtual ledger before any filesystem mutation. This slice uses temporary test ledgers only and does not collect provider data, write repository CSV/JSON files, or rebuild readiness. + +## Approaches Considered + +### Selected: ordered virtual-ledger preflight plus one append handle + +Evaluate proposed rows in input order. Every technically reviewable row is added to an in-memory virtual ledger before the next row is previewed. This detects duplicates, same-period conflicts, and explicit revision chains inside the input. Record writes the full reviewed batch through one append handle only after all applicable gates pass. + +This is deterministic validation atomicity: a known row-level rejection cannot leave an earlier row recorded. It is not a claim of crash-safe filesystem transactionality or protection from a separate concurrent writer. + +### Rejected: independent row preflight + +Checking every proposed row against only the saved ledger would avoid some partial writes, but two proposed rows could still conflict or form an invalid lineage chain without preview detecting it. It would preserve the current preview/record mismatch. + +### Rejected: temporary full-ledger replacement + +Writing a new full file and replacing the ledger could improve crash behavior, but it changes the append-only operating model, rewrites existing bytes, and introduces permission and concurrency complexity beyond this local reliability gap. + +## Batch Preview Contract + +Add an immutable `BatchCollectionPreview` with: + +- `mode="preview_only"`; +- `write_performed=false`; +- `state`, either `reviewable_batch`, `rejected_batch`, or `empty_batch`; +- `row_count` and `reviewable_count`; +- `technical_write_allowed`; +- `commercial_evidence_ready`; +- `commercial_write_allowed`; +- ordered `technical_blockers` and `commercial_blockers` with one-based row positions; +- ordered row-level `CollectionPreview` results. + +Technical and commercial evidence remain independent. `technical_write_allowed` requires a non-empty batch and every row-level `write_allowed`. `commercial_evidence_ready` requires a non-empty batch and every row-level commercial evidence state. `commercial_write_allowed` requires both. + +The batch preview always reports both decisions. It does not decide which operating mode the caller selected and never writes. + +## Ordered Virtual Ledger + +Rows are evaluated in input order because an append-only revision must reference evidence already present in the saved ledger or earlier in the same reviewed input. + +For each proposed row: + +1. call the existing row preview against the current virtual ledger; +2. retain the complete row-level technical, rights, Revenue-scope, and EPS-scope evidence; +3. add the row to the virtual ledger only when its technical `write_allowed` is true; +4. continue through all rows so reviewers receive the complete deterministic batch result. + +A commercial-evidence failure does not erase an otherwise valid technical lineage from the virtual review. This preserves readiness independence: a later explicit revision can be technically valid even when the whole commercial batch remains blocked by an earlier rights failure. + +Input order is authoritative. The collector does not sort by timestamp, repair a reversed revision chain, infer a supersession target, or select one conflicting row. + +## Record Contract + +Add `append_reviewed_batch(...)` with explicit review confirmation plus optional commercial-mode and rights-registry injection. + +The function: + +1. refuses missing confirmation; +2. loads the saved ledger and resolves the mode/rights registry once; +3. runs the same whole-batch preview used by the CLI; +4. rejects an empty or technically invalid batch before directory creation or ledger mutation; +5. in explicit Commercial Research mode, rejects any incomplete commercial evidence before mutation; +6. opens the destination once and appends the entire reviewed batch in input order, writing the header only for a new ledger. + +The existing single-row `append_reviewed_snapshot(...)` delegates to the batch function so it retains confirmation, duplicate, commercial-rights, and append-only behavior without a second decision path. + +The CLI `record` command loads its input once and calls the batch function once. It cannot loop through row-level writes. + +## Error And Evidence Behavior + +- Empty input: `empty_batch`; no directory or file creation. +- Any technical row rejection: `rejected_batch`; no rows written in research or commercial mode. +- Commercial evidence incomplete: research-mode technical batch can remain reviewable; explicit commercial record is blocked before mutation. +- Intra-batch exact duplicate: the later row is `duplicate`. +- Same ticker/period without explicit supersession: the later row is rejected. +- Valid ordered revision: the revision row is `reviewable_revision` because its target is already in the virtual ledger. +- Reversed revision: rejected because the target is not yet present; the collector does not reorder evidence. + +Errors include stable one-based row positions and row-level state/blocker text so the reviewer can correct the supplied file without inspecting a partially changed ledger. + +## Testing + +Test-first temporary-fixture coverage will prove: + +1. preview detects an intra-batch duplicate and keeps the saved ledger unchanged; +2. preview accepts an ordered new-snapshot plus explicit revision chain; +3. preview rejects a reversed revision chain without reordering it; +4. research-mode record leaves an existing ledger byte-identical when a later row is invalid; +5. commercial-mode record leaves a missing destination directory absent when a later row lacks rights or metric scope; +6. a fully valid research batch appends every row in order through one batch call; +7. a fully rights-approved commercial batch appends every row in order; +8. an empty batch is blocked without filesystem mutation; +9. single-row append compatibility remains covered; +10. no repository data, generated artifact, source-rights record, or readiness file changes. + +## Completion Criteria + +- Preview and record use one ordered whole-batch decision contract. +- A deterministic later rejection cannot leave an earlier proposed row appended. +- Intra-batch duplicates and revision lineage are visible before record. +- Technical eligibility, commercial rights, Revenue scope, and EPS scope remain independent. +- Research-only behavior stays explicit and reviewed; commercial mode remains fail-closed. +- The implementation does not claim concurrent-writer locking, crash-safe transactions, provider availability, payload correctness, nowcast readiness, calibration, hosting, reviewer validation, or market validation. diff --git a/docs/superpowers/specs/2026-07-19-consensus-ledger-integrity-design.md b/docs/superpowers/specs/2026-07-19-consensus-ledger-integrity-design.md new file mode 100644 index 00000000..a62524e6 --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-consensus-ledger-integrity-design.md @@ -0,0 +1,61 @@ +# Consensus Ledger Integrity Design + +## Problem + +The prospective consensus collector validates proposed rows and preflights a +batch, but it does not validate every existing ledger row before status, preview, +or append. Static ledgers can therefore contain duplicate IDs, duplicate evidence +identities, forks, missing parents, reversed revisions, multiple roots, or cycles +without an explicit fail-closed decision. Recording also recomputes a preview at +each row's retrieval time instead of proving it is the exact batch, cutoff, and +ledger state that a reviewer previewed. + +## Decision + +Add one ledger-integrity validator used by status, preview, and append. It first +validates every row's schema and evidence contract with row numbers, then requires: + +- globally unique snapshot IDs and semantic evidence identities; +- exactly one root per ticker/fiscal-period chain; +- every revision parent to exist in the same scope and appear earlier; +- at most one child per snapshot; +- strictly later snapshot and retrieval timestamps for every child; and +- an acyclic chain in which every row is reachable from the one root. + +Proposed input remains a separate batch contract: rows are individually parsed, +then simulated in supplied order against a validated virtual ledger. A proposed +revision may supersede only the current leaf. Invalid existing evidence blocks the +entire preview or append before mutation. + +## Preview receipt + +Every batch preview normalizes the explicit UTC review cutoff and reports: + +- a digest of the validated existing ledger; +- a digest of the exact ordered proposed input; +- a receipt derived from the schema version, cutoff, both digests, and commercial + mode. + +Recording requires the same cutoff and receipt. It reloads and revalidates the +ledger, recomputes all digests and decisions, and refuses mismatches before +creating a directory or opening the ledger for append. This binds review to the +exact immutable input and ledger state. It is not a multi-process lock or a +crash-safe database transaction; those remain separate operating concerns. + +## Input boundary + +Upstream `SOURCE_INPUT` and prospective `COLLECTION_INPUT` remain different +schemas. No command maps, normalizes, or writes one from the other. A receipt +proves only local collection review integrity; it does not prove source rights, +payload correctness, historical availability, nowcast readiness, or calibration. + +## Verification + +- Status, preview, and append reject row-numbered malformed existing rows. +- Duplicate IDs/identities, multiple roots, missing parents, forks, cycles, + reversed order/timestamps, and non-leaf supersession fail closed. +- An invalid existing ledger or later proposed rejection preserves exact bytes. +- Record requires and verifies the preview cutoff and receipt. +- Changed input, cutoff, existing ledger, commercial mode, or receipt causes no + filesystem mutation. +- Valid linear research and commercial chains still append in order. diff --git a/docs/superpowers/specs/2026-07-19-consensus-source-review-command-design.md b/docs/superpowers/specs/2026-07-19-consensus-source-review-command-design.md new file mode 100644 index 00000000..aebd697f --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-consensus-source-review-command-design.md @@ -0,0 +1,135 @@ +# Consensus Source Review Command Design + +## Purpose + +The repository can classify consensus-provider availability and can validate in-memory source rows, but it has no supported command that joins those capabilities for a supplied reviewed CSV. Stage 2 therefore lacks a repeatable first gate between receiving a file and running prospective collection preview. + +Add one read-only source-review command that loads a supplied CSV, requires an explicit provider identity and UTC review cutoff, calls the existing `validate_source_rows(...)` contract, and renders complete deterministic evidence. The command must not fetch, normalize, record, apply, rebuild readiness, create directories, or write generated artifacts. + +This closes an operating gap only. It does not supply a dataset, approve rights, convert candidate context into point-in-time history, append a snapshot, or activate Earnings Nowcast. + +## Approaches Considered + +### Selected: explicit review mode in the existing source module + +Extend `src.earnings_consensus_sources` with a `--review-csv` mode plus required `--provider` and `--as-of` arguments. Add `make earnings-consensus-source-review INPUT= PROVIDER= AS_OF=` as the stable entrypoint. + +This keeps provider availability and source-row review in the same bounded module, reuses the existing validator directly, and preserves the current status command as the default behavior. + +### Rejected: a new CLI module + +A second module would isolate presentation but duplicate source-rights loading, argument handling, result serialization, and maintenance for a small read-only surface. + +### Rejected: a documented inline Python invocation + +An ad hoc snippet would not provide a tested operating gate, deterministic rendering, Make discoverability, or a stable reviewer handoff. + +## Command Contract + +The current command remains compatible: + +```bash +make earnings-consensus-source-status +make earnings-consensus-source-status REVIEWED_CSV= JSON=1 +``` + +The new command is: + +```bash +make earnings-consensus-source-review \ + INPUT= \ + PROVIDER= \ + AS_OF= +``` + +`JSON=1` selects machine-readable output. The Make target passes only explicit values and never supplies a default input, provider, or cutoff. +It also sets `PYTHONDONTWRITEBYTECODE=1` so the read-only review cannot create local bytecode churn. + +At the Python layer: + +```bash +python3 -m src.earnings_consensus_sources \ + --review-csv \ + --provider \ + --as-of \ + [--json] +``` + +When `--review-csv` is absent, `--provider` and `--as-of` are invalid rather than being ignored. When `--review-csv` is present, both are mandatory. This prevents partial invocations from appearing reviewed. + +## Data Flow + +1. Open the supplied CSV read-only with UTF-8 and newline-aware parsing. +2. Require a header row with non-blank, unique column names. +3. Reject rows containing more values than declared headers rather than silently dropping data. +4. Preserve header spelling and cell strings; do not infer, alias, normalize, or enrich evidence fields. +5. Pass the ordered row mappings, explicit provider, and explicit cutoff to `validate_source_rows(...)`. +6. Render the immutable `SourceValidationResult` in human or JSON form. + +The existing validator remains the sole owner of history scope, required fields, timestamp ordering, fiscal/comparability validation, candidate versus historical routing, exact registry evidence, and row-level commercial scope decisions. + +The reviewed source export and the prospective collection CSV are distinct input contracts. The source export must declare `history_scope`; the checked-in prospective schema declares `review_state`. A reviewer must explicitly preserve accepted evidence while mapping between them. This command does not infer that mapping, transform a file, or write an output for collection preview. + +## Human Output + +The human report starts with explicit boundaries and then prints stable summary fields: + +- provider and normalized review cutoff; +- technical state; +- accepted and rejected row counts; +- historical-reviewable and candidate-context counts; +- rights status and commercial approval; +- commercial-ready and commercial-review-required counts; +- aggregate commercial blockers; +- rejected row numbers and reasons; +- accepted row commercial reviews with required fields, missing fields, readiness, and blockers; +- `auto_apply=false` and the next gate. + +Empty tuples render as `none`. Row evidence remains ordered by the original one-based CSV row position. Technical rejection and commercial review remain separate. + +The next gate is collection preview only when the operator has separately reviewed the payload and the relevant evidence states. The command never claims that a reviewable row is collected, activated, forecast-ready, backtest-ready, or calibrated. + +## JSON Output + +JSON serializes the existing frozen result through `dataclasses.asdict` with stable indentation and key ordering. No additional permissive or computed approval field is added. Tuple values become JSON arrays through the standard encoder. + +## Exit And Error Behavior + +- A readable CSV and valid invocation return exit code `0` after rendering the complete result, including `still_blocked`, candidate-only, rejected-row, or commercial-review-required states. The command is a review surface, not an automatic activation gate. +- Missing review arguments are parser errors and return nonzero. +- Missing, unreadable, or directory input paths return nonzero with a concise source-review error. +- Missing, blank, or duplicate headers return nonzero. +- Extra undeclared row values return nonzero. +- An invalid cutoff returns nonzero through the existing cutoff parser. +- A valid empty file with headers produces a truthful `still_blocked` result with zero accepted rows; it does not become an invocation error. + +No error path creates an output file, directory, ledger, readiness artifact, bytecode artifact, or partial mutation. + +## Testing + +Test-first coverage will prove: + +1. a reviewed historical fixture renders accepted, historical-reviewable, cutoff, rights, metric scope, and `auto_apply=false` evidence; +2. candidate-only input remains candidate context; +3. mixed valid/rejected rows preserve original row numbers and rejection reasons; +4. commercial rights and populated Revenue/EPS field scope remain independent; +5. JSON matches the existing result contract; +6. missing provider or cutoff fails explicitly; +7. missing input, missing headers, duplicate headers, and extra values fail without writes; +8. the existing status command remains byte-for-byte compatible for its current tested cases; +9. the Make target passes `INPUT`, `PROVIDER`, `AS_OF`, and optional `JSON` to the module without defaults; +10. review mode leaves the input byte-identical and creates no sibling or repository artifacts. + +## Documentation And Product Boundary + +Update the roadmap, Earnings Nowcast pilot, data strategy, methodology, provenance contract, and continuation prompt. Document the source-review command as the first non-writing Stage 2 gate before collection preview. + +The command proves only local contract evaluation of supplied rows. It cannot prove provider entitlement, payload correctness, publication availability, source-reference durability, commercial rights beyond checked-in metadata, historical depth, collection, readiness, backtesting, calibration, hosted operation, reviewer adoption, commercial demand, or market validation. + +## Completion Criteria + +- One supported read-only command reviews a supplied consensus CSV through the existing validator. +- Existing provider-status behavior remains compatible. +- Invocation, CSV-shape, technical, temporal, commercial-rights, and metric-scope evidence fail closed without being conflated. +- No provider call, canonical mutation, consensus append, readiness rebuild, generated CSV/JSON/report/sample-report/screenshot/timing output, or bytecode churn occurs. +- Focused and full verification plus all required non-writing product and hygiene gates pass. diff --git a/docs/superpowers/specs/2026-07-19-consensus-source-rights-guard-design.md b/docs/superpowers/specs/2026-07-19-consensus-source-rights-guard-design.md new file mode 100644 index 00000000..decfe072 --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-consensus-source-rights-guard-design.md @@ -0,0 +1,66 @@ +# Prospective Consensus Source-Rights Guard Design + +## Purpose + +The prospective consensus collector already validates point-in-time timestamps, immutable snapshot identity, append-only revision lineage, review confirmation, and collection cutoffs. It does not currently join the exact declared source to the checked-in commercial source-rights registry. A technically valid row can therefore be previewed and appended without showing whether commercial rights are approved or whether the registered source covers the supplied Revenue and EPS consensus fields. + +Add independent source-rights evidence to every collection preview and a fail-closed write guard in explicit Commercial Research mode. Preserve the existing reviewed local-research workflow. This slice uses temporary test ledgers only and does not collect provider data, write repository CSV or JSON files, or rebuild readiness. + +## Selected Contract + +- Technical collection validity remains independent from commercial evidence readiness. +- `write_allowed` continues to mean that the record satisfies the reviewed append-only research contract. +- Every preview also reports exact-source commercial-rights status, required consensus scopes, missing scopes, deterministic blockers, and `commercial_write_allowed`. +- A non-empty Revenue value requires exact `revenue_consensus` membership in the registered source's `supported_fields`. +- A non-empty EPS value requires exact `eps_consensus` membership in the registered source's `supported_fields`. +- Revenue-only evidence can therefore be commercially ready even when EPS scope is unavailable, and the inverse is also true. No umbrella estimate field may silently unlock both metrics. +- Unknown, blank, composite, unverified, or scope-incomplete source declarations fail closed. The collector does not split, normalize, or infer provider identities. +- Research mode remains backward compatible: a technically valid, explicitly reviewed local row can still be appended through the existing manual path. +- Explicit Commercial Research mode refuses a write before directory creation or ledger mutation unless both the technical and commercial evidence gates pass. +- Rights approval and field scope do not prove payload accuracy, point-in-time history, freshness, reviewer approval, calibration, or Earnings Nowcast readiness. + +## Preview Evidence + +Extend `CollectionPreview` with: + +- `rights_status` from the exact registry lookup; +- `commercial_rights_approved`; +- `required_supported_fields` in deterministic Revenue-then-EPS order; +- `missing_supported_fields`; +- `commercial_evidence_ready`; +- `commercial_write_allowed`; +- ordered `commercial_blockers`. + +The ordered blockers are: + +1. `commercial_rights:` when commercial rights are not approved; +2. `registered_consensus_scope_missing:` for every required scope absent from the exact rights record. + +The evidence is computed even when a row is technically rejected, so preview users can see independent failure dimensions. `commercial_write_allowed` is true only when both `write_allowed` and `commercial_evidence_ready` are true. + +## Mode And Registry Inputs + +Production defaults to the existing `COMMERCIAL_RESEARCH_MODE` environment contract and checked-in `config/source_rights.yml`. Tests may pass an explicit mode and injected immutable registry so decisions are deterministic without editing real rights records. + +The preview and append paths use the same registry instance. The current checked-in registry contains no commercially approved prospective-consensus source or `revenue_consensus` / `eps_consensus` scope, so the real Commercial Research path remains truthfully blocked until explicit rights evidence is reviewed and registered. + +## Testing + +Test-first temporary-fixture coverage will prove: + +1. an approved source with both scopes is commercially writeable after technical review; +2. Revenue-only and EPS-only records require only their present metric scope; +3. a mixed record preserves independent scope blockers when one metric is unsupported; +4. unknown, unverified, and composite exact sources fail closed without provider inference; +5. research mode preserves the existing append behavior for `reviewed_csv`; +6. commercial mode blocks an unregistered source before creating or mutating a ledger; +7. a fully approved commercial fixture can append in a temporary directory; +8. no repository data, generated artifact, source-rights record, or readiness file changes. + +## Completion Criteria + +- Prospective consensus preview makes technical and commercial evidence states independently visible. +- Revenue and EPS source scopes remain independent. +- Explicit Commercial Research mode cannot append unapproved or scope-incomplete consensus evidence. +- Research-only behavior remains compatible and still requires explicit review confirmation. +- The real-company nowcast and numerical Beat/Miss probability remain withheld until their separate data and calibration gates are satisfied. diff --git a/docs/superpowers/specs/2026-07-19-consensus-source-temporal-integrity-design.md b/docs/superpowers/specs/2026-07-19-consensus-source-temporal-integrity-design.md new file mode 100644 index 00000000..8edd9397 --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-consensus-source-temporal-integrity-design.md @@ -0,0 +1,93 @@ +# Consensus Source Temporal Integrity Design + +## Purpose + +The upstream consensus source-row validator now derives source rights correctly, but its technical contract still has three fail-open time behaviors: it accepts a snapshot recorded after its retrieval timestamp, has no explicit review cutoff, and treats every unrecognized or missing `history_scope` as current-only candidate context. + +Add a deterministic temporal review boundary before a supplied row can become candidate context or historical-reviewable evidence. Keep time validity independent from exact-source rights and Revenue/EPS field scope. + +This slice validates supplied in-memory rows only. It does not call a provider, collect or append evidence, modify a ledger, rebuild readiness, or write generated artifacts. + +## Approaches Considered + +### Selected: mandatory cutoff, explicit scope, and ordered timestamps + +Require a keyword-only `as_of` value for every validation call. Require `history_scope` to be exactly `current_only` or `point_in_time`, and accept a technically valid row only when `snapshot_at <= retrieved_at <= as_of`. + +There are no production callers, so making the cutoff mandatory removes an avoidable fail-open compatibility path. Both candidate and historical rows use the same temporal truth because future or reversed current-only context is also unsafe review evidence. + +### Rejected: optional cutoff + +An optional cutoff would avoid updating test callers, but any omitted value would preserve the current inability to prove what was knowable at review time. It would create two technical contracts for the same validator. + +### Rejected: historical-only time enforcement + +Applying the rules only to `point_in_time` rows would prevent some leakage but still admit future or reversed provider estimates as candidate context. Candidate context cannot alter forecasts, but it must still be truthfully timestamped. + +## Public Contract + +Change the validator signature to: + +```python +def validate_source_rows( + provider: str, + rows: Sequence[Mapping[str, object]], + *, + as_of: object, + rights_registry: Mapping[str, SourceRights] | None = None, +) -> SourceValidationResult: +``` + +Parse `as_of` once with the label `review cutoff`. An invalid cutoff is a caller-contract error and raises `ValueError` before any row is reviewed. Add the normalized UTC cutoff to `SourceValidationResult.review_cutoff` so every result identifies the boundary used. + +## Row Validation Flow + +For each row in supplied order: + +1. require `history_scope` to be exactly `current_only` or `point_in_time` after trim/lower normalization; +2. apply the existing field requirements, including the complete comparability fields for point-in-time rows; +3. parse `snapshot_at` and `retrieved_at` independently with existing UTC-only validation; +4. when both timestamps parse, reject `snapshot_at > retrieved_at`; +5. reject either timestamp later than the normalized review cutoff; +6. run the existing `ConsensusSnapshot` value, fiscal-period, metric, and comparability validation; +7. only after all technical checks pass, add the row to candidate/history counts and derive its independent commercial review. + +One invalid row does not suppress valid sibling rows. Original one-based row numbers remain stable in technical rejection and commercial review evidence. + +## Error And State Behavior + +- Missing or unknown scope: reject with `history_scope must be current_only or point_in_time`. +- Invalid cutoff: raise `ValueError` labeled `review cutoff` before processing rows. +- Invalid row timestamp: retain the existing timestamp-specific parser reason. +- Snapshot later than retrieval: reject with `snapshot_at cannot be after retrieved_at`. +- Snapshot or retrieval later than cutoff: reject with a field-specific `is after review cutoff` reason. +- Timestamp exactly equal to retrieval or cutoff: accept if every other technical check passes. +- No technically accepted rows: `still_blocked`; no commercial-review rows. +- Valid current-only rows: `candidate_context_only`. +- Valid point-in-time rows: `historical_evidence_reviewable`. + +Time rejection remains technical evidence only. It cannot change the source-rights registry, grant or remove commercial permission, create a provider identity, or promote readiness. + +## Testing + +Test-first in-memory coverage will prove: + +1. all existing valid calls supply and expose one normalized review cutoff; +2. an invalid review cutoff raises before row review; +3. missing and unknown scopes are rejected rather than classified as candidate context; +4. a snapshot after retrieval is rejected; +5. a snapshot after the review cutoff is rejected; +6. a retrieval after the review cutoff is rejected; +7. exact equality at retrieval and cutoff is accepted; +8. technical time rejection produces no commercial review row and does not alter the independent source-level rights decision; +9. a mixed batch preserves valid-row counts and original row numbering; +10. `auto_apply` remains false and no filesystem path is introduced. + +## Completion Criteria + +- Every accepted source row has an explicit valid scope and proves `snapshot_at <= retrieved_at <= review_cutoff`. +- Every result exposes the normalized cutoff used. +- Temporal technical validity remains independent from exact-source rights and populated Revenue/EPS scope. +- Candidate context remains non-activating and cannot bypass the cutoff. +- The checked-in source-rights registry and all canonical/generated data remain byte-unchanged. +- No real consensus snapshot, readiness promotion, backtest event, calibration evidence, provider claim, or market-validation claim is created. diff --git a/docs/superpowers/specs/2026-07-19-consensus-source-validation-rights-design.md b/docs/superpowers/specs/2026-07-19-consensus-source-validation-rights-design.md new file mode 100644 index 00000000..25bdde36 --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-consensus-source-validation-rights-design.md @@ -0,0 +1,93 @@ +# Consensus Source Validation Rights Design + +## Purpose + +The upstream consensus-source validator currently accepts a caller-supplied `rights_status` string and treats three labels as sufficient for technical row acceptance. A caller can therefore overstate commercial evidence without an exact source-rights registry record or the metric-specific field scope already required by the prospective collector. + +Replace that self-declared label with checked-in, exact-source registry evidence. Preserve technically valid research and candidate context even when commercial evidence is incomplete, and make historical rows reviewable rather than calling them commercially ready. + +This slice validates supplied in-memory rows only. It does not fetch provider data, append a ledger, activate evidence, rebuild readiness, or write generated artifacts. + +## Approaches Considered + +### Selected: independent technical acceptance and registry-derived commercial review + +Remove the caller-supplied rights label. Resolve the exact normalized provider against the checked-in rights registry, derive the Revenue and EPS scopes required by each technically accepted row, and return immutable row-level plus aggregate commercial-review evidence. + +Technical schema and comparability validity continue to determine `accepted_count` and `rejected_rows`. Commercial rights and field scope are reported separately and cannot turn an invalid payload into an accepted row or prevent a valid research-only row from being reviewed. + +### Rejected: require commercial permission for technical acceptance + +Rejecting all rows whose commercial evidence is incomplete would be fail-closed for commercial use, but it would couple source licensing to research review and suppress truthful candidate context. It would also disagree with the independent readiness contract used by the prospective collector. + +### Rejected: retain the result shape and silently replace the label + +Deriving only `rights_status` from the registry would prevent caller spoofing, but would not show whether Revenue, EPS, or both lack registered scope. It would retain the ambiguous `historical_evidence_ready` state and provide insufficient review evidence. + +## Validation Contract + +`validate_source_rows(provider, rows, *, rights_registry=None)` will: + +1. normalize `provider` once as an exact source ID by trimming whitespace and lowercasing; +2. load the checked-in registry unless an immutable test/review registry is injected; +3. validate each row's required fields, timestamps, fiscal period, metric values, and comparability using the existing technical contract; +4. reject technically invalid rows without calculating a commercial-ready count for them; +5. for each technically accepted row, require `revenue_consensus` scope only when Revenue is populated and `eps_consensus` only when EPS is populated; +6. derive exact-source rights through `commercial_eligibility` and field support from only that exact registry record; +7. preserve current-only rows as `candidate_context_only` and return technically valid point-in-time rows as `historical_evidence_reviewable`; +8. keep `auto_apply=false` in every result. + +Provider aliases, composite labels, configured keys, file origins, and caller assertions are never split or inferred into a registered source. + +## Evidence Model + +Add immutable `SourceCommercialReview` rows with: + +- one-based `row_number`; +- `required_supported_fields` in stable Revenue-then-EPS order; +- `missing_supported_fields` in that same order; +- `commercial_evidence_ready`; +- independent stable `commercial_blockers`. + +Extend `SourceValidationResult` with: + +- registry-derived `rights_status` and `commercial_rights_approved`; +- `commercial_ready_count` and `commercial_review_required_count` across technically accepted rows; +- aggregate `commercial_evidence_ready`, true only when at least one row is technically accepted and every accepted row is commercially ready; +- deduplicated, stable aggregate `commercial_blockers`; +- ordered `commercial_review_rows`. + +The source-level rights decision is shared by every accepted row because the function accepts one exact provider. Metric scope stays row-specific because Revenue and EPS may be populated independently. + +## State And Boundary Behavior + +- No technically accepted rows: `still_blocked`; commercial evidence is not ready. +- One or more current-only accepted rows and no historical rows: `candidate_context_only`. +- One or more point-in-time accepted rows: `historical_evidence_reviewable`. +- Unknown or composite exact source: `rights_status=unknown_source`; required metric scopes are missing. +- Registered but unapproved source: `rights_status=commercial_rights_unverified`; scope is still reported independently. +- Approved source missing one populated metric scope: rights pass, that metric scope fails. +- Approved exact source with every populated metric scope: commercial evidence is ready for the validator result only. + +Commercial evidence readiness remains necessary but insufficient for collection, activation, nowcast readiness, backtesting, calibration, hosting, or market validation. + +## Testing + +Test-first temporary/in-memory coverage will prove: + +1. the default checked-in registry keeps a valid current-only Alpha Vantage row as candidate context while reporting unknown commercial rights and both missing scopes; +2. a technically incomplete historical row remains rejected and does not enter commercial-ready counts; +3. a technically valid historical row is `historical_evidence_reviewable`, not `historical_evidence_ready`; +4. approved Revenue-only, EPS-only, and mixed rows require only their populated metric scopes; +5. an approved source missing EPS scope reports rights ready and EPS scope incomplete independently; +6. a composite provider remains one unknown exact source; +7. the caller-supplied rights parameter no longer exists; +8. every result remains non-writing and `auto_apply=false`. + +## Completion Criteria + +- No caller can grant source rights through a label passed to `validate_source_rows`. +- Technical acceptance, candidate/history classification, exact-source rights, Revenue scope, and EPS scope remain independently visible. +- Historical evidence is described as reviewable, not activated or commercially ready by implication. +- The checked-in registry remains unchanged and still approves no prospective-consensus source or consensus scope. +- No real snapshot, ledger, readiness artifact, CSV, JSON, report, screenshot, or timing artifact is created or modified. diff --git a/docs/superpowers/specs/2026-07-19-dcf-price-lineage-review-design.md b/docs/superpowers/specs/2026-07-19-dcf-price-lineage-review-design.md new file mode 100644 index 00000000..74286fd9 --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-dcf-price-lineage-review-design.md @@ -0,0 +1,121 @@ +# DCF Price Lineage Review Design + +## Purpose + +`make readiness-preview TOP_N=20` already separates proposed technical fundamentals and DCF promotions from fundamentals provenance, exact-source commercial rights, and registered field scope. DCF also requires a saved price row, but the current promotion review stops at an explicit statement that price-source provenance remains unproven. + +Extend the same no-write preview with an independent, fail-closed DCF price-lineage review. The review must explain the exact latest price row selected for each proposed DCF promotion without changing technical readiness, canonical price data, source rights, valuation math, or the separately reviewed readiness rebuild. + +## Approaches Considered + +1. **Extend the existing preview with a pure price-lineage component (selected).** Preserve `make readiness-preview` as the only continuation-safe command while keeping price evidence in its own data structures and output section. +2. **Add a second price-lineage command.** This would isolate the command surface, but it would weaken the existing stale-readiness rule by creating a second safe command and an ordering problem for operators. +3. **Migrate the canonical price schema now.** This could support future row-level lineage, but it would write broad generated price churn and still could not reconstruct missing historical provider evidence truthfully. + +## Selection Contract + +The review examines only tickers moving from false to true for `dcf_ready` between the saved and proposed in-memory readiness frames. + +For each promoted ticker it applies the same practical row-selection rule used by the local report provider: + +1. normalize the ticker and parse the canonical observation `date`; +2. keep rows with a valid date and positive numeric `close`; +3. identify the greatest observation date; +4. require exactly one row at that latest date; +5. inspect that selected row without inferring values from older rows or file metadata. + +A missing usable row fails closed as `missing_latest_price_row`. More than one row at the latest date fails closed as `ambiguous_latest_price_row`. The audit does not silently choose the last duplicate because duplicate grain is evidence ambiguity even though a downstream provider may currently select by row order. + +## Evidence Model + +For each proposed DCF promotion the review records: + +- ticker; +- selected latest observation date; +- number of valid price rows and latest-date candidates; +- exact row-level `source` value; +- durable row-level source reference from `source_ref`; +- row-level retrieval or review timestamp from `retrieved_at`; +- exact commercial-rights status for the complete source identifier; +- whether the rights record explicitly supports `prices`; +- missing provenance fields and blockers. + +The observation date is market-data evidence, not a retrieval timestamp. `as_of_date`, a file modification time, a `local:prices.csv` provider label, refresh warnings, or knowledge that an adapter exists cannot substitute for `retrieved_at` or `source_ref`. The source identifier is evaluated exactly; composite, missing, or unregistered values are not split or mapped to a guessed provider. + +## Independent States + +The output keeps five questions separate: + +1. **Technical DCF promotion:** would production readiness move `dcf_ready` from false to true? +2. **Latest price selection:** is there exactly one usable latest row? +3. **Lineage completeness:** does that row carry `source`, `source_ref`, and `retrieved_at`? +4. **Commercial rights:** is the exact source explicitly approved? +5. **Registered field scope:** does that rights record explicitly support `prices`? + +The technical promotion remains untouched even when every price-lineage evidence gate is blocked. Conversely, complete lineage would not establish fundamentals evidence, reviewer acceptance, data freshness, or rebuild approval. + +## Statuses + +- `no_dcf_promotions`: no ticker moves from false to true for `dcf_ready`. +- `price_lineage_review_complete`: every promoted ticker has one usable latest row, complete row-level lineage, approved exact-source commercial rights, and registered `prices` support. +- `price_lineage_review_required`: at least one promoted ticker lacks one of those gates. + +Even `price_lineage_review_complete` is inspection evidence only. It cannot make saved readiness current or authorize `make readiness`. + +## Failure Behavior + +- Missing price columns fail closed rather than inventing a source, reference, timestamp, date, or close. +- Missing price files are treated as empty evidence and leave the review required when DCF promotions exist. +- Invalid dates and nonpositive or nonnumeric closes are not usable latest-price evidence. +- Duplicate latest-date rows fail closed even if their values match. +- A missing or invalid rights registry keeps the command on its existing concise no-write error path. +- `TOP_N` caps rendered evidence rows but never changes aggregate counts. +- Missing saved readiness preserves the existing `missing_saved_snapshot` result and does not start the price review. + +## Architecture + +Add a small pure module, `src/dcf_price_lineage.py`, containing frozen evidence/review structures and `review_dcf_price_lineage(...)`. `src/readiness_preview.py` will: + +- reuse the existing saved and proposed readiness frames; +- read `data/prices.csv` only after a saved snapshot is available; +- pass the already loaded immutable rights registry into the pure review; +- attach the result independently to `ReadinessImpactPreview`; +- render a `DCF Price Lineage Review` section after the fundamentals promotion review. + +No readiness engine, local provider, canonical schema, import, refresh, or valuation code changes are part of this slice. + +## Output + +The new stdout section reports: + +- DCF promotion count; +- usable and ambiguous latest-row counts; +- complete and review-required lineage counts; +- approved and review-required rights counts; +- complete and review-required registered price-field scope counts; +- exact source and rights-status counts; +- capped per-ticker evidence rows; +- explicit no-inference, no-readiness-change, and no-rebuild-authorization boundaries. + +No CSV, JSON, report, sample-report, screenshot, timing, cache, directory, or output-path mode is added. + +## Testing + +Test-first coverage will prove: + +1. only false-to-true DCF promotions enter the review; +2. one unique latest valid row is selected deterministically; +3. missing rows, invalid rows, duplicate latest dates, missing provenance, unknown/composite sources, unapproved rights, and missing `prices` field support fail closed independently; +4. exact approved source evidence can complete the local audit without changing readiness; +5. detail capping preserves totals; +6. integrated preview output preserves technical, lineage, rights, field-scope, and rebuild boundaries; +7. the complete filesystem manifest is unchanged by the command. + +## Completion Criteria + +- `make readiness-preview TOP_N=20` explains DCF price evidence for every proposed DCF promotion. +- Current canonical rows are truthfully reported as lacking complete row-level lineage; no provider is inferred. +- The command remains byte-for-byte no-write. +- Technical readiness and every evidence/readiness state remain independent. +- ROADMAP, methodology, provenance/data strategy, the continuation prompt, and draft PR #113 record the verified result and exact next gate. +- No generated artifact is staged or committed. diff --git a/docs/superpowers/specs/2026-07-19-eps-split-basis-enforcement-design.md b/docs/superpowers/specs/2026-07-19-eps-split-basis-enforcement-design.md new file mode 100644 index 00000000..982e4d66 --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-eps-split-basis-enforcement-design.md @@ -0,0 +1,54 @@ +# EPS Split-Basis Enforcement Design + +## Problem + +SEC Companyfacts staging correctly labels Q1-Q3 EPS as +`companyfacts_split_basis_unverified`, and Earnings Nowcast readiness already +withholds that history. Two downstream paths do not yet enforce the same +sentinel: + +- quarterly Business Trend can compare all-sentinel EPS rows and render a + numeric EPS trend; and +- walk-forward backtesting can score a sentinel target EPS as ground truth. + +Both behaviors overstate evidence without explicit primary split-basis proof. + +## Decision + +Define the sentinel once in the quarterly evidence contract and use it wherever +canonical EPS becomes a displayed comparison or evaluated outcome. + +Quarterly Business Trend excludes sentinel EPS observations from every EPS +value and comparison. If none remain, EPS is blocked with an explicit split-basis +reason. If verified historical EPS remains beside withheld sentinel periods, the +trend stays partial and names those periods; Revenue remains independent. + +Walk-forward backtesting replaces a sentinel target or prior-year EPS outcome +with `None` before error, interval, direction, or benchmark calculations. A +Revenue-bearing event remains eligible for Revenue evaluation. An event with no +other comparable target metric is excluded rather than counted as valid EPS +evidence. + +The cohort lane inherits the Business Trend result, so commercial field-scope +approval cannot override an unverified EPS split basis. Synthetic fixtures may +exercise verified and unverified states in tests only. + +## Boundaries + +- No split basis is inferred from values, price history, provider metadata, or a + matching sentinel in consensus. +- No existing canonical row, source-rights record, readiness artifact, nowcast, + report, or ledger is rewritten. +- Explicit Q4 evidence, revision handling, source rights, consensus, calibration, + and canonical loader integrity remain independent. + +## Verification + +- All-sentinel quarterly EPS blocks while Revenue remains usable. +- Mixed verified/sentinel history never renders or compares a sentinel value and + remains partial. +- Commercial cohort EPS remains blocked even with an approved test-only EPS + scope when its technical history uses the sentinel. +- Backtesting keeps Revenue evaluation but withholds sentinel EPS targets and + prior-year EPS benchmarks. +- EPS-only sentinel target events are excluded from valid-event counts. diff --git a/docs/superpowers/specs/2026-07-19-pr-engineering-legitimacy-design.md b/docs/superpowers/specs/2026-07-19-pr-engineering-legitimacy-design.md new file mode 100644 index 00000000..556df4e5 --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-pr-engineering-legitimacy-design.md @@ -0,0 +1,60 @@ +# PR Engineering Legitimacy Design + +## Purpose + +PR #113 has extensive current local verification but no GitHub status checks and no independent review. Add one minimal pull-request-only GitHub Actions workflow so each proposed PR revision receives reproducible repository-side engineering evidence before human review. + +This gate proves only that the checked-out revision passes the selected local tests and read-only product checks in GitHub's runner environment. It does not prove human review, source operation, data freshness, hosting, authentication, reviewer adoption, calibration, market demand, or product-market fit. + +## Approaches Considered + +### Selected: one explicit pull-request job + +Create one workflow triggered only by pull requests targeting `main`. Use Python 3.12, install the checked-in requirements, and run the six commands named in the continuation contract as explicit steps. + +This is the smallest legible gate, keeps failure attribution clear, and avoids giving CI broader authority than local verification requires. + +### Rejected: call the full commercial release target + +`make commercial-beta-release-check` intentionally repeats broad checks and prints extensive reviewer evidence. It is appropriate for local release audits, but unnecessarily expensive and noisy for every PR revision. + +### Rejected: parallel jobs or a matrix + +Separate jobs could reduce elapsed time, but they would repeat checkout and dependency installation and add orchestration that is not required for the first independent gate. The repository has no existing Actions convention that needs compatibility. + +## Workflow Contract + +- Trigger only on `pull_request` targeting `main`. +- Grant only `contents: read` permission. +- Run on GitHub-hosted Ubuntu with Python 3.12. +- Install the checked-in package in editable mode from `pyproject.toml` plus `pytest`, without adding a cache or generated artifact upload. This makes repository imports available to direct script entrypoints just as they are in the verified local environment. +- Set `PYTHONDONTWRITEBYTECODE=1` for the job. +- Run, in order: + 1. `python3 -m pytest tests -q` + 2. `make dashboard-smoke` + 3. `make research-dashboard-render-smoke` + 4. `make public-wording-check` + 5. `make diff-hygiene-summary` + 6. `git diff --check` +- Fail closed on any nonzero command. +- Do not run providers, refreshes, imports, readiness generation, deployment, schedules, secrets, hosted probes, or artifact uploads. + +## Test Contract + +A repository test reads the workflow as text and verifies the trigger, least-privilege permission, Python version, editable package and test-runner installation, required commands, and prohibited capabilities. The test fails while the workflow is absent or lacks the package install, then passes when the minimal file is present. + +The test intentionally does not claim that a local YAML parse proves GitHub-hosted execution. The first successful Actions run on PR #113 remains the direct hosted CI evidence. + +## Documentation And Product Boundary + +Update `ROADMAP.md` and the commercial continuation contract to identify Stage 0 as implemented locally but externally pending until GitHub reports a completed check. Keep human review as a separate gate. Update PR #113 only after the local implementation checks pass and the branch is pushed. + +No product research behavior, readiness state, source-rights decision, financial evidence, or generated artifact changes in this slice. + +## Completion Criteria + +- The contract test proves the workflow is minimal, PR-only, read-only, and contains every required command. +- Full local tests and all required non-writing product gates pass. +- Exact intentional files are staged; generated CSV, JSON, report, sample-report, screenshot, timing, readiness, and canonical-data churn remain excluded. +- The commit is pushed only to `codex/personal-research-mode-mvp`. +- PR #113 remains open and draft, and its body reports local verification separately from the pending or completed hosted Actions result. diff --git a/docs/superpowers/specs/2026-07-19-prospective-price-lineage-preservation-design.md b/docs/superpowers/specs/2026-07-19-prospective-price-lineage-preservation-design.md new file mode 100644 index 00000000..52fdd24c --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-prospective-price-lineage-preservation-design.md @@ -0,0 +1,83 @@ +# Prospective Price Lineage Preservation Design + +## Purpose + +The DCF price-lineage review proves that canonical price availability is independent from provider provenance. The current manual price workflow can record `source` and `as_of_date`, but it cannot accept or preserve the required durable `source_ref` and `retrieved_at`. Validation currently treats those fields as unknown, so a prospectively reviewed row cannot satisfy the lineage contract even when the user has the evidence. + +Extend the existing price normalization, validation, preview, and apply contracts so one prospectively sourced row can carry complete row-level lineage. This slice changes schemas and summaries only; it does not create a repository data file, run a repository apply, edit source rights, or make a source commercially approved. + +## Approaches Considered + +1. **Extend the existing staged-row contract (selected).** Accept explicit source reference and retrieval timestamp metadata in the normalizer, preserve them through validation/preview/apply, and report lineage completeness independently from technical row validity. +2. **Require complete lineage for every local price import.** This would fail closed for commercial evidence, but it would incorrectly couple technical local-research price usability to a separate rights/provenance gate and break existing imports. +3. **Create a separate price-provenance sidecar.** This could avoid widening the canonical schema, but it would introduce another file, merge key, drift risk, and generated-artifact surface before a single reviewed source path exists. + +## Explicit Input Contract + +The normalizer adds optional arguments: + +- `source_ref`: a durable reviewed reference for the exact source payload or row batch; +- `retrieved_at`: the source retrieval timestamp supplied by the reviewer. + +Neither value receives a generated default. The workflow must not turn normalization time, file modification time, observation date, `as_of_date`, input file name, or source label into either field. Existing `source` and `as_of_date` behavior remains unchanged for compatibility. + +CLI and Make routing add `--source-ref`, `--retrieved-at`, `SOURCE_REF`, and `RETRIEVED_AT`. Arguments are optional so existing local-research commands continue to work. Documentation must show that both are required before claiming complete row-level lineage. + +## Staged Schema + +The staged price schema becomes: + +`date,ticker,open,high,low,close,volume,adjusted_close,source,source_ref,retrieved_at,as_of_date,notes` + +Validation retains `source_ref` and `retrieved_at` as optional evidence columns. Apply logic already widens canonical output to retained optional staged columns; tests will prove both fields survive new and updated rows. No production apply is run in this slice. + +## Independent Validation States + +Technical OHLCV validity remains based on the existing date, ticker, numeric columns, positive close, nonnegative volume, high/low, and duplicate rules. + +For valid rows, validation separately reports: + +- `lineage_status`: `no_valid_rows`, `lineage_complete`, or `lineage_review_required`; +- `lineage_complete_rows`; +- `lineage_review_required_rows`; +- `lineage_missing_fields`: any of `source`, `source_ref`, or `retrieved_at` missing or invalid across reviewed rows. + +A parseable retrieval timestamp is required for lineage completeness. The original supplied timestamp is normalized to a UTC ISO-8601 string for deterministic output. A missing or invalid value remains blank and is reported; it is not replaced with the current time. + +Lineage completeness does not establish exact-source commercial rights, registered `prices` scope, freshness, reviewer approval, or readiness. Those remain independent gates in the DCF price-lineage review and source-rights registry. + +## Preview And Apply Behavior + +`price-validate` and `price-preview` expose the lineage summary alongside existing technical merge counts. The preview remains read-only. + +`price-apply` retains the two new fields when an explicitly reviewed apply is later authorized. It does not become automatically authorized by a complete lineage summary. Existing backup, merge-key, scope-review, and no-delete behavior remains unchanged. + +## Failure Behavior + +- Missing evidence fields do not invalidate otherwise valid local-research OHLCV rows; they set `lineage_review_required`. +- Invalid `retrieved_at` is blanked for retained evidence and reported as missing rather than preserved as a credible timestamp. +- Empty source references remain missing. +- Duplicate date+ticker handling remains unchanged and keeps the last staged row; its lineage fields travel with that exact row. +- Unknown columns continue to warn and remain excluded. +- No source ID is split, normalized into another provider, or granted commercial rights. + +## Testing + +Test-first coverage will prove: + +1. explicit source reference and retrieval timestamp survive normalization; +2. no timestamp or reference is invented when arguments are absent; +3. validation distinguishes complete from review-required lineage without changing technical validity; +4. invalid retrieval timestamps fail closed; +5. preview preserves the fields and exposes the same counts; +6. a temporary-fixture apply preserves both fields on updated and new rows without deleting unrelated rows; +7. existing normalization and price import tests remain compatible; +8. repository data and generated-artifact hygiene remain unchanged. + +## Completion Criteria + +- One temporary prospective row can retain `source`, `source_ref`, and `retrieved_at` through normalize, validate, preview, and apply tests. +- Missing evidence remains explicit and non-fabricated. +- Technical validity, lineage completeness, source rights, field scope, reviewer decision, and readiness remain independent. +- ROADMAP, methodology/data strategy, continuation prompt, Make help, and draft PR #113 record the boundary. +- No repository CSV, JSON, report, sample-report, screenshot, or timing artifact is generated, staged, or committed. diff --git a/docs/superpowers/specs/2026-07-19-staged-price-rights-scope-review-design.md b/docs/superpowers/specs/2026-07-19-staged-price-rights-scope-review-design.md new file mode 100644 index 00000000..a14283a6 --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-staged-price-rights-scope-review-design.md @@ -0,0 +1,67 @@ +# Staged Price Rights And Scope Review Design + +## Purpose + +The prospective price-lineage path now preserves explicit `source`, `source_ref`, and `retrieved_at` evidence, but its validation and preview summaries stop at lineage completeness. A reviewer still has to inspect the source-rights registry separately to learn whether each exact source is commercially approved and explicitly supports `prices`. That separation is methodologically correct, but the missing joined review makes it too easy to mistake complete provenance for permitted commercial evidence. + +Add a read-only, fail-closed rights and field-scope review to the existing staged price validation and preview results. This slice evaluates checked-in registry truth only. It does not edit rights, fetch a payload, apply rows, rebuild readiness, or make technical local-research validity depend on commercial evidence. + +## Approaches Considered + +1. **Attach independent rights/scope summaries to validation and preview (selected).** Reuse the exact source-rights contract, evaluate each technically valid staged row, and expose aggregate and row-level decisions without changing technical validity. +2. **Block all staged rows lacking approved rights.** This would protect commercial use but would incorrectly reject legitimate local research imports and couple two readiness states. +3. **Evaluate rights only during apply.** This would fail too late and keep preview unable to answer whether a staged batch is commercially reviewable. + +## Evaluation Contract + +For every technically valid staged row: + +- use the exact trimmed `source` string as the registry key; +- never split composite values, infer aliases, map file names, or guess a provider; +- use `commercial_eligibility(...)` for the rights decision; +- require the exact registry record to include `prices` in `supported_fields` for field-scope completeness; +- keep missing source identity as `unknown_source` with incomplete field scope. + +The checked-in registry is authoritative for this local review. Tests may inject an immutable registry so approved, unverified, unknown, mixed, and empty-source behavior is deterministic without changing `config/source_rights.yml`. + +## Independent Summary States + +Validation and preview add: + +- `commercial_rights_status`: `no_valid_rows`, `rights_approved`, `rights_review_required`, or `mixed_rights`; +- `rights_approved_rows` and `rights_review_required_rows`; +- `rights_status_counts`, keyed by the exact eligibility status; +- `price_scope_status`: `no_valid_rows`, `price_scope_complete`, `price_scope_review_required`, or `mixed_price_scope`; +- `price_scope_complete_rows` and `price_scope_review_required_rows`; +- `source_review_rows`, a deterministic capped-independent list containing exact source ID, rights status, price-scope completeness, and blockers for each distinct exact source. + +Technical `status`, valid/skipped counts, lineage status, and merge counts remain unchanged. Rights or scope review requirements may add warnings, but they do not invalidate an otherwise valid local-research row. + +## Failure Behavior + +- Unknown and blank sources fail closed as `unknown_source` and lack registered `prices` scope. +- A registered source with unverified commercial rights remains review-required even if it supports `prices`. +- A commercially approved source remains scope-review-required when its record does not list `prices`. +- Mixed batches report mixed states instead of collapsing to approved or blocked. +- Invalid or missing registry input raises the existing registry error; the workflow does not synthesize a permissive fallback. +- Complete lineage does not change rights or scope decisions. + +## Testing + +Test-first coverage will prove: + +1. an injected approved source with `prices` produces complete independent rights and scope states; +2. `yfinance` remains commercial-rights-review-required while retaining registered price scope; +3. unknown and blank exact sources fail closed without alias inference; +4. a mixed batch reports mixed rights/scope counts deterministically; +5. validation and preview inherit the same review without changing technical or merge results; +6. invalid technical rows are excluded from commercial-evidence counts; +7. no repository data or generated artifact is written. + +## Completion Criteria + +- One staged batch can answer technical validity, lineage completeness, exact-source rights, and registered price scope independently in one read-only result. +- Existing local research import behavior remains compatible. +- No source is approved, aliased, inferred, fetched, applied, or promoted. +- ROADMAP, methodology, provenance, data strategy, continuation prompt, and draft PR #113 record the capability and its boundaries. +- No CSV, JSON, report, sample-report, screenshot, or timing artifact is generated, staged, or committed. diff --git a/docs/superpowers/specs/2026-07-20-canonical-quarterly-fail-closed-design.md b/docs/superpowers/specs/2026-07-20-canonical-quarterly-fail-closed-design.md new file mode 100644 index 00000000..74ae3a84 --- /dev/null +++ b/docs/superpowers/specs/2026-07-20-canonical-quarterly-fail-closed-design.md @@ -0,0 +1,30 @@ +# Canonical Quarterly Ledger Fail-Closed Design + +## Problem + +The canonical quarterly-actual CSV loader retains valid rows while reporting +invalid rows separately. Dashboard trend and cohort composition currently pass +only the accepted rows downstream. A partially rejected canonical ledger can +therefore produce apparently usable quarterly evidence. + +## Contract + +- Keep the loader non-writing and audit-friendly: it may return accepted rows + together with row-numbered rejection evidence. +- Add one load-result-aware packet boundary. If any canonical row is rejected, + do not build a quarterly trend from any accepted row in that file. +- Treat the canonical ledger as one integrity unit for dashboard consumption: + one rejected row blocks Revenue, EPS, and supplemental quarterly trend use for + every ticker until the ledger is corrected and reviewed. +- Carry row number and validation reason into the blocked packet and display + that evidence only in the existing Advanced quarterly evidence expander. +- The focused cohort must not derive quarterly commercial evidence from the + accepted subset of a partially rejected canonical ledger. +- Missing ledgers remain empty/blocked. No rejected row is repaired, inferred, + promoted, written, or copied. + +## Boundaries + +This slice does not change SEC staging, Q4 policy, EPS split-basis policy, +readiness, canonical data, source rights, or generated artifacts. It does not +claim that a fully parseable ledger has been externally verified. diff --git a/docs/superpowers/specs/2026-07-20-company-workbench-cash-generation-portability-design.md b/docs/superpowers/specs/2026-07-20-company-workbench-cash-generation-portability-design.md new file mode 100644 index 00000000..4af9e12f --- /dev/null +++ b/docs/superpowers/specs/2026-07-20-company-workbench-cash-generation-portability-design.md @@ -0,0 +1,174 @@ +# Company Workbench Cash-Generation Portability Design + +## Purpose + +The current Company Workbench cash-generation preview proves one exact NVIDIA filing and one explicit preview-only user flow. A no-write eligibility review of AMD Q1 FY2026 now provides a second exact official SEC filing that passes the existing extraction, source-rights, adapter-acceptance, cutoff, and capex-sign contracts. The next slice should turn that evidence into a bounded two-company portability proof without broadening coverage, accepting caller-supplied filing identity, persisting data, or changing production readiness. + +This is research-only product evidence. It is not investment advice, a recommendation, a production source activation, broad commercial coverage, or market validation. + +## Verified Eligibility Evidence + +The second-company eligibility review used the existing read-only SEC preview command with one exact identity: + +- ticker: `AMD` +- CIK: `0000002488` +- fiscal period: `2026-Q1` +- period start: `2025-12-28` +- period end: `2026-03-28` +- accession: `0000002488-26-000076` +- primary document: `amd-20260328.htm` +- SEC acceptance time: `2026-05-05T22:06:27+00:00` +- fixed review cutoff: `2026-07-20T23:59:59-04:00` + +The existing contract accepted Revenue USD 10.253B, operating income USD 1.476B, cash from operations USD 2.955B, and capital expenditures USD -0.389B. Capital expenditures became a negative cash outflow only after the exact filed table supplied `explicit_filed_table_outflow` evidence. Derived free cash flow is USD 2.566B. The command fetched only Companyfacts, submissions, and the exact primary filing in memory and produced no file or readiness change. + +This evidence proves eligibility for one bounded implementation slice. It does not prove historical depth, Q4 support, a third company, broad parser portability, provider redundancy, external reviewer adoption, demand, calibration, or product-market fit. + +## Approaches Considered + +### 1. Immutable filing registry — selected + +Store the two reviewed filing identities in immutable application configuration and let the loader select only by normalized ticker. This removes duplicated NVIDIA-only constants while ensuring URL parameters cannot choose a CIK, accession, dates, document, or cutoff. It is the smallest change that proves the same loader and composition path works for two exact companies. + +### 2. Separate AMD loader + +Add a second loader that copies the NVIDIA sequence with AMD constants. This minimizes edits to the existing loader but duplicates fetch, extraction, rights, exception, and composition logic. The duplicate paths could drift and would not provide as strong a portability result. + +### 3. Caller-supplied filing parameters + +Allow query parameters or loader arguments to provide CIK, accession, dates, filing, and cutoff. This would be flexible but would weaken the reviewed-identity boundary and create an implicit broad-source surface. It is rejected. + +## Architecture + +### Immutable filing specification + +Add a frozen `CashGenerationPreviewFiling` value type to `src/company_workbench_cash_generation_preview_loader.py`. It contains: + +- `ticker` +- `cik` +- `fiscal_period` +- `period_start` +- `period_end` +- `accession` +- `primary_document` +- `as_of` + +Expose a read-only mapping with exactly two entries: the already reviewed NVIDIA Q1 FY2027 filing and the eligible AMD Q1 FY2026 filing. The registry is application configuration, not canonical research data. It must not be loaded from CSV, JSON, environment variables, URL parameters, or a generated artifact. + +### Bounded loader dispatch + +`load_company_workbench_cash_generation_preview(ticker, *, user_agent=None, fetcher=None, retrieved_at=None)` keeps its public signature. It normalizes the ticker, looks it up in the immutable registry, and returns a complete withheld preview before fetching when no exact entry exists. + +For an exact entry, the loader: + +1. fetches only the three existing official SEC endpoints using that entry's CIK, accession, and primary document; +2. calls the existing extractor with the entry's exact fiscal identity, dates, filing identity, retrieval time, and cutoff; +3. evaluates the existing immutable source-rights and adapter-acceptance contracts; +4. composes the existing Company Workbench preview using the same cutoff and selected ticker; +5. converts supported fetch, identity, and parsing exceptions into a stable all-or-nothing withheld result without exposing exception text. + +An unsupported ticker must not fetch and must not leak another company's fiscal period, accession, source URL, components, or values. + +### Explicit Workbench routes + +The existing query gate remains exact: only `cash_preview=1` invokes the loader. The two explicit routes are: + +- NVIDIA: `?mode=research&page=company-workbench&ticker=NVDA&open=1&cash_preview=1` +- AMD: `?mode=research&page=company-workbench&ticker=AMD&open=1&cash_preview=1` + +No default navigation link includes `cash_preview=1`. The normal Company Workbench route never loads the preview and continues to render canonical evidence and existing production withheld states. + +The presentation layer remains company-agnostic. Accepted AMD evidence uses the same three preview-only primary cards and collapsed Advanced lineage. The cards never use `ready`. Accession, URL, timestamps, exact components, definitions, source references, capex-sign state, cutoff, and blockers remain Advanced-only. + +## Data and Trust Boundaries + +Both companies retain the same invariants: + +- `production_activation=false` +- `readiness_promotions=()` +- `persistence=false` +- no canonical source row +- no readiness rebuild or promotion +- no forecast, valuation, consensus, peer, catalyst, outcome, backtest, or calibration change +- no cache, CSV, JSON, report, sample report, screenshot, timing output, or generated artifact +- no Q4 derivation +- no inference of source identity, period identity, accounting compatibility, publication time, or capex sign + +Revenue, EPS, operating margin, free cash flow, FCF margin, valuation, catalysts, outcomes, consensus, backtesting, and calibration keep independent readiness states. A successful AMD preview cannot borrow or promote any sibling state. + +## Failure Behavior + +The preview is all-or-nothing. Any missing or conflicting Companyfacts value, CIK mismatch, accession mismatch, primary-document mismatch, missing or invalid acceptance time, post-cutoff evidence, missing exact inline fact, missing explicit capex outflow presentation, rights failure, unsupported field, adapter blocker, ticker mismatch, incompatible component, incomplete derived set, forbidden activation, or forbidden readiness promotion withholds all three preview metrics and all components. + +The primary answer must not show partial values. Stable blocker identifiers and exact technical lineage remain available only under Advanced when the result contract permits them. Fetch or parsing exceptions are represented by exception type only; secret-bearing or provider error text is never rendered. + +Removing the AMD registry entry is the rollback. After removal, the explicit AMD route returns an unsupported-ticker withheld result before any fetch. No data rollback is required because the feature never persists data. + +## Testing Strategy + +### Loader contract + +Extend `tests/test_company_workbench_cash_generation_preview_loader.py` to prove: + +- both immutable entries have the exact reviewed identity; +- NVIDIA still fetches exactly its three endpoints and produces its existing result; +- AMD fetches exactly its three endpoints and produces the reviewed Q1 FY2026 result; +- AMD free cash flow is USD 2.566B and all three derived metrics are `preview_available`; +- unsupported tickers fetch nothing and leak no configured filing identity; +- malformed or unsigned AMD evidence withholds every value and component; +- the loader signature still exposes no caller-supplied CIK, accession, filing, cutoff, output, apply, refresh, or readiness parameter; +- the module exposes no file writer, broad provider, fallback, or generated-artifact surface. + +### Dashboard and presentation contracts + +Extend the existing dashboard contract and render-smoke tests to prove: + +- the explicit AMD route invokes the bounded loader and renders accepted preview cards without live network access in the test; +- the normal AMD route never invokes the loader; +- AMD technical lineage remains below the primary cards and inside Advanced; +- default navigation still contains no preview flag; +- preview states never become production `ready` states. + +### Regression and release verification + +Run the focused cash-generation, loader, workspace, dashboard-contract, render-smoke, and documentation suites first. Then run the complete repository test and release-gate sequence required by the continuation contract. Verify exact staged paths and PR-range hygiene before commit and push. Wait for GitHub CI on the exact pushed head. + +## Documentation and Product Claims + +Update `ROADMAP.md`, `docs/PERSONAL_RESEARCH_MODE.md`, `docs/METHODOLOGY.md`, `docs/PROVENANCE_CONTRACT.md`, the continuation goal prompt, and draft PR #113 after implementation passes. + +The documentation may claim: + +- two exact official SEC filings pass one shared bounded parser, loader, acceptance, and Workbench preview path; +- the implementation is a stronger portability and reliability proof than the NVIDIA-only path; +- both routes preserve exact lineage, complete withholding, no persistence, and no activation. + +The documentation must not claim: + +- broad company coverage; +- arbitrary-filing support; +- production source activation; +- current readiness; +- historical or Q4 coverage; +- point-in-time consensus availability; +- calibrated probability; +- hosted reliability or access control; +- external reviewer validation; +- commercial demand or product-market fit. + +## Completion Criteria + +This slice is complete only when: + +1. the immutable registry contains exactly the reviewed NVIDIA and AMD entries; +2. both explicit routes use the same bounded loader and preview composition; +3. unsupported tickers fail before fetch without another company's metadata; +4. the AMD fixture and exact live filing evidence both pass the same required component and capex-sign contract; +5. all failure paths retain complete withholding and no component leakage; +6. normal Company Workbench navigation and canonical evidence remain unchanged; +7. all focused and full local gates pass; +8. generated-artifact and PR-range hygiene remain clean; +9. documentation and PR #113 state the narrow two-company boundary accurately; +10. the branch is pushed only to `codex/personal-research-mode-mvp`, PR #113 remains draft, and exact-head GitHub CI succeeds. + +Passing these criteria closes one bounded two-company portability slice only. The overall commercial-maturity goal remains active because source, hosted-preview, reviewer, calibration, evidence-depth, and operating gates remain incomplete. diff --git a/docs/superpowers/specs/2026-07-20-company-workbench-cash-generation-preview-design.md b/docs/superpowers/specs/2026-07-20-company-workbench-cash-generation-preview-design.md new file mode 100644 index 00000000..a3176a88 --- /dev/null +++ b/docs/superpowers/specs/2026-07-20-company-workbench-cash-generation-preview-design.md @@ -0,0 +1,118 @@ +# Company Workbench Cash-Generation Activation Preview Design + +## Goal + +Add an explicit, read-only Company Workbench preview that demonstrates how one accepted SEC quarterly cash-generation evidence packet would appear in the research workflow without activating production evidence, persisting data, rebuilding readiness, or changing any independent readiness state. + +The preview is product evidence for a controlled local beta. It is not a recommendation, forecast, production-data activation, broad-coverage claim, or market-validation result. + +## Scope + +The slice covers one explicit preview route: + +`?mode=research&page=company-workbench&ticker=NVDA&open=1&cash_preview=1` + +The route may display quarterly operating margin, free cash flow, and FCF margin only when a supplied `SecQuarterlyPilotPreview` has status `accepted_for_review` and passes the activation-preview contract. The ordinary Company Workbench route remains unchanged and continues to load canonical quarterly actuals without supplemental SEC preview observations. + +The slice does not add production activation, canonical persistence, readiness promotion or rebuild, broad SEC collection, another-company proof, hosted deployment, consensus data, calibrated probabilities, or generated CSV, JSON, report, screenshot, timing, or readiness artifacts. + +## Architecture + +Create a focused pure module for the activation-preview boundary. It accepts a `SecQuarterlyPilotPreview` plus the explicit review cutoff and returns one immutable preview view model. The module does not fetch, read, or write files and does not import the Streamlit dashboard. + +The view model contains: + +- ticker, fiscal period, preview status, and a stable human message; +- independently composed operating-margin, free-cash-flow, and FCF-margin preview metrics; +- visible blocker and withheld-metric tuples; +- exact SEC accession, source URL, acceptance time, cutoff, source references, and capex-sign evidence; +- immutable `production_activation=False` and `readiness_promotions=()` fields; +- an explicit `persistence=False` boundary. + +The existing `SecQuarterlyPilotPreview` remains the source-review result. The new contract is a downstream presentation review only; it does not mutate or reinterpret adapter acceptance. + +## Data Flow + +1. An explicit preview caller obtains one in-memory `SecQuarterlyPilotPreview` through the existing bounded SEC extraction and acceptance path. +2. The activation-preview composer rejects any result whose status is not `accepted_for_review`, whose acceptance object is absent or not accepted, whose ticker does not match the selected Workbench ticker, whose cutoff is absent or invalid, or whose non-activation invariants are not intact. +3. For accepted input, the composer calls the existing quarterly trend composition with the SEC extraction's Revenue actual and cash-generation observations. +4. The composer exposes only the three cash-generation metric results in its primary answer. Revenue and EPS readiness remain owned by the existing canonical Company Workbench packet and are not promoted by the preview. +5. The explicit Workbench preview route renders the preview panel before technical evidence and leaves the normal `quarterly_trend_packet` unchanged. +6. Exact component lineage, cutoff, acceptance time, accession, capex-sign proof, definitions, and diagnostics render under a collapsed Advanced preview-evidence disclosure. + +No preview result enters `data/`, `outputs/`, canonical ledgers, session proof ledgers, saved readiness, valuation inputs, Earnings Nowcast, Monitor, or downstream forecasts. + +## Explicit Preview Route + +`cash_preview=1` is the only accepted activation-preview query value. Missing, empty, or any other value keeps the ordinary Company Workbench behavior. + +The preview route is local and opt-in. It must not be added to the default Research Desk, Discover, Company Workbench, or Monitor navigation. It must not silently fetch data during normal Workbench rendering. + +The explicit route uses a dedicated preview loader with the already bounded NVIDIA filing identity and the reviewed cutoff `2026-07-20T23:59:59-04:00`. The route does not accept an arbitrary company, accession, filing, or cutoff from query parameters. This makes the first user-flow proof reproducible and prevents the preview flag from becoming a broad collection surface. If the required SEC user agent, network access, exact filing payload, or evidence contract is unavailable, the route returns a visible withheld preview. It does not fall back to fixtures, canonical rows, alternate providers, inferred values, or stale cached payloads. + +## User Experience + +The primary panel appears inside Business Trend and is labelled: + +`Cash-generation review preview — not production evidence` + +The panel answers three questions: + +- What operating margin does the accepted packet support for the filed quarter? +- What free cash flow does the accepted packet support using explicit signed capital expenditures? +- What FCF margin follows from compatible Revenue and free-cash-flow evidence? + +Each metric keeps its own state. If the complete packet cannot support all required preview metrics, the panel displays all three as withheld rather than leaking partial numeric output. + +The panel always displays the boundaries `production activation: false`, `readiness promotions: none`, and `no persistence or readiness rebuild`. Technical component values, inline-XBRL identifiers, definitions, source references, accession, timestamps, and diagnostics stay under Advanced. + +The ordinary Business Trend cards continue to show canonical Revenue and EPS and continue to withhold operating margin, free cash flow, and FCF margin unless separately activated in a future approved slice. + +## Fail-Closed Rules + +The preview is fully withheld when any of these conditions holds: + +- preview or adapter status is not `accepted_for_review`; +- production activation is true or readiness promotions are non-empty; +- the selected ticker and extraction ticker differ; +- the cutoff is missing, malformed, or earlier than the filing acceptance time; +- the SEC extraction contains blockers; +- accession, source URL, acceptance time, or capex-sign evidence is missing; +- capex sign is not `explicit_filed_table_outflow`; +- observations are empty, mixed-ticker, mixed-source, post-cutoff, ambiguous, incomplete, or definition-incompatible; +- required operating-margin, free-cash-flow, or FCF-margin output is absent; +- Q4 evidence lacks an explicit filed three-month quarter. + +Blocked results retain stable blocker codes and expose no partial numerical preview. Synthetic fixtures remain test-only. + +## Readiness Independence + +The preview cannot change or imply readiness for actuals, consensus, Revenue, EPS, operating margin, free cash flow, FCF margin, valuation, catalysts, outcomes, peers, backtesting, or calibration. + +Revenue is used only as an explicit compatible component for the preview calculation. It is not copied into canonical actuals or used to alter the ordinary Company Workbench Revenue state. EPS remains unverified unless explicit split-basis proof exists. No numerical Beat/Miss probability is introduced. + +## Testing + +Behavior changes are test-driven. The focused tests must prove: + +- an accepted in-memory SEC result composes all three preview metrics; +- the immutable non-activation, no-persistence, and empty-readiness-promotion fields; +- exact accession, source, acceptance time, cutoff, source references, and capex-sign lineage; +- full withholding for rejected, blocked, absent-acceptance, mismatched-ticker, post-cutoff, incomplete, ambiguous, incompatible, and invalid-capex-sign inputs; +- no partial numeric leakage when one required metric fails; +- the explicit `cash_preview=1` query contract; +- answer-first rendering and collapsed Advanced evidence; +- the normal Company Workbench route continues to use the unchanged canonical packet and does not fetch or activate SEC preview evidence; +- no preview output or Make target accepts an output path, apply flag, readiness flag, broad-refresh flag, or fixture fallback. + +After focused tests, run the complete repository test suite and all required dashboard, render, public, commercial-beta, release, pilot-readiness, diff-hygiene, PR-range-hygiene, whitespace, and staged-hygiene gates. + +## Documentation and Release Evidence + +After the implementation passes, update `ROADMAP.md`, `docs/PERSONAL_RESEARCH_MODE.md`, `docs/METHODOLOGY.md`, `docs/PROVENANCE.md`, the continuation goal prompt, and draft PR #113. Documentation must state that the preview proves one explicit user-flow composition only and does not prove production activation, a second company, historical depth, Q4 coverage, hosting, external reviewer adoption, demand, calibration, or product-market fit. + +Stage only the exact product, test, documentation, specification, and plan files. Keep all generated artifacts excluded. Push only `codex/personal-research-mode-mvp` and keep PR #113 draft. + +## Next Maturity Decision + +Once this slice is verified, assess a second-company bounded SEC portability proof against remaining local alternatives. A second-company proof is justified only if it adds evidence about parser and adapter portability that the Workbench preview cannot provide. It remains a separate approved slice and must use one exact filed quarter, official SEC endpoints, in-memory processing, explicit Q4 avoidance or evidence, and no generated artifacts or production activation. diff --git a/docs/superpowers/specs/2026-07-20-filed-q4-split-proof-correction-design.md b/docs/superpowers/specs/2026-07-20-filed-q4-split-proof-correction-design.md new file mode 100644 index 00000000..840d4c11 --- /dev/null +++ b/docs/superpowers/specs/2026-07-20-filed-q4-split-proof-correction-design.md @@ -0,0 +1,26 @@ +# Filed-Q4 Split-Proof Correction Design + +## Problem + +Explicit filed-Q4 extraction correctly required a primary Q4 table, but it +defaulted missing or malformed split-basis language to `as_reported`. Downstream +comparability treated that invented declaration as verified and could unlock +EPS without primary proof. + +## Contract + +- Missing or unparsable filed-Q4 split evidence yields + `primary_split_basis_unverified`, never `as_reported`. +- Both Companyfacts and primary-proof unverified sentinels fail the shared EPS + comparability predicate. +- Only declared supported basis tokens (`as_reported`, `pre_split`, and dated + `post_split`/`split_adjusted` forms) pass; arbitrary nonempty text does not. +- Revenue remains independently usable. +- EPS trend, readiness, backtesting, and comparison paths continue to use the + shared predicate and therefore withhold the unverified filed-Q4 value. +- Explicitly parsed retrospective split proof retains its dated adjusted basis. + +## Boundaries + +This does not infer an as-reported basis, create primary proof, alter Q4 table +requirements, or change any canonical/readiness artifact. diff --git a/docs/superpowers/specs/2026-07-20-optional-evidence-commercial-gates-design.md b/docs/superpowers/specs/2026-07-20-optional-evidence-commercial-gates-design.md new file mode 100644 index 00000000..f90b3f88 --- /dev/null +++ b/docs/superpowers/specs/2026-07-20-optional-evidence-commercial-gates-design.md @@ -0,0 +1,35 @@ +# Optional Evidence Commercial Gates Design + +## Problem + +Historical valuation, catalyst, and research-outcome ledgers preserve a source +identifier but currently do not join that exact identifier to the commercial +source-rights registry. A future non-empty ledger could therefore appear +supported or reviewed in the Commercial Research dashboard without permission +for that evidence lane. + +## Contract + +- Keep technical validity, chronology, exact-source commercial rights, and + registered lane scope independent. +- In explicit Commercial Research composition, require every row used for a + supported/reviewed result to pass the checked-in registry for its exact source + ID and one literal lane scope: + - `valuation_history` for historical valuation observations; + - `catalyst_evidence` for supported catalyst events; + - `research_outcomes` for reviewed outcome evidence. +- Do not infer aliases, split composite source IDs, or borrow permission from a + different source or lane. +- One unapproved or scope-incomplete row blocks the supported/reviewed result + for that scoped packet and remains visible only as blocker evidence. +- Candidate catalyst context remains candidate-only and cannot satisfy the + supported-evidence gate. +- Empty ledgers retain their existing empty/blocked states and display no + fabricated content. +- Research-mode builders retain their existing evidence-review behavior. + +## Boundaries + +This slice does not approve any source, edit the rights registry, populate a +ledger, validate payload truth, change readiness, or activate forecasts. It +adds no provider call and no generated artifact. diff --git a/docs/superpowers/specs/2026-07-20-pr-range-engineering-gate-design.md b/docs/superpowers/specs/2026-07-20-pr-range-engineering-gate-design.md new file mode 100644 index 00000000..267fa549 --- /dev/null +++ b/docs/superpowers/specs/2026-07-20-pr-range-engineering-gate-design.md @@ -0,0 +1,30 @@ +# Pull-Request Range Engineering Gate Design + +## Problem + +The hosted workflow checks a clean checkout with working-tree commands. Both +generated-artifact hygiene and whitespace can therefore inspect zero paths even +when the pull request changes hundreds of files. + +## Contract + +- The pull-request event supplies explicit base and head commit SHAs. +- Checkout fetches sufficient history and checks out the exact PR head. +- A read-only range-hygiene command resolves both values as commits and reads + `git diff --name-status BASE...HEAD`. +- The command classifies every changed path through the existing hygiene rules, + reports product, sample-report, generated CSV/JSON, and manual-review counts, + and fails when generated CSV/JSON churn exists in the PR range. +- Manual-review paths remain explicitly reported; this generated-artifact gate + does not silently recast them as product paths. +- Whitespace uses `git diff --check BASE...HEAD` against the same explicit + event range. +- Tests create a temporary two-commit repository to prove a generated file that + is absent from the working-tree diff is still found in the commit range. + +## Workflow Boundaries + +The workflow stays pull-request-only with `contents: read`. It has no secrets, +provider access, schedule, readiness generation, deployment, artifact upload, +or write permission. Range inspection is evidence about the PR diff only; it is +not human review or merge approval. diff --git a/docs/superpowers/specs/2026-07-20-price-lineage-apply-integrity-design.md b/docs/superpowers/specs/2026-07-20-price-lineage-apply-integrity-design.md new file mode 100644 index 00000000..28eebeb4 --- /dev/null +++ b/docs/superpowers/specs/2026-07-20-price-lineage-apply-integrity-design.md @@ -0,0 +1,60 @@ +# Price Lineage and Apply Integrity Design + +## Problem + +Price lineage currently treats any parseable `retrieved_at` value as complete. +Naive timestamps are silently interpreted as UTC, retrieval can precede the end +of a daily price observation or exceed the reviewer cutoff, and DCF lineage does +not evaluate the same temporal rule. Apply also previews the staged file and then +reads it a second time, so the frame written is not necessarily the frame that +passed preview. The final CSV write is direct rather than atomic. + +## Temporal decision + +Add one pure daily-price temporal validator shared by normalization, staged +validation/preview/apply, and DCF price-lineage review. For a daily observation +dated `D`, the earliest defensible availability is `D + 1 day 00:00:00 UTC`. +This is deliberately conservative and does not claim an exchange-specific close +or provider publication time. + +When lineage declares `retrieved_at`, the timestamp must include an explicit UTC +offset. It must be at or after the daily availability boundary and at or before +an explicitly timezone-aware review cutoff. Missing retrieval remains a separate +lineage gap so research-only technical price rows can remain usable; a malformed, +naive, too-early, post-cutoff, or cutoff-unreviewed retrieval cannot pass temporal +lineage or Commercial Research apply. + +Normalization does not invent timestamps. If the operator supplies +`retrieved_at`, normalization requires a review cutoff and refuses the complete +normalization before writing when any normalized observation violates the shared +temporal rule. Staged validation and preview expose independent temporal counts +and blockers without changing technical OHLCV validity. DCF review adds temporal +blockers independently from exact-source rights and registered `prices` scope. + +## Single-frame apply + +Build preview from one staged-file read and retain the validated immutable frame +inside the apply call. Apply must not re-read or re-normalize the staged CSV after +the commercial and temporal decision. Before mutation it computes the merge from +that frame, optionally writes the existing canonical backup, writes the complete +new CSV to a temporary file in the canonical directory, flushes and fsyncs it, +then replaces the canonical path atomically. + +Known pre-write validation failures create neither a backup nor canonical file. +An atomic replace narrows partial-write risk on one filesystem, but this remains +a local file workflow: it is not a concurrent-writer lock, database transaction, +or guarantee that backup creation and canonical replacement succeed together. + +## Verification + +- Naive, malformed, too-early, post-cutoff, and missing-cutoff retrieval evidence + fail the same shared temporal vocabulary across all four consumers. +- Explicit timezone offsets normalize to UTC deterministically. +- Missing retrieval remains technically usable in research mode but commercially + blocked as incomplete lineage. +- Apply reads the staged file once and writes the exact validated frame. +- A staged-file change after that read cannot change the written rows. +- Known temporal, rights, scope, or technical failures preserve canonical bytes + and create no backup. +- Successful writes use same-directory atomic replacement and preserve the + existing no-delete merge behavior. diff --git a/docs/superpowers/specs/2026-07-20-sec-quarterly-cash-generation-pilot-design.md b/docs/superpowers/specs/2026-07-20-sec-quarterly-cash-generation-pilot-design.md new file mode 100644 index 00000000..81853691 --- /dev/null +++ b/docs/superpowers/specs/2026-07-20-sec-quarterly-cash-generation-pilot-design.md @@ -0,0 +1,245 @@ +# SEC Quarterly Cash-Generation Pilot Design + +## Decision + +Add one read-only, in-memory SEC pilot that can assemble and assess a single +company's exact filed quarter for quarterly operating income, cash from +operations, and capital expenditures. The first evidence target is NVIDIA Q1 +FY2027, period ended 2026-04-26, accession `0001045810-26-000052`. + +The pilot may return `accepted_for_review` through the existing quarterly +adapter acceptance contract. It cannot persist observations, activate product +output, promote readiness, or create or modify CSV, JSON, report, +sample-report, screenshot, timing, or canonical-data artifacts. + +## Why This Resolves The Executable Block + +The calculation and acceptance contracts already exist, but the checked-in SEC +field scope omits the three required cash-generation components and no real +source adapter supplies them. The SEC Companyfacts API and the issuer's exact +filed 10-Q now provide a narrow real-company evidence path that does not depend +on consensus, a paid provider, a hosted account, Q4 derivation, or synthetic +fixtures. + +The NVIDIA filing reports, for the quarter ended 2026-04-26: + +- operating income of USD 53.536 billion; +- net cash provided by operating activities of USD 50.344 billion; and +- purchases related to property and equipment and intangible assets of USD + 1.757 billion, displayed in parentheses in the filed cash-flow table. + +The filing-table presentation is the explicit evidence that capital +expenditures are a cash outflow. The pilot must not infer the negative sign from +the Companyfacts concept name, taxonomy balance, historical convention, or an +unsigned API magnitude. + +## Alternatives Considered + +### Exact Companyfacts plus filing-table evidence — selected + +Use Companyfacts for structured fact and accession selection, then require the +exact filed table to confirm the capital-expenditure line and its displayed +outflow sign. This preserves the existing reported-sign rule and produces a +durable filing reference. + +### Normalize Companyfacts payment concepts automatically — rejected + +Turning a positive `PaymentsToAcquireProductiveAssets` magnitude into a +negative outflow based only on concept semantics would be simpler, but it would +weaken the contract that prohibits inferred capex signs. + +### Wait for a commercial fundamentals or consensus provider — rejected for +this slice + +That path remains valid for broader coverage, but it is externally blocked and +is unnecessary for proving one official-source quarterly adapter path. + +## Source And Rights Boundary + +The exact source ID remains `sec_companyfacts`. The existing registry already +records approved commercial use for source-backed company facts, derived-data +redistribution only, SEC attribution, fair-access limits, and identified +requests. This slice may extend only that exact record's `supported_fields` to: + +- `operating_income`; +- `cash_from_operations`; and +- `capital_expenditures`. + +This is a field-scope decision under the existing SEC rights record, not a new +legal or market-data entitlement claim. No other source record changes. The +adapter must pass the explicit registry to the existing acceptance function; +unknown, composite, or scope-incomplete sources continue to fail closed. + +Every accepted observation must retain the exact SEC accession and a durable +filing URL. Capital expenditures must additionally retain the exact filed-table +fact or row reference used to prove the displayed sign. The pilot must identify +requests with `SEC_USER_AGENT` and respect SEC fair-access behavior. + +## Pilot Interface + +Add a focused SEC quarterly pilot module with two pure layers: + +1. A parser accepts already-retrieved Companyfacts, submissions, and filing + HTML payloads, exact company and fiscal-quarter identity, retrieval time, + and a cutoff. It returns immutable `QuarterlyBusinessObservation` and + compatible Revenue actual objects or deterministic blockers. The + submissions payload supplies the exact UTC `acceptanceDateTime`; filing date + midnight and retrieval time are not substitutes for publication time. +2. A preview function composes the parsed evidence with + `assess_quarterly_cash_generation_adapter` and returns an immutable result + containing the pilot identity, source references, extraction blockers, and + the existing acceptance result. + +Network retrieval stays in a thin injectable client boundary. Tests call the +parser with minimal in-memory payloads and never contact the network. A manual +preview may retrieve the exact SEC endpoints and print a concise human-readable +summary to stdout, but it must not write a file or print a persistence-ready +JSON payload. + +The preview result always exposes: + +- `production_activation=False`; +- `readiness_promotions=()`; +- the exact ticker, CIK, fiscal period, period end, accession, filing date, and + source URL; +- component facts and the capex-sign evidence state; +- stable blockers; and +- the nested adapter acceptance decision. + +## Exact-Quarter Extraction Rules + +The parser must require all of the following: + +1. One exact accession shared by Revenue, operating income, cash from + operations, and capital expenditures. +2. One exact start and end date representing the requested three-month fiscal + quarter. YTD facts cannot be differenced to create a quarter. +3. A filing date and publication timestamp no later than the requested cutoff. + The publication timestamp must equal the matching accession's SEC + submissions `acceptanceDateTime` and must be timezone-aware. +4. Supported concepts selected through an explicit ordered concept map rather + than fuzzy text matching. +5. Matching USD currency, unit scale, period end, accounting basis, and duration + basis across components and Revenue. +6. An exact filing-table row whose inline XBRL concept, context, and magnitude + match the selected capex Companyfacts fact and whose displayed presentation + explicitly marks the value as an outflow. +7. For Q4, an explicit SEC-filed three-month Q4 table. The NVIDIA Q1 pilot does + not exercise or relax this boundary. + +The initial supported concept map is intentionally narrow: + +- operating income: `OperatingIncomeLoss`; +- cash from operations: + `NetCashProvidedByUsedInOperatingActivities`; +- capital expenditures: `PaymentsToAcquireProductiveAssets`, with + `PaymentsToAcquirePropertyPlantAndEquipment` allowed only when it satisfies + the same exact-quarter and sign-evidence rules; and +- Revenue: reuse the existing SEC Revenue concept selection and + `QuarterlyActual` contract. + +Multiple conflicting facts, missing context identity, amended filings without +explicit supersession, missing filed-table sign evidence, mismatched +magnitudes, post-cutoff facts, or unsupported concepts block the affected +preview. The parser never guesses a fact or silently chooses among ambiguous +leaves. + +## Value And Sign Representation + +Values use USD with `unit_scale=1.0`, `duration_basis=three_months`, and one +explicit accounting-basis identifier shared by the compatible components and +Revenue. + +Operating income and cash from operations preserve their filed numeric values. +Capital expenditures enter `QuarterlyBusinessObservation` as a negative value +only after the exact filed table proves the matching outflow presentation. Free +cash flow remains the existing formula: + +`cash_from_operations + capital_expenditures` + +No issuer-defined adjusted FCF, annual-minus-nine-month calculation, taxonomy +balance inference, or post-processing sign convention is permitted. + +## Product And Readiness Boundary + +This slice establishes real-company source-adapter review evidence, not broad +coverage or product activation. It does not alter Research Desk, Discover, +Company Workbench, or Monitor output. It does not change actuals, consensus, +Revenue, EPS, valuation, catalyst, outcome, backtest, calibration, or hosted +readiness. + +Even a successful NVIDIA preview remains `accepted_for_review`. A separate, +explicit activation design and review would be required before Company +Workbench can consume live observations or saved readiness can change. + +Real-company Earnings Nowcast remains blocked without permitted point-in-time +consensus. Numerical Beat/Miss probability remains withheld without calibration +evidence. + +## Failure And Safety Behavior + +- Missing `SEC_USER_AGENT`: block before requesting SEC data. +- SEC error, timeout, or malformed payload: return a stable external-source + blocker; do not fall back to another provider. +- Missing exact accession or exact-quarter context: block. +- Capex magnitude present without explicit filed-table outflow evidence: block. +- Any Q4 derived from annual or nine-month facts: reject. +- Any post-cutoff filing or retrieval substituted for publication time: reject. +- Any requested persistence or production activation: unsupported by the pilot + interface. +- Empty or rejected evidence: show blockers and no fabricated values. + +## Test-First Coverage + +Implementation begins with failing tests for: + +1. the exact NVIDIA-shaped Q1 payload producing three compatible component + observations and a Revenue actual; +2. the matching filed-table parentheses producing a negative capex observation; +3. unsigned Companyfacts capex without table sign evidence blocking; +4. mismatched accession, context, magnitude, period, currency, or concept + blocking; +5. YTD-only and derived-Q4 candidates blocking; +6. cutoff and amended-filing ambiguity blocking; +7. missing, malformed, mismatched, or post-cutoff submissions acceptance time + blocking without substituting filing date or retrieval time; +8. missing `SEC_USER_AGENT` and retrieval failures blocking without fallback; +9. exact SEC field scope accepted and incomplete/unknown scope rejected; +10. successful composition returning `accepted_for_review` while keeping + activation false and readiness promotions empty; +11. no filesystem writes, generated artifacts, broad refreshes, or network calls + in unit tests. + +Focused tests cover the new parser/client boundary, quarterly adapter +acceptance, source rights, SEC provider regressions, and the quarterly business +metric contract. + +## Verification And Delivery + +After implementation, run focused tests followed by: + +- `python3 -m pytest tests -q`; +- `make dashboard-smoke`; +- all applicable Research route render smoke checks; +- `make public-wording-check`; +- `make public-check`; +- commercial-beta and package/release checks required by the repository; +- `make pilot-readiness-check TOP_N=10` without rebuilding readiness; +- `make diff-hygiene-summary`; +- `make pr-range-hygiene-check`; +- `git diff --check`; and +- `make staged-hygiene-check` after exact staging. + +Stage only intentional source, test, config, methodology, roadmap, continuation +prompt, and design files. Keep draft PR #113 draft, push only to +`codex/personal-research-mode-mvp`, and do not merge or deploy. + +## Completion Boundary + +The slice is complete when the exact NVIDIA Q1 evidence can pass a read-only +preview from official SEC payloads, all failure paths remain fail closed, the +field-scope review is explicit, all required verification passes, and no +generated artifact is created or staged. + +This does not complete broad source coverage, readiness activation, consensus, +calibration, hosted validation, reviewer validation, or market validation. diff --git a/docs/superpowers/specs/2026-07-21-company-workbench-answer-first-handoff-design.md b/docs/superpowers/specs/2026-07-21-company-workbench-answer-first-handoff-design.md new file mode 100644 index 00000000..242eae30 --- /dev/null +++ b/docs/superpowers/specs/2026-07-21-company-workbench-answer-first-handoff-design.md @@ -0,0 +1,190 @@ +# Company Workbench Answer-First Handoff Design + +## Status + +Approved by the user and implemented in commit `ca4772ffb` on the active feature branch. Focused test-first contracts, the full local release matrix, and desktop/phone browser acceptance pass; exact push, draft-PR update, and exact-head CI evidence remain required before the slice is safe for draft review. + +## Problem + +Company Workbench is intended to answer the selected-company question before showing navigation or technical evidence. A live local audit of the AVGO route at `390x844` found that the selected-ticker answer begins at approximately `753px`, while its Data Health handoff begins at approximately `1136px`. The phone viewport is `844px` high. The page has no horizontal overflow or browser error, but the usable/withheld/evidence-handoff answer is not available in the first viewport. + +The current vertical order is: + +1. Global readiness and profile trust context. +2. Full Personal Research workspace header, including freshness and next action already represented elsewhere. +3. `Selected Company` heading. +4. Collapsed `Review path`. +5. Collapsed `Advanced: selected-company lane coverage`. +6. Selected-ticker answer. + +The two collapsed controls occupy approximately `100px`, but moving them alone is insufficient. The Workbench also repeats profile freshness and next-action context before the selected-ticker answer. + +## User Outcome + +A researcher opening Company Workbench for a selected ticker should see, in this order: + +1. The page and selected ticker identity. +2. What evidence is usable now. +3. What remains withheld. +4. The Data Health evidence handoff and stop condition. +5. Review-path and lane-coverage navigation. +6. Business trend, valuation, Forward View, research conclusion, next task, and Advanced evidence. + +The first four items should fit within the first `390x844` viewport for the audited AVGO route without hiding freshness, research-only boundaries, or blocked states. + +## Chosen Design + +Use a Workbench-specific compact header plus an anchored selected-answer slot. + +### Compact Workbench Header + +Extend the existing research workspace header contract with an explicit compact option used only by Company Workbench. The compact form keeps: + +- `Personal research mode`. +- The semantic `h1` for `Company Workbench`. +- The selected ticker and profile label. +- The research-only, no-investment-advice boundary. + +It omits the header freshness and next-action definition list because: + +- The global profile trust strip already displays saved freshness. +- The selected-ticker answer already supplies the next safe action and evidence boundary. + +Research Desk, Discover, Monitor, Data Health, and Proof History retain the existing header unchanged. + +### Anchored Selected Answer + +Company Workbench declares a Streamlit placeholder immediately after the compact header. It then renders the collapsed `Review path` and `Advanced: selected-company lane coverage` controls. + +`render_single_stock_report` accepts an optional selected-answer target. When supplied: + +- The fast loading summary renders into that target. +- The final report summary renders into the same target after local report composition. +- The summary is not duplicated at the report's execution position. +- All content after the summary continues to render in its current order below the two collapsed controls. + +This preserves the existing loading/final handoff contract while changing only its visual placement in Company Workbench. + +### Navigation Placement + +Remove the redundant `Selected Company` level-three heading because the compact header already provides the semantic page heading and selected ticker identity. + +Keep `Review path` and `Advanced: selected-company lane coverage` collapsed. They appear immediately after the anchored selected answer and before `What Changed` and the remaining report sections. + +Technical lane coverage stays under Advanced. No raw tables or technical diagnostics move into the primary answer. + +## Alternatives Rejected + +### Hide Profile/Freshness Context On Phone + +This would recover space but weaken the evidence-trust boundary. Freshness must remain visible and independent from the selected-company answer. + +### Move Both Expanders After The Entire Report + +This is mechanically simpler but makes the review path and lane coverage difficult to discover after a long report. The selected-answer slot provides answer-first placement without burying navigation. + +### Render A Separate Approximate Summary Before The Report + +The fast snapshot can differ from the final report state. Rendering an independent persistent summary would risk contradictory usable/withheld claims. The selected-answer slot must be filled by the same fast and final summary paths already used by the report. + +## Data And State Flow + +1. The route resolves the ticker and optional cash-generation preview exactly as it does now. +2. Company Workbench renders the compact header and creates the answer placeholder. +3. Collapsed review-path and technical lane-coverage controls render after the placeholder. +4. `render_single_stock_report` receives the placeholder. +5. If no report payload exists, the existing fail-closed fast snapshot renders into the placeholder while the saved local report is composed. +6. After rerun, the final readiness-derived selected-ticker answer replaces the fast answer in the same placeholder. +7. The report renders its existing sections and independent readiness states unchanged. + +No provider call, refresh, import, canonical write, readiness rebuild, evidence append, or generated artifact is introduced. + +## Failure And Loading Behavior + +- The loading answer remains explicitly temporary and cannot state that unavailable sections are ready. +- A report composition failure keeps existing warning/error behavior; it does not promote the fast snapshot into trusted final evidence. +- An empty answer frame continues to show the existing no-answer state. +- The optional target defaults to absent, preserving all current public and operator callers. +- The selected answer must render exactly once in the final Workbench DOM. + +## Accessibility And Responsive Requirements + +- `Company Workbench` remains the page `h1`. +- The selected answer retains `aria-label="Selected ticker answer"`. +- The Data Health link retains its ticker-preserving research-mode URL. +- The reading order is page identity, selected answer, review navigation, report content. +- The phone route has no horizontal overflow. +- The Data Health handoff is fully inside the first `390x844` viewport with a non-negative bottom clearance. +- The desktop route preserves the existing multi-column selected-answer layout and visible evidence handoff. +- Screenshot evidence cannot prove keyboard, screen-reader, contrast, or full WCAG conformance; existing automated and semantic checks remain required. + +## Test-First Implementation + +### Red + +Add failing contracts that require: + +- A compact header to retain page identity, ticker/profile scope, and research-only boundary while omitting duplicated freshness/action metadata. +- Company Workbench to declare its selected-answer target before review-path and lane-coverage controls. +- The report renderer to receive that target. +- Fast and final research-mode summaries to use the supplied target rather than render duplicate summaries. +- The final Workbench render to contain exactly one selected-ticker answer and preserve the ticker-specific Data Health URL. + +### Green + +Implement only the compact header option, optional selected-answer target, and Workbench placement changes needed to pass those contracts. + +### Verification + +- Focused research workspace, dashboard helper, render-smoke, and performance-contract tests. +- `python3 -m pytest tests -q`. +- `make dashboard-smoke`. +- `make public-wording-check`. +- `make public-check`. +- `make commercial-beta-release-check`. +- `make pilot-readiness-check TOP_N=10`. +- `make pr-range-hygiene-check`. +- `make diff-hygiene-summary`. +- `git diff --check`. +- `make staged-hygiene-check` after exact staging. +- Fresh desktop and `390x844` browser measurements of order, overflow, link visibility, and error state. + +## Invariants + +- Research-only; no investment advice or direct buy/sell instruction. +- No broker integration, order routing, auto-trading, or post-earnings price prediction. +- Candidate context cannot alter deterministic forecasts or become trusted evidence. +- Actuals, consensus, Revenue, EPS, operating margin, free cash flow, FCF margin, valuation, peer, catalyst, outcome, backtest, and calibration readiness remain independent. +- Missing evidence remains withheld. +- EPS split basis remains unverified without explicit proof. +- Q4 requires explicit SEC-filed three-month table evidence. +- Synthetic fixtures remain test-only. +- Empty valuation, catalyst, outcome, consensus, and field-proof ledgers remain empty. +- No readiness rebuild or generated CSV, JSON, report, sample-report, screenshot, timing, or canonical-data artifact enters the commit. + +## Acceptance Criteria + +The slice is complete only when: + +1. Focused and full tests pass. +2. The complete local release matrix passes. +3. Live desktop and phone evidence confirms the selected answer precedes both collapsed controls. +4. The ticker-specific Data Health handoff is fully visible in the first `390x844` viewport. +5. Exactly one final selected-ticker answer exists. +6. No readiness, source, report conclusion, or evidence state changes. +7. Only intentional code, test, documentation, and specification files are staged. +8. The 18 pre-existing generated CSV/report changes remain unstaged. +9. Independent engineering review reports no unresolved Critical or Important findings. +10. The exact pushed HEAD passes GitHub Actions while PR #113 remains draft. + +## Implementation Evidence + +The implementation uses a Workbench-only compact header, an anchored Streamlit target, and one target-aware summary renderer shared by the fast and final report paths. Live review also found that the selected answer's tracked multi-column styles were loaded only in Public mode, despite the design assumption that Personal Research mode already inherited them. The implementation therefore adds an explicit `research` summary class with scoped desktop and phone styles; it does not load the Public shell or change other routes. + +Current-tree AVGO measurements: + +- `1280x720`: one selected answer; CSS grid columns `128px 303.5px 303.508px 242.797px`; Data Health visible; answer precedes Review path; no horizontal overflow. +- `390x844`: one selected answer from approximately `409px` to `711px`; Data Health ends near `669px`; stop condition ends near `705px`; Review path ends near `746px`; lane coverage ends near `806px`; document and viewport widths are both `390px`. +- Browser logs contained only the initial connection-health errors recorded more than seven minutes before final measurement; no later product/runtime error was recorded during the final desktop or phone acceptance run. + +Screenshots remain ephemeral under `/tmp/stock-research-workflow-audit-2026-07-22/` and must not be staged. These measurements are local runtime evidence only and do not prove hosting, full accessibility conformance, independent review, demand, or market validation. diff --git a/docs/superpowers/specs/2026-07-21-company-workbench-task-arbitration-design.md b/docs/superpowers/specs/2026-07-21-company-workbench-task-arbitration-design.md new file mode 100644 index 00000000..ebd605b9 --- /dev/null +++ b/docs/superpowers/specs/2026-07-21-company-workbench-task-arbitration-design.md @@ -0,0 +1,61 @@ +# Company Workbench Task Arbitration Design + +## Purpose + +Company Workbench must end with one authoritative research task. A fresh desktop and phone audit found that the current AVGO route can simultaneously say to add point-in-time consensus, add peer mappings, and continue the review or wait. Each statement is locally truthful within its own lane, but together they violate the product promise of one clear next research task. + +## Scope + +This slice changes task composition and labels only. It does not change readiness, evidence, source rights, forecasts, scenarios, data files, provider access, or ledger contents. + +## Decision Contract + +Add one pure task-arbitration helper that receives: + +- the ticker-scoped research-change answer; and +- the existing ordered Research Conclusion cards. + +It returns one immutable task-shaped mapping with title, body, state, and badges. Research Conclusion primary cards declare an existing routing state explicitly: missing price, fundamentals, or peer evidence is `wait_for_evidence`; ETF/context review and full-report review are `review_now`. The arbiter preserves that state and never creates a new readiness or routing vocabulary. + +Priority is deterministic: + +1. A research change can win only when the change-answer mapping carries the explicit boolean source-backed eligibility field derived from `event.evidence_status == "source_backed"`. An open eligible item keeps its recorded suggested task; a still-blocked item keeps `wait_for_evidence` and its non-empty recorded wait condition (or the existing blocked fallback); an intentionally deferred item keeps `monitor` and its non-empty recorded wait condition (or the existing deferred fallback). +2. Otherwise the first existing Research Conclusion priority wins. That function already orders price, fundamentals, peer evidence, and full-report review without treating optional earnings or estimates as core readiness. +3. If neither input provides a task, return a neutral wait condition without inventing evidence or an action. + +The change-answer mapping also carries an explicit context kind with exactly `none`, `snapshot_only`, or `source_backed`. `none` receives a neutral no-queued-change label, `snapshot_only` receives only the snapshot-evidence label, and `source_backed` receives the source-backed label. Snapshot-only items remain visible context, receive no source-backed badge, and never outrank Research Conclusion. The arbiter does not infer context or eligibility from state, text, source reference, or caller shape. It does not inspect candidate context, infer source availability, change deterministic scenarios, or upgrade blocked evidence. + +## Presentation Contract + +- `Research Conclusion` remains a summary of usable evidence, gaps, and optional context. Its first card is labeled `RESEARCH PRIORITY`, not `NEXT STEP`. +- `Next Research Task` renders only the authoritative arbitration result. +- The Forward View card keeps its lane-specific guidance but is labeled `FORWARD-VIEW LANE UNBLOCK`, not `NEXT RESEARCH TASK`. +- Technical evidence remains under Advanced. +- Desktop and phone layouts remain structurally unchanged. + +For the audited AVGO state, the result is one peer-evidence task. The absence of a queued source change remains visible in `What Changed`, while missing consensus remains a Forward View lane unblock rather than a competing overall task. + +## Failure And Safety Behavior + +- Missing or malformed task inputs produce the neutral wait condition. +- A non-mapping change answer, invalid card collection, non-mapping card, or invalid badge collection fails closed as one whole arbitration input; the helper never skips malformed input to choose a later card. +- Empty change evidence never becomes a fabricated event. +- Optional earnings and analyst-estimate gaps remain lower priority. +- No direct buy/sell wording, post-earnings price prediction, probability, recommendation, broker action, or data mutation is introduced. + +## Verification + +Tests must prove: + +- an explicitly eligible source-backed open change outranks the conclusion priority, while a snapshot-only open change does not; +- no matching change maps to context kind `none`, snapshot-only matching change maps to `snapshot_only`, and eligible source-backed change maps to `source_backed`, with no badge crossover; +- source-backed still-blocked and intentionally deferred changes preserve their routing and wait conditions; +- with no change, the first ordered conclusion priority becomes the one task; +- blocked-input priorities preserve `wait_for_evidence`, while directly reviewable priorities preserve `review_now`; +- empty inputs produce the neutral wait condition; +- Research Conclusion no longer emits a `NEXT STEP` kicker; +- Forward View no longer emits a `NEXT RESEARCH TASK` kicker; +- rendered AVGO Company Workbench contains a scoped no-queued-change card without a snapshot-only badge, exactly one `ONE NEXT TASK` marker with `Add peer mappings`, one `FORWARD-VIEW LANE UNBLOCK`, no retired uppercase `NEXT RESEARCH TASK` kicker, and zero exceptions; +- existing independent readiness and fail-closed tests remain unchanged. + +Run focused tests first, then the full repository and all required dashboard, research-render, public, commercial-beta, pilot, whitespace, and hygiene gates. diff --git a/docs/superpowers/specs/2026-07-21-proof-readiness-conflict-diagnosis-design.md b/docs/superpowers/specs/2026-07-21-proof-readiness-conflict-diagnosis-design.md new file mode 100644 index 00000000..f1ef4d18 --- /dev/null +++ b/docs/superpowers/specs/2026-07-21-proof-readiness-conflict-diagnosis-design.md @@ -0,0 +1,282 @@ +# Proof-Readiness Conflict Diagnosis Design + +**Date:** 2026-07-21 + +**Status:** Approved direction; written specification awaiting final review + +## Purpose + +Make proof-readiness reconciliation explain two independent questions without weakening fail-closed readiness: + +1. Does a historical batch outcome apply to this exact ticker and lane? +2. What observable current input prevents the lane from being ready now? + +The reconciliation remains read-only. Current saved readiness remains authoritative. Historical proof never restores canonical data, promotes readiness, rewrites proof history, or proves current source rights, field scope, provenance, payload truth, commercial use, hosting, reviewer adoption, or market validation. + +## Current Evidence + +The current saved snapshot contains 21,246 reconciliation rows across 3,541 tickers and six canonical lanes. It reports 3,506 `historical_supported_currently_blocked` rows: + +- fundamentals: 2,615 +- share count: 862 +- DCF: 20 +- price: 6 +- peer mapping: 3 + +The fundamentals audit found: + +- 2,607 conflicts whose latest proof explicitly names the ticker in `changed_tickers`; +- 8 conflicts whose ticker appears only in batch scope and is not named in `changed_tickers`; +- 2,605 conflicts with no current canonical fundamentals row; +- 10 conflicts with a current SEC Companyfacts row that lacks one or more required fundamentals fields. + +Across all conflict lanes, 44 rows are scope-only or otherwise lack explicit ticker-change attribution: 8 fundamentals, 15 DCF, and 21 share-count rows. A supporting batch outcome cannot truthfully establish ticker-level support for those rows. + +The historical ledger records batch-level outcomes and narrative notes. It does not preserve a structured per-ticker, per-field payload snapshot or a structured exact-source rights/scope decision. The system therefore cannot reliably determine whether a genuine historical/current divergence resulted from data removal, a changed readiness contract, a changed source-rights decision, a changed field-scope decision, or another historical event. It must state that limitation instead of guessing. + +Snapshot counts are audit evidence only. They are not durable coverage claims and may change when the saved inputs change. + +## Design Principles + +1. **Two independent axes.** Historical applicability and current blocking evidence must never be collapsed into one label. +2. **Explicit ticker attribution.** A batch-level supporting outcome applies to a ticker only when the normalized ticker appears in the proof row's structured `changed_tickers` field. +3. **Current state is authoritative.** Current readiness and current blocker fields come only from the saved readiness inputs. +4. **No narrative inference.** `notes`, `scope`, `command_run`, and other free text may be displayed as historical context but cannot establish source identity, rights, supported fields, payload truth, or a root cause. +5. **Fail closed on malformed evidence.** Missing, placeholder, malformed, ambiguous, or out-of-universe ticker-change evidence is not supporting ticker-level proof. +6. **No writes.** The command and dashboard integration do not change canonical data, proof ledgers, readiness reports, generated artifacts, or external services. +7. **Advanced evidence only.** Detailed reconciliation remains in the CLI/JSON contract and Advanced Proof History. The four primary research routes remain unchanged. + +## Historical Applicability Contract + +Every applicable historical proof receives one `proof_applicability` value: + +- `explicit_ticker_change`: the proof has a valid review date, an explicitly supporting outcome, and the ticker is listed in normalized `changed_tickers`. +- `scope_only_not_supported`: the ticker is in the batch `tickers` scope but not in a nonempty normalized `changed_tickers` set. +- `missing_ticker_change_detail`: the proof is otherwise applicable by lane and scope, but `changed_tickers` is blank, placeholder, malformed, or a reviewed no-change value. +- `non_supporting_outcome`: the latest proof outcome is not one of `supported`, `auto_supported`, or `human_reviewed_supported`. +- `malformed_review_date`: the latest proof date is invalid and cannot support. +- `no_applicable_proof`: no valid ticker-and-lane proof row exists. + +Only `explicit_ticker_change` is ticker-level supporting evidence. + +The existing `tickers` field remains the batch scope used to locate potentially applicable proof rows. It does not become proof of a ticker-level change. `changed_tickers` is normalized using the same comma/semicolon token rules and current-universe validation as batch scope. + +When multiple proof rows apply, the existing deterministic latest-proof ordering remains: valid ISO review dates outrank malformed dates, later valid dates outrank earlier dates, and append order breaks exact-date ties. Applicability is evaluated after selecting the latest proof. The system does not search backward for an older supporting row when the latest row is non-supporting or malformed. + +## Current Blocker Contract + +Every reconciliation row receives structured current-state fields: + +- `current_blocker_code` +- `current_blocker_fields` +- `current_blocker_detail` +- `next_safe_review` + +The blocker is derived only from current saved readiness inputs. + +### Fundamentals + +- `current_canonical_row_missing` when `fundamentals_ready=false` and no current canonical fundamentals row exists. +- `current_required_fields_missing` when a current canonical row exists and the current DCF readiness row names missing required fields. +- `current_readiness_input_unavailable` when the required current input cannot be loaded or interpreted. +- `none` when current readiness is true. + +`current_blocker_fields` contains only canonical current field names in deterministic order: `free_cash_flow`, `shares_outstanding`, `revenue`, `fcf_margin`, and `price` when applicable. Fundamentals diagnosis uses the current canonical fundamentals row to distinguish absent payload from incomplete payload; it does not claim that price alone is a fundamentals field. + +### DCF and Share Count + +- `current_required_fields_missing` from current `missing_dcf_fields` when the DCF input is available. +- `current_canonical_row_missing` when the current canonical fundamentals row is absent and the required DCF fields are missing. +- `current_readiness_input_unavailable` when the DCF readiness input is unavailable or malformed. +- `none` when the authoritative lane field is true. + +Share-count diagnosis reports `shares_outstanding` only. It does not turn other missing DCF fields into share-count blockers. + +### Price + +- `current_price_missing` when `price_ready=false`. +- `current_readiness_input_unavailable` when current ticker readiness is unavailable or malformed. +- `none` when `price_ready=true`. + +The diagnosis does not infer a provider, source, refresh failure, or source-rights status. + +### Peer Mapping and Peer Valuation Inputs + +- `current_peer_mapping_missing` when `peer_ready=false` for peer mapping. +- `current_peer_valuation_inputs_missing` when `peer_valuation_ready=false`. +- `current_readiness_input_unavailable` when the required peer input is unavailable or malformed. +- `none` when the authoritative lane field is true. + +The diagnosis does not fabricate peers, comparability, valuation anchors, or source proof. + +## Historical Evidence-Limitation Contract + +Every row with a historical proof also receives: + +- `historical_payload_status` +- `historical_evidence_limit` + +The current ledger does not contain a structured per-ticker/per-field payload snapshot, so existing proof rows use: + +- `historical_payload_status=structured_payload_not_recorded` +- `historical_evidence_limit=Historical batch proof cannot distinguish payload removal, readiness-contract change, source-rights change, field-scope change, or another historical cause.` + +This limitation remains present even when `proof_applicability=explicit_ticker_change`. Free-text notes cannot upgrade it. + +Future structured proof rows may introduce a versioned per-ticker/per-field evidence contract in a separate design. This slice does not modify the append-only proof schema or retroactively manufacture structured evidence. + +## Reconciliation State Changes + +The existing state names remain stable for consumers, but ticker-level support is recalculated from `proof_applicability`: + +- `current_supported_with_matching_proof` requires current readiness true and `explicit_ticker_change`. +- `historical_supported_currently_blocked` requires current readiness false and `explicit_ticker_change`. +- `current_ready_proof_not_supporting` includes current-ready rows whose latest proof is scope-only, missing ticker-change detail, malformed, non-supporting, or absent. +- `currently_blocked_with_non_supporting_history` includes current-blocked rows with a proof that is scope-only, missing ticker-change detail, malformed, or non-supporting. +- `no_proof_record` and `not_applicable` retain their existing meanings. + +This reclassification removes false ticker-level historical-support claims. It does not make any current lane ready. + +## Interfaces + +### Python + +`ProofReadinessReconciliationRow` gains the following immutable fields: + +- `proof_applicability: str` +- `current_blocker_code: str` +- `current_blocker_fields: tuple[str, ...]` +- `current_blocker_detail: str` +- `next_safe_review: str` +- `historical_payload_status: str` +- `historical_evidence_limit: str` + +`build_proof_readiness_reconciliation` additionally accepts the current canonical fundamentals frame. Loading remains tolerant of missing or malformed files and fails closed to explicit unavailable states. + +### CLI and JSON + +`make proof-readiness-reconciliation TOP_N=20` remains the only command. No writing target is added. + +Text output adds: + +- applicability counts; +- current blocker counts; +- `Proof applicability`, `Current blocker`, and `Next safe review` columns for displayed rows; +- a boundary stating that blocker diagnosis describes current observable inputs and not the historical cause. + +JSON adds the new row fields plus top-level `proof_applicability_counts` and `current_blocker_counts`. Existing keys remain available. + +### Advanced Proof History + +The global reconciliation card continues to show the historical-support/current-readiness conflict count. Its body adds the largest current blocker category and explicitly distinguishes current blocker evidence from historical cause. + +For a selected ticker, the second card names: + +- canonical conflicting lanes; +- proof applicability; +- current blocker fields or blocker code; +- the safe next review. + +Cards expose no mutation command. Raw ledger details remain collapsed below the cards. Research Desk, Discover, Company Workbench, and Monitor do not receive this technical detail. + +## Safe Next-Review Rules + +Next-review text is deterministic and non-writing: + +- scope-only or missing change detail: review the proof row; do not reuse it as ticker-level support; +- canonical fundamentals row missing: obtain and review a permitted source payload for the exact ticker before any import or readiness rebuild; +- required fields missing: review the named missing fields through the existing source-review and preview-first workflow; +- price missing: inspect the exact ticker's current price evidence without inferring a provider; +- peer mapping missing: review a source-backed relationship through the existing peer evidence contract; +- peer valuation inputs missing: review current peer valuation inputs independently from mapping readiness; +- unavailable input: restore or inspect the current saved input before drawing a conclusion. + +The reconciliation does not run any suggested workflow. + +## Error Handling + +- Missing current ticker readiness returns an unavailable summary and no inferred rows. +- Missing DCF, peer, or canonical fundamentals inputs produce lane-specific unavailable diagnoses without blocking independent lanes. +- Duplicate ticker tokens are deduplicated deterministically. +- Placeholder `changed_tickers` values such as `-`, `none`, `n/a`, `not available`, and `unknown` do not support. +- Unknown proof outcomes fail closed as `non_supporting_outcome`. +- Malformed review dates remain non-supporting and cannot outrank a valid dated proof. +- Unknown missing-field tokens are excluded from canonical blocker fields and surfaced only in a fail-closed detail message. +- No free text is parsed for source identity, rights, field scope, or historical root cause. + +## Testing Strategy + +Tests must be written and observed failing before production changes. + +Focused unit coverage will prove: + +1. A scope-only ticker with a supporting batch outcome is not ticker-level supported. +2. An explicitly changed ticker retains historical-support conflict classification when current readiness is false. +3. Placeholder or no-change `changed_tickers` fails closed. +4. Latest-proof ordering remains deterministic and does not fall back to older support. +5. A missing canonical fundamentals row produces `current_canonical_row_missing`. +6. An incomplete canonical fundamentals row produces exact ordered missing fields. +7. Share-count diagnosis reports only `shares_outstanding`. +8. Price and peer lanes retain independent blocker diagnoses. +9. Missing current inputs affect only their dependent lanes. +10. JSON and text output expose both axes and retain existing keys. +11. Free-text source claims cannot upgrade the historical evidence limitation. +12. The command remains filesystem read-only. + +Dashboard tests will prove: + +1. The global Advanced Proof History card distinguishes current blockers from historical causes. +2. A selected ticker card shows only its own lanes and blocker evidence. +3. Cards contain no command. +4. The four primary research routes remain unchanged. +5. All six Research Mode render contracts still pass. + +Full release verification remains: + +- focused changed-module tests; +- `python3 -m pytest tests -q`; +- `make dashboard-smoke`; +- `make research-dashboard-render-smoke`; +- `make public-wording-check`; +- `make commercial-beta-check`; +- `make public-check`; +- `make commercial-beta-release-check`; +- `make pilot-readiness-check TOP_N=10`; +- `make diff-hygiene-summary`; +- `git diff --check`; +- `make staged-hygiene-check` and `git diff --cached --check` after exact staging; +- exact-head GitHub CI after pushing. + +## Documentation and Delivery + +After implementation verification: + +- update `ROADMAP.md` with the implemented applicability and diagnosis boundary; +- update `docs/OPERATOR_GUIDE.md` with the two-axis interpretation; +- update `docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md` with the new safe continuation rule; +- update the existing release-document contract tests; +- update draft PR #113 with current-snapshot counts and verification evidence; +- stage exact product/code/docs/test files only; +- preserve all generated CSV, JSON, report, sample-report, screenshot, timing, and canonical-data churn as unstaged unless separately reviewed and approved; +- push only `codex/personal-research-mode-mvp` and keep PR #113 draft. + +## Non-Goals + +This slice does not: + +- rebuild readiness; +- restore or delete canonical rows; +- edit historical proof rows; +- infer historical causes from narrative text; +- approve yfinance or another source for commercial use; +- add source rights or supported fields; +- fetch a provider or run a broad refresh; +- create per-field historical evidence retroactively; +- activate Earnings Nowcast, consensus, calibration, valuation, catalysts, outcomes, or backtesting; +- change Q4 or EPS split-basis boundaries; +- change the primary research workflow; +- prove hosting, authentication, external reviewer adoption, operating readiness, commercial demand, or product-market fit. + +## Future Maturity Stage + +Design a versioned append-only per-ticker/per-field proof record that preserves exact source ID, durable reference, as-of date, retrieval time, reviewed field scope, rights decision reference, payload digest, readiness contract version, and reviewer decision. Adoption must be prospective. Existing narrative batch proof remains historical audit context and must never be silently upgraded. diff --git a/docs/superpowers/specs/2026-07-21-proof-readiness-reconciliation-design.md b/docs/superpowers/specs/2026-07-21-proof-readiness-reconciliation-design.md new file mode 100644 index 00000000..561b1681 --- /dev/null +++ b/docs/superpowers/specs/2026-07-21-proof-readiness-reconciliation-design.md @@ -0,0 +1,149 @@ +# Proof-Readiness Reconciliation Design + +## Problem + +The reviewed batch proof ledger is append-only historical evidence, while the readiness reports describe the current saved product state. Those two sources can diverge legitimately when generated canonical data is retained only locally, later replaced, or excluded from a commit. The product currently shows latest proof outcomes without explicitly reconciling them to current readiness, so a historical `supported`, `auto_supported`, or `human_reviewed_supported` outcome can be mistaken for current support. + +The current repository demonstrates the risk directly. Batch `RB-20260626-FUND-SOURCE-LADDER-002` says ARCT fundamentals were applied and auto-supported, but ARCT is absent from both the committed canonical fundamentals file at that proof commit and the current canonical fundamentals file. Current readiness correctly keeps ARCT blocked. A read-only audit of valid current-universe tickers found 3,486 ticker/lane pairs whose latest mapped proof outcome is supporting while the corresponding current readiness state is blocked: 2,615 fundamentals, 862 share count, 6 price coverage, and 3 peers. These counts are audit findings from the current local snapshot, not permanent product constants. + +This is a proof-interpretation defect, not permission to restore old data, rewrite history, or promote readiness. + +## Decision + +Add one pure proof-readiness reconciliation contract that compares the latest applicable proof outcome for each valid ticker and lane with the corresponding current saved readiness state. + +The contract will expose these explicit states: + +- `current_supported_with_matching_proof`: current readiness is true and the latest applicable proof outcome is explicitly supporting; +- `historical_supported_currently_blocked`: the latest applicable proof outcome is explicitly supporting but current readiness is false; +- `current_ready_proof_not_supporting`: current readiness is true but the latest applicable proof is absent, non-supporting, malformed, or unknown; +- `currently_blocked_with_non_supporting_history`: current readiness is false and the latest applicable proof is explicitly non-supporting; +- `no_proof_record`: the ticker/lane has a current readiness state but no applicable proof row; +- `not_applicable`: no authoritative current readiness mapping exists for the lane or ticker. + +Only literal `supported`, `auto_supported`, and `human_reviewed_supported` outcomes count as supporting historical proof. Unknown outcomes fail closed. A supporting historical outcome never changes current readiness, source rights, field scope, provenance, or product availability. + +## Alternatives Considered + +### Reconcile only the DCF source-review queue + +Rejected as too narrow. The current audit found the same interpretation risk in fundamentals, share count, price coverage, and peer lanes, and Proof History is the shared evidence surface. + +### Rewrite or delete contradictory proof rows + +Rejected because the ledger is append-only audit history. Historical claims must remain inspectable even when later evidence contradicts or supersedes them. + +### Treat every historical supporting outcome as current support + +Rejected because generated data can remain local or disappear from the committed canonical state. Current saved readiness remains authoritative for current availability. + +### Shared read-only reconciliation contract + +Selected because one deterministic comparison can protect CLI and Advanced Proof History surfaces without mutating either input source. + +## Architecture + +Create `src/proof_readiness_reconciliation.py` as a focused, read-only module. + +It will: + +1. load or accept the reviewed proof ledger, ticker readiness report, DCF readiness report, and peer readiness report; +2. normalize column names without changing input frames; +3. accept only ticker tokens that exist in the current readiness universe, preventing descriptive ledger text from becoming a fabricated ticker; +4. map each supported proof lane to exactly one authoritative current state; +5. choose the latest applicable proof deterministically by valid review date and append order; +6. emit immutable reconciliation rows and aggregate counts; +7. render a bounded human-readable table and optional JSON without writing files. + +Lane mappings remain explicit: + +| Proof lane | Authoritative current field | +| --- | --- | +| `fundamentals` | ticker readiness `fundamentals_ready` | +| `fundamentals_dcf` | ticker readiness `dcf_ready` | +| `share_count` | DCF readiness `has_shares_outstanding` | +| `prices`, `price`, `price_coverage`, `price_history` | ticker readiness `price_ready` | +| `peers`, `peer_mapping` | ticker readiness `peer_ready` | +| `peer_valuation_inputs` | peer readiness `peer_valuation_ready` | + +Every other lane is `not_applicable`; it is never coerced into one of these states. Earnings, analyst estimates, consensus, Revenue, EPS, valuation history, catalysts, outcomes, backtesting, and calibration remain independent. + +Add `make proof-readiness-reconciliation TOP_N=20` as the read-only operator entry point. Optional `TICKERS=` filtering must narrow output only and must never change the aggregate or underlying data. + +Advanced Proof History will consume the pure reconciliation summary. It will show the answer before ledger detail: + +- whether historical support conflicts with current readiness; +- bounded counts by lane; +- the selected ticker's conflicts when a ticker is present; +- the exact read-only command for further inspection; +- a boundary stating that reconciliation neither restores data nor unlocks a lane. + +The four primary Personal Research pages remain unchanged. Proof reconciliation stays under Advanced Evidence and cannot become a competing research recommendation or next task. + +## Data Flow + +```text +reviewed_batch_proofs.csv ----+ +ticker_readiness_report.csv --+--> pure reconciliation --> CLI / Advanced Proof History +dcf_readiness_report.csv ------+ +peer_readiness_report.csv -----+ +``` + +No output flows back into canonical data, readiness reports, the proof ledger, research decisions, forecasts, or UI routing. + +## Error Handling + +- Missing or empty proof ledger returns `no_proof_record` for mapped current states. +- Missing required readiness input fails closed with an explicit unavailable-input summary; it does not infer false readiness. +- Malformed review dates cannot outrank valid dated rows. If only malformed dated rows exist, the latest append-order row remains historical evidence but its reconciliation is non-supporting and visibly malformed. +- Descriptive strings, dashes, counts, and unknown tokens in a ledger `tickers` field are ignored unless they exactly match a current-universe ticker. +- Duplicate ticker tokens in one proof row are deduplicated. +- Unknown lanes and outcomes remain visible as `not_applicable` or non-supporting evidence; prefix matching is forbidden. +- Boolean parsing accepts only explicit true/false representations used by current reports. Missing or malformed values produce an unavailable current state, never `true`. + +## Testing + +Use strict red-green development. + +1. Prove a historical supporting fundamentals row plus current blocked fundamentals yields `historical_supported_currently_blocked`. +2. Prove current readiness plus matching supporting proof yields `current_supported_with_matching_proof`. +3. Prove current readiness with no or non-supporting proof yields `current_ready_proof_not_supporting`. +4. Prove a current blocked lane with a later `still_blocked` outcome does not reuse an earlier supporting outcome. +5. Prove fundamentals, DCF, share count, price, peer mapping, and peer valuation inputs use independent authoritative fields. +6. Prove unknown lanes, outcomes, malformed dates, malformed booleans, duplicate tickers, and descriptive ticker text fail closed. +7. Prove ticker filtering and `TOP_N` only bound presentation. +8. Prove the CLI writes no files and prints the research-only and non-promotion boundaries. +9. Prove Advanced Proof History shows aggregate and selected-ticker conflicts before raw ledger detail while primary research routes remain unchanged. +10. Run focused tests, the full repository suite, dashboard and research render smoke, public wording/public checks, commercial-beta and release checks, pilot readiness, diff hygiene, whitespace checks, and staged hygiene. + +## Documentation + +Update: + +- `ROADMAP.md` with the verified reconciliation behavior and its evidence limits; +- `docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md` so future runs inspect reconciliation before reusing supporting proof; +- relevant Proof History/operator documentation with the read-only command and state definitions. + +Historical audit counts must be described as current-snapshot findings, never durable coverage totals. + +## Acceptance Criteria + +1. Current readiness remains the only authority for current lane availability. +2. Historical supporting proof that conflicts with current readiness is labeled `historical_supported_currently_blocked`. +3. Current readiness without matching supporting proof is not silently described as proof-backed. +4. Lane mappings remain explicit and independent. +5. Advanced Proof History exposes the conflict before raw proof detail and keeps it under Advanced Evidence. +6. No proof row, canonical data row, readiness row, research output, or generated artifact is written or modified by reconciliation. +7. The existing 18 generated readiness files remain unstaged and excluded. +8. Focused and complete verification pass. +9. Only exact intentional code, test, Makefile, and documentation files are staged. +10. PR #113 remains open and draft. + +## Non-Goals + +- No restoration of omitted canonical data. +- No mutation, deletion, correction, or supersession of historical proof rows. +- No source fetch, provider probe, import, preview, apply, readiness rebuild, or generated report. +- No inference of source rights, field scope, provenance, payload truth, freshness, reviewer intent, or current support from a proof label. +- No broad provider, coverage, consensus, peer-sourcing, or calibration loop. +- No investment advice, ranking, recommendation, broker integration, order routing, or auto-trading. diff --git a/docs/superpowers/specs/2026-07-21-prospective-field-proof-design.md b/docs/superpowers/specs/2026-07-21-prospective-field-proof-design.md new file mode 100644 index 00000000..0de8f10f --- /dev/null +++ b/docs/superpowers/specs/2026-07-21-prospective-field-proof-design.md @@ -0,0 +1,183 @@ +# Prospective Per-Ticker Field Proof Design + +**Date:** 2026-07-21 + +**Status:** Approved Stage A direction; implementation pending + +## Purpose + +Add a prospective, append-only proof contract that can preserve what was reviewed for one exact ticker and field without retroactively upgrading narrative batch history. The contract records evidence identity and reviewer disposition; it never claims that a referenced payload is true, licensed, commercially usable, or readiness-eligible merely because a row exists. + +This Stage A slice is deliberately isolated from current readiness. It creates a testable evidence primitive before any future mapping into Company Workbench, reconciliation, canonical data, or a readiness lane. + +## Current Evidence And Boundary + +The existing reviewed-data and reviewed-batch ledgers preserve batch scope, outcomes, and narrative notes. They do not preserve a structured per-ticker/per-field payload digest, an exact source-rights decision reference, or a linear revision chain for that field proof. Current proof-readiness reconciliation therefore truthfully reports `structured_payload_not_recorded` for legacy history. + +The new ledger is prospective only. Existing rows are not migrated, inferred, or upgraded. An absent or empty ledger produces an explicit unavailable/empty status and no fabricated content. No sample or synthetic proof row is checked in as product evidence. + +## Alternatives Considered + +### 1. Extend the narrative batch-proof ledger + +This would minimize file count, but it would mix batch outcomes with field-level payload identity and make old narrative rows appear structurally equivalent to new evidence. Rejected because it invites retroactive overclaiming and weakens the current reconciliation boundary. + +### 2. Store structured details inside narrative notes or JSON text + +This would avoid a schema addition, but free text cannot safely enforce identity, scope, revision lineage, rights references, or deterministic receipts. Rejected because narrative text must not become trusted evidence. + +### 3. Add an isolated prospective field-proof ledger + +Selected. A separate strict schema can fail closed, preserve append-only revisions, support preview-before-record receipts, and remain disconnected from readiness until a separately designed activation contract exists. + +## Architecture + +Add `src/prospective_field_proof.py` with immutable record, preview, batch-preview, validation, receipt, append, rendering, and CLI interfaces. The implementation reuses proven repository mechanics rather than sharing mutable state: + +- strict CSV headers and append-only ledger validation from `src/earnings_consensus_collector.py`; +- UTC timestamp parsing from `src/earnings_nowcast_contract.py`; +- exact source and supported-field review from `src/commercial_source_rights.py`; +- semantic SHA-256 identities, ledger/input digests, and preview receipts; +- all-or-nothing append after revalidating the approved preview against current ledger and input bytes. + +The default ledger path is `data/prospective_field_proofs.csv`. The file is not created by status or preview commands. Recording is an explicit write action requiring an exact preview receipt. + +## Record Contract + +Schema version: `prospective-field-proof-v1`. + +Strict ordered fields: + +- `schema_version` +- `proof_id` +- `ticker` +- `field_key` +- `readiness_contract_version` +- `observed_at` +- `retrieved_at` +- `source_id` +- `source_ref` +- `source_status` +- `rights_status` +- `rights_decision_ref` +- `payload_status` +- `payload_sha256` +- `reviewer_id` +- `reviewer_decision` +- `reviewed_at` +- `supersedes_proof_id` + +The normalized scope key is `(ticker, field_key, readiness_contract_version)`. Ticker and field keys are normalized before comparison. Every row must have a semantic `proof_id` equal to its deterministic identity digest. A payload digest must be exactly 64 lowercase hexadecimal characters; the payload itself is not stored. + +Allowed controlled values are independent: + +- `source_status`: `identified`, `unavailable`, `disputed`; +- `rights_status`: `approved`, `unverified`, `restricted`, `not_applicable`; +- `payload_status`: `reviewed`, `unavailable`, `rejected`; +- `reviewer_decision`: `accepted`, `rejected`, `needs_follow_up`. + +An accepted record requires identified source, reviewed payload, a non-placeholder source reference, a payload digest, and a non-placeholder reviewer. It does not by itself require `rights_status=approved`, because technical evidence review and commercial-rights review remain independent. Commercial eligibility is reported separately and fails closed unless the exact registered source and field scope are approved and the record carries an explicit rights-decision reference. + +Timestamps must be timezone-aware UTC. `observed_at <= retrieved_at <= reviewed_at`, and every timestamp must be at or before the explicit review cutoff. Future-dated or malformed rows fail closed. + +## Revision And Integrity Contract + +Each scope has exactly one root and at most one child per revision. A revision must: + +- name an existing parent in the same normalized scope; +- be appended after its parent; +- have a strictly later `reviewed_at` timestamp; +- preserve a linear chain with no forks or cycles; +- supersede the current leaf, not an older revision; +- have a distinct semantic identity and `proof_id`. + +Proof IDs and semantic identities must be globally unique. A source reference or payload digest is evidence identity only; neither proves factual correctness, source rights, provenance completeness, readiness, or commercial usability. + +## Preview-Before-Record Contract + +`preview_field_proof_batch` validates the existing ledger, proposed rows, cutoff, revision chains, and source-rights field scope without writing. It returns immutable row previews plus: + +- normalized cutoff; +- current ledger digest; +- proposed input digest; +- source-rights registry digest; +- technical and commercial blockers; +- technical write eligibility; +- commercial evidence eligibility; +- deterministic preview receipt. + +`append_reviewed_field_proof_batch` repeats all validation and requires the exact receipt. Any change to ledger contents, proposed input, cutoff, commercial mode, or source-rights registry invalidates the receipt. The append is all-or-nothing and uses one header only. It does not update readiness, canonical data, proof reconciliation, dashboards, reports, or other ledgers. + +Technical recording remains available in research mode when the technical contract passes. Explicit commercial mode additionally requires exact source registration, approved commercial rights, supported `field_key`, and a non-placeholder `rights_decision_ref`. An unavailable provider, rights review, or hosted account blocks only its dependent commercial lane. + +## Interfaces + +### Python + +- `ProspectiveFieldProofRecord` +- `FieldProofPreview` +- `BatchFieldProofPreview` +- `field_proof_identity(record)` +- `load_field_proofs(path)` +- `load_proposed_field_proofs(path)` +- `validate_field_proof_ledger(records)` +- `preview_field_proof_batch(...)` +- `append_reviewed_field_proof_batch(...)` + +### CLI And Make + +- `make prospective-field-proof-status`: read-only summary of absent, empty, valid, or invalid ledger state. +- `make prospective-field-proof-preview INPUT=... AS_OF=...`: read-only batch validation and receipt generation. +- `make prospective-field-proof-record INPUT=... AS_OF=... PREVIEW_RECEIPT=...`: explicit append after receipt revalidation. + +All commands state their read/write boundary. Status and preview must not create the ledger, rewrite input, refresh data, produce generated reports, or touch network services. + +## Error Handling + +- Missing ledger: valid empty state for status/preview. +- Empty existing ledger file: invalid because a present ledger must contain the exact header. +- Wrong, reordered, duplicated, or extra headers: fail closed. +- Blank required fields, placeholders, invalid enum values, malformed digests, or non-UTC timestamps: fail closed with row number. +- Duplicate identity or proof ID, missing/cross-scope parent, root fork, child fork, cycle, non-leaf revision, or non-monotonic review time: fail closed. +- Unknown source or unsupported field: technical evidence may remain separately reviewable in research mode, but commercial eligibility is false. +- Stale receipt or partially invalid batch: append nothing. + +## Testing Strategy + +Tests must be written and observed failing before implementation. Focused coverage will prove: + +1. strict schema, normalization, enum, digest, placeholder, and UTC validation; +2. missing-ledger status is empty and read-only; +3. deterministic identity and unique ID enforcement; +4. exactly one linear revision chain per scope; +5. cross-scope, missing-parent, fork, cycle, stale-leaf, and time-order rejection; +6. independent technical and commercial eligibility; +7. exact source-rights and supported-field review; +8. deterministic preview receipts over ledger, input, cutoff, mode, and registry; +9. stale receipt rejection after any relevant change; +10. all-or-nothing append with one exact header; +11. CLI/Make wording and filesystem read-only behavior for status/preview; +12. no readiness, canonical, dashboard, or legacy-proof mutation. + +After focused tests, the complete repository test suite and all dashboard, render, public, commercial, pilot, PR-range, and hygiene gates must run before delivery. + +## Documentation And Delivery + +After verified implementation: + +- update `ROADMAP.md`, `docs/OPERATOR_GUIDE.md`, the continuation goal prompt, and release-document tests; +- state explicitly that legacy proof remains non-upgraded and the new ledger does not activate readiness; +- keep all existing generated CSV/report churn unstaged; +- stage exact code/test/docs/Make files only; +- commit coherently, push only `codex/personal-research-mode-mvp`, and keep PR #113 draft; +- update PR #113 with exact verification evidence and await exact-head CI. + +## Deferred Work + +Separate designs are required before: + +- mapping structured field proof into reconciliation; +- activating Company Workbench or any readiness lane from proof; +- storing or ingesting an actual source payload; +- collecting real point-in-time consensus; +- claiming commercial source rights, hosted operation, reviewer validation, calibration, or market validation. diff --git a/docs/superpowers/specs/2026-07-21-public-mobile-evidence-handoff-design.md b/docs/superpowers/specs/2026-07-21-public-mobile-evidence-handoff-design.md new file mode 100644 index 00000000..5ee79908 --- /dev/null +++ b/docs/superpowers/specs/2026-07-21-public-mobile-evidence-handoff-design.md @@ -0,0 +1,44 @@ +# Public Mobile Evidence Handoff Design + +## Problem + +Fresh desktop and 390x844 browser evidence for the five-page public workflow shows healthy reflow, no horizontal overflow, no tracebacks, and answer-first content on all ten page/viewport combinations. Home and Stock Selector expose their primary actions in the first phone viewport. Data Health and Proof History correctly use their first screens for lane and evidence review. + +Single-Stock Report is the exception. Its first phone viewport preserves the selected ticker, usable evidence, and withheld evidence, but the existing **Open Data Health** handoff begins below the viewport. Desktop already shows the same handoff correctly. + +## Decision + +Keep the current information and fail-closed reading order: + +1. Selected ticker +2. Use now +3. Still withheld +4. Evidence handoff + +Do not move the action ahead of withheld evidence and do not remove methodology or boundary text. On viewports at or below 640px: + +- reduce only the vertical gap and padding inside `.public-ticker-summary`; +- place `.public-primary-action` first inside the final `.public-ticker-action` block; +- keep the explanatory next-action sentence and stop rule immediately after the link; +- leave desktop layout and all readiness, evidence, source, forecast, and research states unchanged. + +## Alternatives Rejected + +- **Move the full action block ahead of Still withheld:** exposes the link earlier but weakens the required fail-closed sequence. +- **Hide context or stop-rule copy on phone:** saves space by removing evidence-boundary information. +- **Add new calls to action to Data Health and Proof History:** invents workflow pressure on pages whose job is to review lane state and evidence. + +## Acceptance Evidence + +- A source contract proves the phone-only rules exist and desktop grid rules remain unchanged. +- A rendered 390x844 Single-Stock Report check proves: + - selected ticker, Use now, and Still withheld appear before Open Data Health in document order; + - Open Data Health is fully inside the first viewport; + - no horizontal overflow or traceback appears; + - Advanced sections remain collapsed. +- Existing public and Personal Research render tests remain green. +- Full tests, release gates, pilot check, PR-range hygiene, staged hygiene, and exact-head GitHub CI pass. + +## Boundaries + +This is a public-workflow usability correction only. It does not refresh or rebuild readiness, change source rights, activate blocked evidence, modify canonical data, create forecasts or probabilities, add a provider or hosted service, or generate/stage CSV, JSON, report, sample-report, screenshot, timing, readiness, canonical-data, or manual-review churn. diff --git a/docs/superpowers/specs/2026-07-21-public-packaging-reconciliation-design.md b/docs/superpowers/specs/2026-07-21-public-packaging-reconciliation-design.md new file mode 100644 index 00000000..ebb4c0c5 --- /dev/null +++ b/docs/superpowers/specs/2026-07-21-public-packaging-reconciliation-design.md @@ -0,0 +1,106 @@ +# Public Packaging Reconciliation Design + +## Status + +Approved by the user through the July 21 public-package audit and the instruction to implement its recommendations. + +## Goal + +Make the public README, LinkedIn Featured guidance, share-check contract, performance evidence, and Featured thumbnail accurately represent the current answer-first Personal Research product without presenting generated readiness counts, draft-branch work, hosted availability, market validation, or research outputs as stronger evidence than they are. + +## Scope + +This is one bounded public-packaging slice. It changes: + +- the README's first-review hierarchy; +- the LinkedIn project brief and copyable Featured guidance; +- the read-only LinkedIn share checklist; +- the Company Workbench first-useful performance marker; +- the curated LinkedIn Featured thumbnail and its browser-QA contract; +- roadmap and continuation documentation needed to preserve repository truth. + +It does not change research calculations, readiness data, source rights, canonical data, report conclusions, routing behavior, hosting, licensing, or PR draft status. + +## Design Decision + +Use one external-reviewer entry point instead of competing `Personal Research Start Here` and `External Reviewer Start Here` sections. + +The first screen of the README will: + +1. name the project and the readiness-first principle; +2. show a reviewed Company Workbench image; +3. state that this is a local controlled portfolio beta, not a hosted or commercially launched product; +4. present Personal Research as the primary product workflow: Research Desk -> Discover -> Company Workbench -> Monitor; +5. retain the controlled Public five-page path as the secondary reviewer demo; +6. keep the research-only, source, hosting, reuse, and freshness boundaries visible; +7. route detailed operator commands and methodology to their dedicated documents. + +The rest of the README may retain product depth, but the first-review section will not begin with provider setup, proof-queue mechanics, or long implementation inventories. + +## LinkedIn Featured Contract + +The recommended Featured item will describe the project as a local Python and Streamlit portfolio beta for evidence-first company research. It will name the four-step Personal Research workflow and state that usable, withheld, and advanced evidence remain separate. + +The link target remains the stable GitHub repository only after the reviewed feature reaches the public default branch. While PR #113 is draft, the documentation must tell the owner to keep the existing stable item or label any PR/branch link as a draft engineering preview. + +The copy must retain: + +- research-only; +- no broker integration; +- no order routing or auto-trading; +- no investment advice; +- no hosted-product claim; +- no complete-coverage or current-data claim; +- no open-source or reuse claim under the controlled demo license. + +## Visual Evidence + +Replace `docs/assets/linkedin-public-dashboard.png` with a real `1200x627` Company Workbench capture from the current app. + +The image must: + +- show Company Workbench identity; +- show one selected-ticker answer with `Use now`, `Still withheld`, the Data Health handoff, and the stop condition; +- show the research-only boundary; +- avoid numerical universe, price-ready, DCF-ready, peer-ready, or source-date claims; +- avoid tracebacks, loading states, operator commands, raw tables, or generated artwork; +- preserve readable scale and intentional framing rather than stretching or approximating the product. + +The browser-QA contract will identify the asset as a Workbench answer-first screenshot and treat it as product-flow evidence only. + +## Performance Evidence + +The Commercial Research performance gate will use `Use now` as the Company Workbench first-useful marker. `Company Workbench` remains a full-route marker, but the time-to-first-useful claim must be bound to the selected-company answer rather than the page title. + +## Failure Handling + +- If the current app cannot render the selected answer without numerical readiness claims in the target crop, do not replace the existing asset; record the visual blocker. +- If the image is not exactly `1200x627`, do not stage it. +- If README, LinkedIn, public wording, browser-QA, performance, or hygiene checks fail, do not commit or push the slice. +- If GitHub exact-head CI fails, keep PR #113 draft and report the failing gate. +- Generated CSV, JSON, report, sample-report, screenshot, timing, canonical-data, and readiness churn remains excluded unless it is the one explicitly reviewed thumbnail. + +## Test Strategy + +Add or update contracts that prove: + +- the README starts with `External Reviewer Start Here` before any Personal Research subsection; +- the README names the four-step Personal Research workflow and keeps the five-step Public path secondary; +- the LinkedIn brief uses the new title and evidence-first description, contains the draft-link boundary, and rejects stale readiness-number claims in the Featured guidance; +- `make linkedin-share-check` prints the same current title, link boundary, image rule, and research-only stop rules; +- the Workbench performance first-useful marker is `Use now`; +- browser-QA identifies the LinkedIn asset as the Company Workbench answer-first visual with the required markers and dimensions. + +Verification includes focused tests, the full test suite, dashboard and research render smoke, public wording, public check, LinkedIn share check, browser-QA evidence, public and Commercial Research performance gates, commercial beta release check, pilot readiness, PR-range hygiene, diff/staged hygiene, and whitespace checks. + +## Acceptance Criteria + +1. One external-reviewer entry point leads the README. +2. The primary product story is Research Desk -> Discover -> Company Workbench -> Monitor. +3. The Public five-page path remains available as a secondary controlled demo. +4. The LinkedIn Featured title and description match current product maturity and research boundaries. +5. The Featured image is a reviewed real Workbench capture at `1200x627` with no readiness counts. +6. The performance gate measures `Use now` instead of the Workbench page title. +7. All required local checks and exact-head CI pass. +8. Only intentional docs, tests, contracts, and the reviewed thumbnail are staged. +9. PR #113 remains open and draft; no merge or public deployment occurs. diff --git a/docs/superpowers/specs/2026-07-21-public-ux-post-fix-outcome-contract-design.md b/docs/superpowers/specs/2026-07-21-public-ux-post-fix-outcome-contract-design.md new file mode 100644 index 00000000..f7a67e94 --- /dev/null +++ b/docs/superpowers/specs/2026-07-21-public-ux-post-fix-outcome-contract-design.md @@ -0,0 +1,72 @@ +# Public UX Post-Fix Outcome Contract Design + +## Problem + +The completed phone-width Single-Stock Report review is recorded as `resolved_post_fix` so the audit retains the fact that a defect was observed, corrected, and recaptured. The public UX status parser currently treats every classification except literal `resolved` and `pending` as a problem. That makes a fully completed ten-row review report `review_limited` and causes project status to understate public UX maturity. + +This is an evidence-state classification defect. It does not affect research readiness, data freshness, source rights, forecasts, or the rendered product workflow. + +## Decision + +Add `resolved_post_fix` as a second explicit successful public UX review outcome. + +- `resolved` means the reviewed route and viewport passed without a recorded corrective cycle. +- `resolved_post_fix` means an observed issue was corrected and fresh route/viewport evidence passed. +- Both outcomes satisfy the public UX share-review gate. +- Their raw classification counts remain separate so the audit history is not flattened. +- Project-status evidence sums both successful outcomes when reporting the resolved-row numerator. +- `pending` remains incomplete. +- `intentionally_deferred`, `environment_limited`, `skipped`, `blocked_with_evidence`, and every unknown classification remain fail-closed problem outcomes. + +The implementation must use an explicit successful-outcome set. It must not use prefix matching such as `classification.startswith("resolved")`, because that would silently accept misspelled or invented classifications. + +## Components + +### Public UX review checklist + +`src/public_ux_review_checklist.py` owns the outcome vocabulary and the review-note status decision. It will expose the two successful outcomes through one explicit constant, include `resolved_post_fix` in the rendered review instructions, and use the constant when separating successful rows from problem rows. + +The status payload continues to preserve raw `classification_counts`. A ten-row review with nine `resolved` rows and one `resolved_post_fix` row must return: + +- `status=review_complete` +- `share_review_gate=share_review_ready` +- `pending_rows=0` +- `problem_rows=[]` +- raw counts of `resolved: 9` and `resolved_post_fix: 1` + +### Project status + +`src/project_status.py` will calculate the resolved-row numerator as the sum of the two explicit successful outcomes. The resulting evidence must say `10/10 public desktop/mobile review rows resolved` for the verified audit while retaining the raw distinction in the underlying review-note status. + +### Documentation + +`ROADMAP.md`, `docs/DASHBOARD_QA.md`, and the commercial-beta continuation contract will state that the verified ten-row desktop/phone audit is `share_review_ready`, with one row resolved after a corrective recapture. This is local screenshot-based product QA only; it does not establish hosted behavior, accessibility conformance, external reviewer validation, data freshness, or market validation. + +## Test Strategy + +Use a strict red-green cycle. + +1. Add a public UX review-note regression test with nine `resolved` rows and one `resolved_post_fix` row. Verify it fails because the current parser returns `review_limited`. +2. Add a project-status regression assertion that successful outcomes are summed to `10/10`. +3. Implement the smallest explicit-outcome-set change. +4. Run focused tests for the checklist and project-status modules. +5. Run the full repository, dashboard, render, public, commercial-beta, release, pilot, and hygiene gates. + +Unknown classifications must remain covered by the existing fail-closed problem-row behavior. + +## Boundaries + +- Do not edit or stage the `/tmp` review notes or screenshots. +- Do not run `make readiness` or generate readiness, CSV, JSON, report, sample-report, screenshot, or timing artifacts. +- Do not change research readiness, source evidence, forecasts, valuation, consensus, catalysts, outcomes, backtesting, or calibration. +- Keep PR #113 open and draft. +- Stage only the exact product, test, and documentation files required by this slice. + +## Acceptance Criteria + +1. The existing verified notes file reports `review_complete` and `share_review_ready` without being edited. +2. Raw counts remain `resolved: 9` and `resolved_post_fix: 1`. +3. Project status reports `10/10` resolved public UX rows. +4. Deferred, limited, blocked, skipped, unknown, and pending states continue to fail closed. +5. Focused and full verification pass. +6. No generated artifact enters the working tree, commit range, or staging area. diff --git a/docs/superpowers/specs/2026-07-22-field-proof-stage-b-audit-design.md b/docs/superpowers/specs/2026-07-22-field-proof-stage-b-audit-design.md new file mode 100644 index 00000000..b95744a2 --- /dev/null +++ b/docs/superpowers/specs/2026-07-22-field-proof-stage-b-audit-design.md @@ -0,0 +1,54 @@ +# Stage B Prospective Field-Proof Audit Design + +**Date:** 2026-07-22 +**Status:** Approved by the Next-Stage Maturity Program +**Scope:** Read-only operator audit and explanation only + +## Problem + +Stage A validates an append-only per-field proof ledger, previews proposed rows, binds a receipt, and permits an explicitly confirmed append. Its safety contract is strong, but its operator answer is too compressed: + +- status reports only absent/valid/invalid and a record count; +- preview text reports batch counts and blocker lists but hides per-row state and reason unless JSON is requested; +- no read-only command explains the current revision heads, superseded history, reviewer dispositions, or current commercial blocker categories; +- preview receipts are deliberately not persisted, but the operator output does not explain that a receipt must be retained externally and revalidated at record time. + +## Selected Approach + +Add one read-only `audit` command and enrich preview text. Do not add a dashboard route, persistence, receipt store, readiness mapping, generated report, or automatic record operation. + +Other approaches were rejected: + +- Persisting preview receipts would create a new write path and stale-receipt lifecycle. +- Mapping accepted proof into readiness or Company Workbench would exceed the approved Stage B boundary. +- A dashboard authoring flow belongs to Priority 3 and must not be smuggled into this operator slice. + +## Audit Contract + +`make prospective-field-proof-audit` returns: + +- ledger integrity and absent/valid/invalid state; +- total records, normalized scopes, active revision heads, and superseded records; +- accepted, rejected, and needs-follow-up counts; +- latest reviewed timestamp; +- one append-order history row per record with scope, revision number, current/superseded state, reviewer disposition, source/payload/rights state, proof ID, and parent ID; +- current blocker categories derived only from active heads and the current source-rights registry; +- `preview_receipt_persisted=false` and `receipt_revalidation_required=true`. + +An absent ledger is a valid empty audit. A present empty, malformed, forked, cyclic, or otherwise invalid ledger fails closed with a controlled explanation and nonzero exit. + +## Preview Explanation + +Human-readable preview output adds one line per proposed row with state, technical eligibility, commercial eligibility, reason, and blocker categories. It also states which inputs the receipt binds and that the receipt is not saved by the tool. + +## Boundaries + +- Audit and preview write no ledger, input, readiness, canonical data, legacy proof, report, JSON file, screenshot, timing artifact, or dashboard state. +- Audit status cannot prove payload truth, reviewer independence, source rights, commercial permission, freshness, or readiness. +- A current active accepted record remains evidence history only. It cannot feed proof-readiness reconciliation, Company Workbench, Research Decision Lab, forecasts, calibration, recommendations, sizing, ranking, or transaction behavior. +- JSON is stdout only. No artifact is created. +- Recording remains available only through the existing explicit preview-receipt and confirmation command. + +## Acceptance Evidence + +Tests must prove absent, valid multi-revision, blocked active-head, and invalid-ledger audits; stable append order; normalized scope counts; current-head blocker aggregation; per-row preview explanations; controlled CLI errors; and byte-for-byte no-write behavior across scoped sentinels. Full release and hygiene checks must show zero generated files staged. diff --git a/docs/superpowers/specs/2026-07-22-in-app-research-record-authoring-design.md b/docs/superpowers/specs/2026-07-22-in-app-research-record-authoring-design.md new file mode 100644 index 00000000..a24fc894 --- /dev/null +++ b/docs/superpowers/specs/2026-07-22-in-app-research-record-authoring-design.md @@ -0,0 +1,227 @@ +# In-App Research-Record Authoring Design + +## Status + +The user approved the unified Company Workbench composer design and this written specification on 2026-07-22. Priority 3 — completed locally after direct desktop/phone runtime review and the required automated acceptance matrix. The test-first implementation plan is recorded in `docs/superpowers/plans/2026-07-22-in-app-research-record-authoring.md`. + +## Purpose + +Company Workbench can read the selected profile's thesis journal, catalyst timeline, and research-outcome ledger, but recording those reviewed entries still requires command-line preparation. Priority 3 adds a command-line-free path for a researcher to create and revisit thesis, evidence, catalyst, and outcome records without changing any analytical result or treating a draft as evidence. + +## Release Contract + +Thesis, evidence, catalyst, and outcome records are all available in the collapsed Company Workbench composer. +A valid record requires an exact preview and explicit confirmation before save. +Drafts are untrusted and preview receipts are session-only. +Production tests never append repository ledgers; persistence tests use temporary ledgers. +A saved record cannot change readiness, forecasts, probabilities, recommendations, or any other ledger. + +Priority 3 is complete locally only after all automated acceptance tests and direct desktop/phone review pass; Priority 4 is next and incomplete. +Priority 4 exit requires one bounded permitted point-in-time dataset with rights, identity, corporate-action, delisting, survivorship, cutoff, reproduction, and leakage gates all passing. + +## Implementation Evidence + +- Desktop `1280x720`: `clientWidth=scrollWidth=1280`. The composer was collapsed by default at `3274.97px` with width `1034.81px`, followed by collapsed Advanced thesis history at `3334.80px`; Research Conclusion (`3453.45px`) and Next Research Task (`3690.29px`) remained below the journal/outcome workflow. The locked scope was visible as `Profile: default | Ticker: NVDA — locked to this Company Workbench.` and the menu contained exactly Thesis, Evidence, Catalyst, and Outcome. +- Phone `390x844`: `clientWidth=scrollWidth=390`. The collapsed composer was at `5814.70px` between `22.59px` and `367.41px`; Advanced thesis history (`5874.52px`), Research Conclusion (`5993.18px`), and Next Research Task (`6520.84px`) remained below it. With the composer open, `scrollWidth` remained `390`; all 11 controls fit between `39.59px` and `350.41px`. +- Production no-write rehearsal: journal SHA-256 remained `0c427c359c1bdb6d8c8410aece9b3a46831ffb9ccd91d401aeb7930b5f401717`; catalyst and outcome paths remained absent. Invalid `effective_at=not-a-timestamp` showed the field-level ISO-8601 error and no confirmation. A valid session-only preview showed the append-only preview and confirmation; editing the summary removed confirmation and required re-preview. No successful production save was attempted; persistence evidence remains temporary-ledger AppTest and direct persistence tests only. +- Applicable Tasks 1-4 implementation chain: `6b7cdbd3b`, `996d86610`, `10c2c155c`, and `e67b16d04`. This design intentionally does not self-reference the Task 5 commit. +- Hardening commit `07758114c` closes the confirmation race: all three append engines share one resolved-ledger cooperative lock, receipts bind resolved ledger identity, every new preview resets confirmation, and uncertain post-append teardown requires one-shot read-side reload before success. +- Final integrity commit `e3a090dba` ensures confirmation appends only the receipt-matched recomputed record and enforces one readable active thesis lineage: revisions must supersede the exact active entry and preserve its thesis ID. The Company Workbench locks and explains that relationship, with temporary-ledger create -> revise -> reload coverage. +- Confirmation-integrity commit `5a6c55921` binds every displayed preview field, preview time, and destination label to the exact receipt. If an append raises after it may have written, confirmation returns one-shot `save_pending_reload` with the exact record ID unless the locked ledger is provably unchanged; it never invites a blind duplicate retry. + +## Scope + +Add one Company Workbench composer with an explicit `Validate -> Preview -> Confirm and save` flow for four user-facing record types: + +1. Thesis, persisted through the existing research thesis journal. +2. Evidence, persisted through the existing research thesis journal with `entry_type=evidence`. +3. Catalyst, persisted through the existing catalyst evidence ledger. +4. Outcome, persisted through the existing research outcome review ledger. + +The slice does not add a generic ledger, rich-text editor, attachment upload, automated research text, source discovery, source-rights approval, readiness promotion, forecast input, probability, score, ranking, recommendation, portfolio action, or transaction behavior. + +## Chosen Architecture + +Create a focused composition module, `src/research_record_authoring.py`, between the Streamlit page and the three existing persistence engines. The module owns authoring state, context binding, preview receipts, cross-ledger reference checks, and safe dispatch. It does not duplicate the validators or CSV append implementations in: + +- `src/research_thesis_journal.py`; +- `src/catalyst_evidence_timeline.py`; or +- `src/research_outcome_review.py`. + +The composition module exposes immutable draft, preview, and save-result contracts. It converts a user draft into the existing record dataclass, invokes the existing validator or preview function, and dispatches a confirmed record to exactly one existing append function. + +The dashboard owns only widget collection and rendering. It must not open or write a ledger directly. + +## Alternatives Rejected + +### Separate Forms In Each Report Section + +This would reduce the first implementation's orchestration code, but it would scatter confirmation rules across the thesis, Forward View, and outcome sections. Researchers would need to learn multiple save patterns, and stale-preview protection would be easy to implement inconsistently. + +### One New Universal Research Ledger + +A universal schema would appear simpler at the UI boundary, but it would duplicate validated domain contracts, require migration or dual reads, and create a new path that could disagree with the existing thesis, catalyst, and outcome engines. + +### Direct Dashboard Calls To Existing Append Functions + +This would reuse persistence but leave context binding, receipt validation, reference checks, and error normalization embedded in a large dashboard module. A small composition boundary is easier to test and prevents UI reruns from becoming persistence logic. + +## User Workflow + +The composer appears after the selected-ticker journal answer and research-learning card, before Advanced history. It is collapsed by default under the label `Add a reviewed research record`. Authoring is a research action, not technical evidence, but it stays below the primary company answer so it cannot displace usable and withheld findings. + +1. The page displays the selected profile and ticker as locked scope. +2. The researcher chooses Thesis, Evidence, Catalyst, or Outcome. +3. The composer shows only fields used by that record's existing contract. +4. The researcher enters the claim, reviewer identity, effective or observation dates, and source provenance where required. +5. `Validate and preview` performs no write and returns either deterministic field errors or an exact preview. +6. A valid preview displays every persisted field, the destination ledger label, the append-only correction rule, and the research-only boundary. +7. The researcher checks `I reviewed this exact record and its source evidence` and selects `Confirm and save`. +8. Confirmation revalidates the preview receipt and current ledger before appending exactly one row. +9. The page reloads the selected-ticker state and shows the saved record identifier plus the correction path: append a revision or new record; never edit or delete history. + +On phone, fields use one column, buttons use the available width, preview content wraps, and no raw dataframe is required to understand whether a record was saved or rejected. + +## Record Contracts + +### Shared Context + +- `profile_key` and `ticker` come only from the active Company Workbench context and are not editable text fields. +- The reviewer must enter a non-empty identity. The UI does not infer identity from the local machine, Git configuration, or a future hosted account. +- A technical record identifier may be generated by the application. It carries no research meaning and cannot supply evidence content. +- The actual UTC preview timestamp is captured when validation creates the preview and is shown in that preview. Journal `recorded_at` uses that exact timestamp. The catalyst ledger has no separate recording field, so `retrieved_at` remains an explicit researcher input; outcome `reviewed_at` also remains an explicit researcher input. The UI never invents when a source was retrieved or an outcome was reviewed. +- User-entered effective, publication, retrieval, review, and observation timestamps must satisfy the existing temporal validators. Confirmation persists the exact previewed values and does not silently replace them. + +### Thesis + +- Uses `JournalEntry` with `entry_type=thesis`. +- Requires thesis identifier, summary, effective time, reviewer, and review date fields accepted by the existing contract. +- A revision must explicitly select the current thesis entry it supersedes. The UI cannot silently overwrite or infer a prior entry. + +### Evidence + +- Uses `JournalEntry` with `entry_type=evidence`. +- Requires an existing thesis identifier in the same profile/ticker scope, evidence direction, summary, source, source reference, source publication time, effective time, and reviewer. +- Supporting, conflicting, and contextual directions remain documentation categories only. They cannot change a forecast, readiness state, or conclusion automatically. + +### Catalyst + +- Uses `CatalystEvent` and the existing event-type and evidence-state vocabularies. +- Requires title, summary, effective, publication, and retrieval timestamps, exact source and reference, evidence state, and reviewer. +- `candidate_context_only` remains untrusted context. `supported` remains subject to independent exact-source rights and commercial field-scope checks when composed for Commercial Research. + +### Outcome + +- Uses `ResearchOutcome` and the existing outcome-state vocabulary. +- Requires an existing thesis entry in the same profile/ticker scope, observation window, reviewed time, summary, learning, source provenance, and reviewer. +- The composer rejects a missing or cross-profile/cross-ticker thesis reference even if the lower-level row schema is otherwise valid. +- No return, price-performance, skill grade, expected-return score, or company-ranking field is introduced. + +## Preview Receipt And Concurrency Contract + +A successful preview produces a session-only receipt containing deterministic hashes of: + +- the normalized record kind and every prospective persisted field; +- the selected `profile_key` and normalized ticker; +- the resolved destination ledger identifier; and +- the destination ledger's current bytes, including the empty-ledger state. + +The receipt and prospective record remain in `st.session_state` only. They are not written to CSV, JSON, a report, canonical data, readiness data, screenshots, or timing output. + +Before confirmation, the composition layer must: + +1. require the explicit confirmation boolean; +2. reconstruct and revalidate the exact prospective record; +3. confirm the active profile, ticker, and record kind still match; +4. confirm the draft digest still matches the preview; +5. confirm the destination ledger fingerprint is unchanged; and +6. rerun all existing duplicate, temporal, provenance, and append-only validation. + +Any mismatch returns `preview_stale` and performs no write. The researcher must preview again. A successful save consumes the receipt so a rerun or double click cannot append the same record twice. + +## Empty-Ledger And Persistence Behavior + +- Loading a missing ledger returns the existing empty state and creates no file. +- Validation and preview create no directories or files. +- Confirmation may create only the one existing destination ledger when it is absent, using its established header and append function. +- The implementation and automated tests never append to repository production ledgers. Persistence tests use temporary paths. +- A failed or stale confirmation leaves all scoped files byte-identical. +- A successful confirmation appends one row and preserves every previous byte before the append boundary. +- Saved entries are never edited, deleted, compacted, reordered, or automatically superseded. + +## Error And Recovery Behavior + +- Field errors identify the exact rejected field without exposing a stack trace. +- An invalid existing ledger fails the entire corresponding record type closed; the composer does not skip malformed history or create a replacement ledger. +- A missing thesis reference blocks evidence and outcome confirmation. +- A duplicate technical identifier or semantic duplicate blocks confirmation. +- A changed selected company, profile, record type, draft, or ledger invalidates the preview. +- A filesystem failure reports that no save was confirmed and leaves the researcher on the preview/retry path. +- After a successful save, the UI displays the persisted identifier and reloads read-side state from disk rather than claiming success from session state alone. + +## Independence And Safety Invariants + +- Authoring is research-only and provides no investment advice. +- No direct buy/sell instruction, allocation, position sizing, account import, broker integration, order routing, auto-trading, or post-earnings price prediction is added. +- Saving one record type cannot append or promote another record type. +- No draft, preview, or saved record changes deterministic forecasts, DCF assumptions, scenarios, consensus, historical valuation, readiness, source rights, peer trust, backtesting, calibration, or numerical probability. +- Candidate context cannot become trusted evidence through authoring. +- Empty valuation, catalyst, outcome, consensus, and other ledgers remain visibly empty until an explicitly confirmed record is appended to that exact ledger. +- EPS split basis remains unverified without explicit primary proof. +- Q4 actuals still require an explicit SEC-filed Q4 table. +- Synthetic fixtures remain test-only. + +## Test-First Implementation Slices + +### Slice A — Pure Authoring Composition + +Add failing unit tests for normalized drafts, locked profile/ticker scope, exact field mapping, cross-ledger thesis references, read-only previews, receipt contents, stale receipt rejection, and one-ledger-only dispatch. Implement only the pure composition and temporary-path persistence behavior needed to pass them. + +### Slice B — Thesis And Evidence UI + +Add failing dashboard-helper and render tests for the collapsed composer, locked scope, record-type-specific fields, preview errors, exact confirmation language, saved receipt, and correction path. Wire thesis and evidence through the composition module. + +### Slice C — Catalyst And Outcome UI + +Add failing tests for catalyst source/evidence-state fields, outcome observation/reference fields, candidate-context wording, cross-scope rejection, and no performance scoring. Wire catalyst and outcome through the same composer. + +### Slice D — Workflow And Release Evidence + +Add render and browser contracts covering fresh desktop and phone Company Workbench authoring states without writing production ledgers. Verify that the primary answer remains above the composer, Advanced evidence remains collapsed, and the complete Research Desk -> Discover -> Company Workbench -> Monitor path is unchanged. + +Each slice uses a strict red-green-refactor cycle and may be committed only after its focused and required full verification passes. + +## Verification + +For each implementation slice, run focused tests for every changed module, then: + +- `PYTHONDONTWRITEBYTECODE=1 python3 -m pytest tests -q` +- `make dashboard-smoke` +- `make research-dashboard-render-smoke` +- `make public-wording-check` +- `make public-check` +- `make linkedin-share-check` +- `make browser-qa-evidence` +- `make pilot-readiness-check TOP_N=10` +- `make commercial-beta-release-check` +- `make diff-hygiene-summary` +- `git diff --check` +- `make staged-hygiene-check` after exact staging +- `git diff --cached --check` + +Fresh desktop and phone evidence must cover validation rejection, valid preview, stale preview, confirmed save against temporary ledgers, persisted reload, and the absence of horizontal overflow or hidden confirmation state. Screenshots or generated browser evidence stay ephemeral and are not staged. + +## Acceptance Criteria + +Priority 3 is complete only when direct current evidence proves all of the following: + +1. A researcher can create and revisit all four record types in Company Workbench without command-line use. +2. Every record follows the exact `Validate -> Preview -> Confirm and save` state sequence. +3. Preview performs no write, explicit confirmation is required, and stale receipts fail closed. +4. Profile/ticker scope, reviewer identity, timestamps, provenance, duplicates, and thesis references are validated. +5. Exactly one established append-only ledger receives exactly one row after a successful confirmation. +6. Missing or invalid ledgers, invalid drafts, and failed confirmations create no content. +7. Saved results and append-only correction instructions are visible on desktop and phone. +8. No generated research content, fabricated evidence, recommendation, rank, score, return attribution, or transaction language is introduced. +9. Forecasts, probabilities, recommendations, readiness, source-rights decisions, and all independent evidence lanes remain byte-identical or behaviorally unchanged. +10. Focused tests, the full repository suite, every required release/hygiene gate, exact-file staging, pushed exact-head CI, and draft PR #113 all pass. diff --git a/docs/superpowers/specs/2026-07-22-legacy-research-utility-quarantine-design.md b/docs/superpowers/specs/2026-07-22-legacy-research-utility-quarantine-design.md new file mode 100644 index 00000000..2c31b5bf --- /dev/null +++ b/docs/superpowers/specs/2026-07-22-legacy-research-utility-quarantine-design.md @@ -0,0 +1,104 @@ +# Legacy Research Utility Quarantine Design + +**Date:** 2026-07-22 +**Status:** Approved by the Next-Stage Maturity Program +**Scope:** Legacy compatibility routes and product wording only + +## Purpose + +The supported product is Personal Research Mode: + +```text +Research Desk -> Discover -> Company Workbench -> Monitor +``` + +The repository also retains older calculation and report surfaces for monthly picks, momentum leaders, portfolio review, value/re-rating, and a final watchlist. They are useful for regression and compatibility work, but their ranked-company, holdings, cost-basis, disposition, and action-like language is outside the supported research workflow. This design prevents those surfaces from being mistaken for current investing capability without deleting compatibility code or rewriting historical outputs. + +## Audit Findings + +1. The supported Personal Research and Public route rails do not list the legacy pages. +2. Explicit query routes can still preserve an advanced page in Personal Research or Public mode because the explicit page wins after the route rail chooses its safe default. +3. Operator navigation displays legacy page names beside current maintenance and evidence tools without a compatibility label. +4. Monthly Picks renders ordered candidates and track-record context in the primary view. Output tabs can render ranked, portfolio, and watchlist tables without an explicit legacy boundary. +5. `portfolio_review.py`, `monthly_picks.py`, and historical output filenames remain part of deterministic pipeline compatibility. Removing them would create broad migration risk unrelated to the current product workflow. +6. The Research Decision Lab already forbids importing `portfolio_review.py`; its pure composition module does not consume dashboard output frames. Company Workbench loads selected-company evidence independently, but a direct contract test is needed so that isolation cannot regress silently. +7. README and readiness documentation still describe the old outputs as general product capability instead of compatibility-only artifacts. + +## Approaches Considered + +### Delete legacy calculations and reports + +This would remove ambiguity, but it would also break historical report contracts, tests, and operator regression workflows. It is disproportionate to the product-surface problem and is not selected. + +### Relabel pages only + +This is low effort, but it does not close direct-route leakage and still exposes ranked or position-oriented content on first view. It is insufficient. + +### Operator-only quarantine with collapsed compatibility output + +This is selected. It preserves deterministic compatibility code while preventing legacy pages from becoming Personal Research or Public product capabilities. The route, navigation label, page boundary, collapsed output, documentation, and isolation tests all reinforce the same contract. + +## Quarantined Pages + +The immutable quarantine set is: + +- `Monthly Picks` +- `Momentum Leaders` +- `Portfolio Review` +- `Value / Re-rating` +- `Final Watchlist` + +`Overview`, `Market Direction`, `Single-Stock Report`, `Data Health`, and `Universe Manager` are not included. They remain operator or supported evidence/research surfaces and do not inherently represent portfolio actions or company ranking. Any transaction-like content found inside them must still be removed or separately quarantined. + +## Route Contract + +- Personal Research mode may open only Research Desk, Discover, Company Workbench, Monitor, Data Health, or Proof History. +- Public mode may open only Home, Stock Selector, Single-Stock Report, Data Health, or Proof History. +- A quarantined explicit deep link resolves to `Research Desk` in Personal Research mode and `Home` in Public mode. +- Operator mode may preserve and open quarantined routes. +- Query parsing keeps historical aliases so old operator links remain usable; access control occurs when resolving the workspace page, not by deleting aliases. + +## Operator Navigation And Page Contract + +The Operator advanced selector prefixes quarantined pages with `Legacy utility ·`. Canonical page titles remain unchanged internally. + +Every quarantined page begins with the exact boundary: + +`Legacy research utility — not part of Personal Research Mode` + +The boundary explains that the retained view is compatibility-only and cannot provide recommendations, ranking for action, position sizing, transaction direction, readiness, or Decision Lab evidence. Detailed candidate cards, charts, track records, position/cost-basis fields, and tables require expanding `Advanced: legacy compatibility output` and then selecting a separate load checkbox. Rendering occurs only after both deliberate Operator actions, which avoids unsupported nested Streamlit expanders while keeping the default page free of legacy output. + +The primary view contains only the legacy boundary and fail-closed availability state. It must not expose a ranked candidate, disposition, position percentage, cost basis, entry/exit zone, or transaction-like action. + +## Data And Evidence Boundaries + +- No calculation, CSV schema, report filename, readiness state, source record, or canonical data changes in this slice. +- No generated CSV, JSON, report, screenshot, or timing artifact is created or staged. +- Legacy output cannot populate Research Decision Lab lanes, Research Conclusion, Next Research Task, readiness, calibration, or a recommendation. +- Candidate context remains untrusted and cannot alter deterministic forecasts. +- Empty or missing compatibility output stays explicit; it is never fabricated. +- The quarantine does not make retained legacy calculations commercially validated or market-ready. + +## Documentation Contract + +README, Product Spec, Readiness Model, ROADMAP, and the continuation contract must distinguish: + +- the supported Personal Research workflow; +- operator-only legacy compatibility utilities; and +- retained historical output filenames. + +Public claims must not describe portfolio review, picks, final watchlists, ranked companies, sizing, or action states as current product features. + +## Test And Acceptance Evidence + +The slice exits only when direct tests prove: + +1. the exact quarantine set and operator labels; +2. Personal Research and Public deep links fail closed to their safe home pages; +3. Operator deep links remain available; +4. quarantined output requires one collapsed legacy disclosure plus explicit load confirmation; +5. Research Decision Lab and Company Workbench do not import or consume legacy portfolio, monthly-pick, or final-watchlist contracts; +6. public wording and no-trading checks remain green; +7. full tests, dashboard render checks, release checks, and hygiene checks pass without staging generated churn. + +This evidence proves local product isolation only. It does not prove hosted controls, independent-user comprehension, accessibility, source rights, consensus coverage, calibration, demand, or product-market fit. diff --git a/docs/superpowers/specs/2026-07-22-research-decision-lab-design.md b/docs/superpowers/specs/2026-07-22-research-decision-lab-design.md new file mode 100644 index 00000000..868ece96 --- /dev/null +++ b/docs/superpowers/specs/2026-07-22-research-decision-lab-design.md @@ -0,0 +1,241 @@ +# Research Decision Lab Design + +**Date:** 2026-07-22 +**Status:** Implemented locally; external maturity gates remain separate +**Scope:** Personal Research Mode only + +## Purpose + +The Research Decision Lab turns the Command Center's existing research-process capabilities into one visible, repeatable loop: + +```text +Research plan -> evidence -> invalidation -> scenario -> review trigger -> learning +``` + +It helps a researcher document and review how a conclusion was formed without becoming a trading system. It produces no recommendation, transaction direction, position size, allocation, stop-loss price, take-profit price, expected return, company score, or broker action. + +The first version is a composition layer over existing verified capabilities. It does not introduce a new persistent ledger, top-level route, data provider, generated report, or readiness state. + +## User Outcome + +For one selected company, a researcher can answer: + +1. Is a current reviewer-authored thesis documented? +2. Is supporting or conflicting evidence recorded and reviewed? +3. Is a source-backed invalidation condition documented? +4. Is scenario review available, blocked, or not applicable? +5. What evidence event or review date requires attention? +6. Has a prior research outcome and learning been reviewed? +7. What is the next research-process step? + +For the focused cohort, Monitor can show which research-process items require review without ranking companies or implying an investment action. + +## Product Placement + +### Company Workbench + +The existing answer-first handoff remains unchanged: + +```text +Selected ticker -> Use now -> Still withheld -> Data Health handoff +``` + +The Decision Lab appears after `What Changed` and before the detailed company-research sections. It is a compact process summary, not a replacement for the Workbench answer, Research Conclusion, or authoritative Next Research Task. + +The summary contains six independently derived lanes: + +| Lane | Primary answer | Truth boundary | +| --- | --- | --- | +| Plan | Current thesis documented, not started, or unavailable | Generated thesis text cannot populate reviewer history. | +| Evidence | Current, conflict review needed, not started, or unavailable | Candidate context and unreviewed text cannot become evidence. | +| Invalidation | Documented, missing, or unavailable | No invalidation condition is inferred from risks or price movement. | +| Scenario | Reviewable, blocked, excluded, or unavailable | Scenario math appears only when the existing DCF gate passes. | +| Review trigger | Evidence change due, overdue, scheduled, unscheduled, or unavailable | A review trigger is a research task, never a transaction trigger. | +| Learning | Reviewed, not started, commercial-evidence blocked, or unavailable | Outcome review never becomes return attribution or skill scoring. | + +One `Next process step` is derived from the first explicit process gap in the deterministic order below. It remains separate from the existing authoritative company research task. + +1. Review recorded conflicting evidence that has no later review. +2. Review an overdue thesis. +3. Record a current reviewer-authored thesis. +4. Record a source-backed invalidation condition. +5. Record source-backed research evidence. +6. Schedule the next evidence review. +7. Restore visible DCF assumptions only when DCF is already ready. +8. Continue monitoring when no process gap is present. + +This order represents documentation workflow, not company attractiveness, expected return, or portfolio priority. + +### Monitor + +Monitor keeps its existing Weekly Research Summary and source-backed change answer first. A new `Research Discipline Review` appears immediately afterward and before detailed change evidence. + +The review uses the focused cohort only and groups items by process state: + +- conflicting evidence awaiting later review; +- overdue thesis reviews; +- missing current thesis; +- missing invalidation conditions; +- missing evidence; +- unscheduled reviews; +- reviewed or not-started outcome learning. + +Empty state copy must say that no process item is currently due from saved reviewer-authored evidence. It must not say that no risk exists, no research is needed, or no market event occurred. + +The cohort display must not calculate a score, rank, winner, expected return, allocation, position size, or action. Stable display order is the existing focused-cohort order followed by ticker, not process severity or market value. + +## Architecture + +Create one focused derivation module: + +```text +src/research_decision_lab.py +``` + +The module consumes existing immutable results: + +- selected-profile stock report payload; +- `JournalState` from `src/research_thesis_journal.py`; +- `DecisionProcessScorecard` from `src/decision_process_scorecard.py`; +- `OutcomeStatus` from `src/research_outcome_review.py`; +- selected-profile research change items; +- existing scenario and valuation readiness fields. + +It produces immutable display contracts: + +```python +@dataclass(frozen=True) +class DecisionLabLane: + key: str + label: str + state: str + answer: str + evidence: str + next_step: str + + +@dataclass(frozen=True) +class ResearchDecisionLabState: + profile_key: str + ticker: str + status: str + lanes: tuple[DecisionLabLane, ...] + next_process_step: str + boundary: str + identity: str +``` + +The module also produces cohort rows from already derived per-ticker states. Dashboard helpers convert these contracts into cards and tables; the derivation module must not import Streamlit. + +No Decision Lab code may import from `src/portfolio_review.py`. Its historical `Keep`, `Risk Reduce`, `Constructive Review`, cost-basis, and position-risk language is outside the Personal Research contract and must not be surfaced or repurposed by this feature. + +## Data Flow + +```text +Saved selected-profile evidence + -> existing report, journal, scorecard, outcome, change, and scenario derivations + -> Research Decision Lab composition + -> Company Workbench summary and Monitor cohort review + -> details remain in existing journal, scorecard, scenario, outcome, and Advanced evidence sections +``` + +The composition cannot write to the thesis journal, outcome ledger, source data, canonical data, readiness, proof history, reports, screenshots, or timing artifacts. It cannot resolve a Change Monitor item or change an existing research conclusion. + +## Fail-Closed Behavior + +- Missing journal: Plan, Evidence, Invalidation, and Review Trigger remain `not_started`; no thesis or evidence is synthesized. +- Invalid journal: the Decision Lab becomes `unavailable` for that ticker and shows the existing verification error without partial journal-derived claims. +- Missing outcome ledger: Learning is `not_started`; no learning is fabricated. +- Commercial outcome blocker: Learning is `commercial_evidence_blocked`; local technical history is not presented as commercially usable evidence. +- DCF blocked: Scenario is `blocked`; no baseline, sensitivity, or valuation number appears. +- DCF excluded: Scenario is `excluded`; the state is method-fit context, not a negative company signal. +- Missing or mismatched profile/ticker inputs: derivation raises `ValueError`; the dashboard renders one compact unavailable state while preserving the rest of Company Workbench. +- Candidate peer, news, catalyst, or generated narrative context cannot populate a lane unless the existing source-backed contract already accepts it. + +Independent lanes must remain independent. A complete thesis cannot unlock Scenario, reviewed Learning cannot clear an evidence conflict, and current readiness cannot create journal history. + +## Interaction And Copy + +- Keep the Workbench first viewport and `USE NOW` performance marker unchanged. +- Do not add a top-level navigation item in version one. +- Use plain labels: `Plan`, `Evidence`, `Invalidation`, `Scenario`, `Review trigger`, and `Learning`. +- Use `Action needed` only for research documentation work. Never use `Buy`, `Sell`, `Hold`, `Add`, `Trim`, `Reduce`, `Entry`, `Exit`, `Stop loss`, `Take profit`, or `Position size`. +- Keep source rows, identities, timestamps, and raw evidence under existing Advanced disclosures. +- Desktop may use a compact multi-column layout. Phone must reflow to one column without horizontal scrolling and retain the next process step before Advanced details. + +## Testing Strategy + +### Derivation tests + +Cover each lane independently and prove that one lane cannot promote another. Required cases include: + +- empty journal and outcome ledger; +- current thesis with evidence, invalidation, and scheduled review; +- conflicting evidence without a later review; +- later review clearing only the conflict-review process gap; +- overdue and unscheduled review states; +- DCF ready with visible assumptions; +- DCF blocked and DCF excluded; +- commercial outcome evidence blocked; +- mismatched profile or ticker; +- deterministic identity and stable lane order; +- absence of trading and allocation language. + +### Dashboard tests + +- Company Workbench renders exactly one Decision Lab summary after `What Changed`. +- The existing answer-first handoff remains earlier in document order. +- The Decision Lab does not replace Research Conclusion or Next Research Task. +- Monitor keeps Weekly Research Summary first and groups process states without ranking. +- Empty states display no fabricated thesis, evidence, risk, scenario, or learning. +- Technical evidence remains collapsed. + +### Runtime and release tests + +- Personal Research render smoke for all four routes. +- Desktop and `390x844` phone browser review for Company Workbench and Monitor. +- No horizontal overflow, traceback, duplicated summary, or raw-table-first view. +- Commercial Research performance thresholds continue to pass with `USE NOW` as Workbench first useful evidence. +- Public wording, research-only language, full tests, PR-range hygiene, diff hygiene, and staged hygiene remain green. + +## Implementation Slices + +### Slice 1 — Read-only Decision Lab composition + +Add the immutable derivation module, independent lane states, deterministic next-process-step selection, render helpers, and focused tests. No dashboard placement or persistence change. + +### Slice 2 — Company Workbench integration + +Render the compact summary after `What Changed`, preserve the existing first viewport and authoritative research task, and verify desktop/phone behavior. + +### Slice 3 — Monitor discipline review + +Compose focused-cohort process rows, render the grouped review after Weekly Research Summary, and verify truthful empty states and stable no-ranking order. + +### Slice 4 — Documentation and release evidence + +Update methodology, provenance, Personal Research documentation, browser-QA markers, ROADMAP, continuation contracts, and the draft PR. Run the complete local release matrix and require exact-head GitHub CI. + +Each slice must be independently testable and committed before the next begins. + +## Later, Separately Approved Work + +An append-only pre-commitment record may be designed only after Decision Lab usage shows that the existing Thesis Journal cannot capture the required research plan. A hypothetical paper-position laboratory would require a separate design, private-data policy, language review, and explicit approval. Neither is part of this design. + +Live holdings, account imports, recommended position sizing, price-triggered stop or profit rules, broker integration, order routing, auto-trading, and real transaction records remain out of scope. + +## Acceptance Criteria + +The design is implemented only when all of the following are directly verified: + +1. Company Workbench displays one compact six-lane process summary without changing its answer-first handoff. +2. Monitor displays a focused-cohort Research Discipline Review after its weekly summary without ranking companies. +3. Empty, blocked, excluded, invalid, and commercially blocked states remain explicit and independent. +4. No new ledger, readiness state, generated data artifact, route, transaction field, or trading instruction is introduced. +5. Existing journal, scorecard, scenario, outcome, change-monitor, and conclusion contracts remain authoritative. +6. Focused and full tests, route renders, browser review, performance, wording, and hygiene checks pass on the implementation revision. +7. Draft PR #113 remains draft; no merge or public deployment occurs without explicit approval. + +## Product-Maturity Boundary + +The Decision Lab can improve workflow coherence, research discipline, repeat-use value, and portfolio-demo differentiation. It does not prove source coverage, predictive accuracy, investment performance, independent reviewer adoption, hosted reliability, commercial demand, competitive superiority, or product-market fit. diff --git a/docs/superpowers/specs/2026-07-23-point-in-time-universe-foundation-design.md b/docs/superpowers/specs/2026-07-23-point-in-time-universe-foundation-design.md new file mode 100644 index 00000000..f1399213 --- /dev/null +++ b/docs/superpowers/specs/2026-07-23-point-in-time-universe-foundation-design.md @@ -0,0 +1,452 @@ +# Point-in-Time Benchmark and Universe Foundation Design + +## Purpose + +Priority 4 establishes a provider-neutral, read-only evidence boundary for +historical benchmark membership and research-universe membership. It must make +survivorship, identifier, corporate-action, delisting, cutoff, source-rights, +revision, and leakage failures explicit before any historical result is called +out-of-sample evidence. + +This design does not select a provider, fetch data, modify the current +universe, rebuild readiness, calculate an investment recommendation, or make +historical data commercially usable. Synthetic fixtures prove software +behavior only. Priority 4 remains incomplete until one bounded permitted real +dataset passes every exit gate and reproduces from its immutable manifest. + +## Approved Approach + +Use an isolated validation layer rather than extending the current +`src/universe_builder.py` merge path. + +The existing universe builder remains a current-state metadata workflow keyed +primarily by ticker. It may preview and merge contemporary membership sources, +but it cannot prove historical identity or membership. Reusing that path would +risk silently substituting today's ticker, constituents, listing state, or +source retrieval for historical evidence. + +The new layer consumes an operator-supplied immutable manifest plus four +explicit evidence contracts. It validates and classifies rows in memory and +returns deterministic diagnostics. It never writes a normalized file, repairs +an input, maps a ticker by inference, applies a row, or changes canonical data. + +## Scope + +The first implementation slice includes: + +1. exact schemas for security identity, membership observations, + corporate-action and listing-status events, and an immutable manifest; +2. deterministic file-integrity and manifest validation; +3. independent technical, temporal, identity, membership, corporate-action, + delisting, source-rights, reproduction, and leakage decisions; +4. in-memory `raw`, `normalized`, `excluded`, and `analysis_eligible` + classifications with stable reason codes; +5. deterministic membership counts and membership digests at a declared + cutoff; +6. read-only status and preview interfaces; +7. synthetic, test-only fixtures and no-write tests. + +The first slice excludes: + +- provider selection, provider calls, keys, accounts, or license changes; +- writing, recording, applying, refreshing, rebuilding, or publishing data; +- migration of `data/universe.csv`, `data/universe_master.csv`, or + `data/universe_active.csv`; +- price adjustment, benchmark return calculation, model training, + backtesting, calibration, or probability; +- Company Workbench, Discover, Monitor, or public UI integration; +- broad coverage, automated source mapping, or current-ticker fallback; +- declaring Priority 4 complete from schemas, fixtures, or green tests. + +## Architecture + +Create one focused module, `src/point_in_time_universe.py`, with no dependency +on the existing universe apply path. The module may reuse +`src.commercial_source_rights` for exact-source commercial eligibility and +registered field-scope review. + +The module has five responsibilities: + +1. load the manifest and referenced files without modifying them; +2. verify manifest identity, schema version, file hashes, row counts, and + cutoff policy; +3. validate each evidence contract and its revision lineage; +4. compose independent readiness decisions without collapsing their states; +5. produce a deterministic read-only status or preview packet. + +Parsing, validation, lineage resolution, cutoff selection, and rendering stay +separate so each unit has one observable contract and can be tested without +provider or repository data. + +## Input Contracts + +All timestamps are timezone-aware RFC 3339 UTC values ending in `Z`. Dates use +real calendar `YYYY-MM-DD` values. Blank strings do not satisfy required +fields. Identifiers are case-sensitive opaque strings except tickers, which +are normalized to uppercase for display only. + +### Security identity observations + +Required columns: + +| Column | Meaning | +| --- | --- | +| `identity_row_id` | Immutable unique row identifier | +| `security_id` | Stable security identifier; never derived from ticker | +| `issuer_id` | Stable issuer identifier; must not equal `security_id` | +| `ticker` | Ticker observed during the validity interval | +| `exchange` | Listing venue observed during the validity interval | +| `security_type` | Explicit security type | +| `currency` | Trading currency | +| `valid_from` | Inclusive identity-validity timestamp | +| `valid_to` | Exclusive validity timestamp or blank for an open interval | +| `source_id` | Exact source-rights registry identifier | +| `source_ref` | Durable source reference | +| `source_published_at` | When the source made the observation available | +| `retrieved_at` | When the evidence was retrieved | +| `supersedes_identity_row_id` | Exact prior row or blank for a root | + +Ticker uniqueness is not required globally because symbols may change or be +reused. A security may have only one active identity interval at an evaluated +instant. Overlapping unresolved intervals, unknown parents, forks, cycles, +cross-security supersession, and reversed validity are excluded. +Adjacent chronological intervals for one stable `security_id` must retain the +same `issuer_id`; no corporate-action event authorizes stable-ID reuse across +issuers. A same-effective superseding correction may replace an erroneous +issuer observation before the effective history is evaluated. + +### Membership observations + +Required columns: + +| Column | Meaning | +| --- | --- | +| `membership_row_id` | Immutable unique row identifier | +| `universe_id` | Stable benchmark or research-universe identifier | +| `universe_kind` | Exactly `benchmark` or `research_universe` | +| `security_id` | Stable security identifier | +| `membership_state` | Exactly `included` or `excluded` | +| `effective_from` | Inclusive membership timestamp | +| `effective_to` | Exclusive membership timestamp or blank | +| `observation_at` | Point in time represented by the observation | +| `source_id` | Exact source-rights registry identifier | +| `source_ref` | Durable source reference | +| `source_published_at` | Source publication timestamp | +| `retrieved_at` | Evidence retrieval timestamp | +| `supersedes_membership_row_id` | Exact prior row or blank for a root | + +A membership row is not usable unless its security identity is valid at the +evaluation timestamp. Missing exclusions are not inferred from missing +inclusions. An input must declare its coverage semantics in the manifest as +either `complete_snapshot` or `event_history`; the validator never guesses +which representation was supplied. + +### Corporate-action and listing-status events + +Required columns: + +| Column | Meaning | +| --- | --- | +| `event_row_id` | Immutable unique row identifier | +| `security_id` | Affected stable security identifier | +| `event_type` | One declared event type | +| `effective_at` | Event-effective timestamp | +| `successor_security_id` | Explicit successor security or blank | +| `ratio_numerator` | Explicit action ratio numerator or blank | +| `ratio_denominator` | Explicit action ratio denominator or blank | +| `listing_state_after` | `active`, `delisted`, `suspended`, or blank | +| `source_id` | Exact source-rights registry identifier | +| `source_ref` | Durable source reference | +| `source_published_at` | Source publication timestamp | +| `retrieved_at` | Evidence retrieval timestamp | +| `supersedes_event_row_id` | Exact prior row or blank for a root | + +Allowed event types are `listing`, `ticker_change`, `exchange_change`, +`split`, `reverse_split`, `merger`, `acquisition`, `spinoff`, `delisting`, +`suspension`, and `reactivation`. + +Split and reverse-split events require positive finite numerator and +denominator values. Merger, acquisition, and spinoff events require an +explicit successor when the original security ceases to represent the same +economic security. Delisting requires `listing_state_after=delisted`. +Successor relationships are never inferred from ticker similarity. + +### Evaluation observations + +Required columns: + +| Column | Meaning | +| --- | --- | +| `evaluation_row_id` | Immutable unique evaluation identifier | +| `universe_id` | Evaluated benchmark or research universe | +| `evaluation_at` | Historical evaluation timestamp | +| `available_at` | Earliest timestamp the evaluation was available | +| `partition` | `train`, `validation`, `test`, or `walk_forward` | +| `source_ref` | Durable evaluation reference | + +This file contains evaluation cutoffs, not forecasts, returns, labels, or +probabilities. An evaluation row is excluded when `available_at` is after +`evaluation_at`, its universe is undeclared, or its partition violates the +manifest boundary policy. + +## Immutable Manifest + +The JSON manifest contains: + +- `schema_version`, initially exactly `point_in_time_universe_v1`; +- `dataset_id` and `manifest_id`; +- `manifest_created_at`; +- `observation_cutoff_at`; +- `coverage_semantics`, exactly `complete_snapshot` or `event_history`; +- `declared_universes`, including one or more IDs and their kinds; +- `allowed_source_ids`; +- `source_rights_registry_sha256`; +- `files`, with relative path, contract name, SHA-256, and row count for each + of the four CSV inputs; +- `evaluation_policy`, exactly one of: + - chronological non-overlapping `train`, `validation`, and `test` + boundaries; or + - chronological `walk_forward` with a positive minimum history count; +- `corporate_action_policy`, which must declare every allowed event type as + `required`, `not_applicable`, or `unsupported`; +- `delisting_policy`, including whether delisted securities remain in + historical membership and how missing delisting evidence is treated; +- `survivorship_policy`, which must prohibit filtering historical membership + by current listing state; +- `reproduction_contract`, initially + `membership_count_and_sha256_at_cutoff_v1`. + +The manifest may reference files only beneath its own directory after path +resolution. Absolute paths, parent traversal, symlink escape, duplicate +contract entries, missing contracts, unlisted files, and hash or row-count +mismatches fail closed. + +Manifest containers are limited to 64 nested levels before immutable semantic +freezing. Source identifiers must be scalar, control-free structural tokens. +Both boundaries fail closed with stable nonzero CLI errors, without traceback +or package mutation; ordinary Unicode scalar identifiers remain supported. + +The rights digest binds validation to the exact reviewed registry state. +Changing the source-rights registry requires a new manifest and validation; +the validator does not silently reuse a previous commercial decision. + +## Validation and State Model + +The preview packet reports these states independently: + +| State | Pass condition | +| --- | --- | +| `manifest_integrity` | Schema, paths, hashes, row counts, and registry digest match | +| `technical_validity` | Required fields, types, enums, identifiers, and ratios are valid | +| `temporal_validity` | Publication, retrieval, validity, membership, event, and evaluation timestamps obey the cutoff | +| `identity_coverage` | Every evaluated membership has one unambiguous valid security identity | +| `membership_coverage` | Declared coverage semantics and membership lineage are complete for the evaluated cutoff | +| `corporate_action_coverage` | Required action types are explicitly covered and resolvable | +| `delisting_coverage` | Listing state and delisted historical members are handled by the declared policy | +| `source_rights_eligibility` | Every populated evidence field has approved exact-source commercial rights and registered scope | +| `reproduction_ready` | Recomputed counts and membership digests are deterministic | +| `leakage_safe` | No evidence unavailable at an evaluation cutoff enters that evaluation | + +Each state is one of `passed`, `blocked`, or `not_applicable`. One passed state +cannot promote another. The packet is `analysis_eligible` only when every +applicable state passes and at least one benchmark evaluation and one research +universe evaluation remain eligible. A technically valid row with unverified +rights remains technically valid but commercially ineligible. + +`analysis_eligible` is local evidence eligibility only. It does not activate +Company Workbench, readiness, backtesting, calibration, or a public claim. + +## Row Classification + +The validator preserves four in-memory collections: + +- `raw`: ordered input rows with file and one-based source-row identity; +- `normalized`: technically parseable rows with canonical timestamps and + display tickers; +- `excluded`: rows withheld from an evaluation, each with stable reason codes; +- `analysis_eligible`: rows that pass every applicable gate for one exact + evaluation cutoff. + +The first slice does not write any collection. Preview output is capped and +contains counts, reason-code summaries, deterministic digests, and a bounded +sample; it does not echo an entire proprietary dataset. + +Initial stable reason-code families are: + +- `manifest_*`; +- `schema_*`; +- `lineage_*`; +- `identity_*`; +- `membership_*`; +- `corporate_action_*`; +- `delisting_*`; +- `source_rights_*`; +- `cutoff_*`; +- `leakage_*`; +- `partition_*`; +- `reproduction_*`. + +Reason codes are machine-stable; human explanations may improve without +changing the code. + +## Revision and Cutoff Rules + +Every contract uses one unique row ID and an optional exact supersession +parent. Within the same logical scope, lineage must form one append-ordered, +timestamp-increasing root-to-leaf chain. Duplicate IDs, missing parents, +cross-scope parents, multiple roots, forks, cycles, reversed order, and +non-leaf supersession are excluded. + +For a given evaluation: + +1. `source_published_at <= evaluation_at`; +2. `retrieved_at <= evaluation_at`; +3. `observation_at <= evaluation_at`; +4. identity and membership effective intervals contain `evaluation_at`; +5. event evidence used for resolution is effective and available no later + than `evaluation_at`; +6. only the latest unambiguous lineage leaf available by the cutoff is used; +7. later revisions remain invisible even when they describe an earlier + effective date. + +Today's constituents, ticker, listing state, prices, fundamentals, or +source-rights decision are never substituted for the historical cutoff. + +## Reproduction Contract + +For each `universe_id` and evaluation cutoff, the validator: + +1. selects eligible included stable `security_id` values; +2. sorts them by Unicode code point; +3. joins them with a single newline and no trailing newline; +4. computes SHA-256 over UTF-8 bytes; +5. reports the member count and digest. + +Repeated validation of identical bytes and registry state must reproduce the +same count, digest, exclusions, reason counts, and state decisions. The +manifest does not contain an expected result in the first slice because that +would permit circular self-attestation. A later reviewed real-data acceptance +step records the independently reviewed expected count and digest in a +separate evidence record. + +## Source-Rights Boundary + +The validator uses the existing exact-source registry but does not modify +`config/source_rights.yml`. + +Commercially eligible real evidence requires approved rights and registered +field scope for: + +- `security_identity`; +- `universe_membership`; +- `corporate_actions`; +- `delistings`. + +If those scopes or rights are absent, technical validation may proceed while +`source_rights_eligibility` remains blocked. Tests inject an isolated synthetic +registry; test registry entries do not authorize production or commercial use. + +## Read-Only Interfaces + +The planned command contracts are: + +```text +make point-in-time-universe-status MANIFEST= +make point-in-time-universe-preview MANIFEST= TOP_N=20 +``` + +Both commands: + +- read only the supplied manifest, referenced files, and rights registry; +- perform no network access; +- create no directory or artifact; +- never mutate canonical or generated data; +- emit research-only and no-completion boundaries; +- return nonzero only for invocation or unreadable/invalid manifest errors; +- return zero with explicit blocked states for a readable evidence package + that fails eligibility gates. + +Status emits the independent states and counts. Preview adds capped row and +exclusion samples plus deterministic membership digests. Neither command +offers an apply, record, refresh, or rebuild instruction. + +## Error Handling + +Invocation errors identify the missing or invalid argument without a +traceback. Unreadable JSON, unsafe paths, malformed CSV shape, hash mismatch, +or unsupported schema stops package evaluation because input identity is +unreliable. + +Row-level failures remain reviewable when the manifest is trustworthy. They +produce blocked independent states and stable exclusions rather than silently +dropping rows or repairing values. + +No exception path may create a file, partially write output, change readiness, +or fall back to current universe data. + +## Testing Strategy + +Tests use temporary directories and synthetic fixtures only. + +Required test groups: + +1. valid bounded package and deterministic count/digest reproduction; +2. missing/extra file, path traversal, symlink escape, hash mismatch, row-count + mismatch, schema mismatch, and registry-digest mismatch; +3. stable security identity across ticker change and ticker reuse across + different securities; +4. overlapping identity, unresolved membership, lineage fork/cycle, and + ambiguous leaf exclusions; +5. split, merger/successor, spinoff, suspension, reactivation, and delisting + policy validation; +6. current-constituent and current-listing-state substitution rejection; +7. post-cutoff publication, retrieval, revision, membership, event, and + evaluation leakage; +8. non-overlapping partition and chronological walk-forward validation; +9. independent technical and commercial-rights states; +10. empty package, no benchmark, no research universe, and all-excluded + behavior; +11. CLI and Make status/preview rendering; +12. byte-for-byte no-write assertions covering the entire temporary root. + +Focused tests must run before the full suite. Every implementation behavior +starts with a failing test. Release checks remain the existing dashboard, +public wording, public, commercial-beta, pilot, diff-hygiene, staged-hygiene, +and whitespace gates. + +## Product and Evidence Boundaries + +This foundation is research infrastructure, not an investing output. + +- It does not rank companies or securities. +- It does not issue buy, sell, allocation, sizing, stop-loss, take-profit, or + transaction instructions. +- It does not predict price or earnings outcomes. +- It does not turn candidate context into trusted evidence. +- It does not activate Revenue, EPS, valuation, catalyst, outcome, backtest, + calibration, or probability readiness. +- Technical evidence remains outside the primary research answer unless a + later separately approved UI design places a concise state under Advanced. + +## Acceptance Criteria + +The local implementation slice is accepted only when: + +1. every named contract and state is implemented exactly and tested; +2. status and preview are demonstrably write-free; +3. stable IDs, historical intervals, revision lineage, actions, delistings, + survivorship, source rights, cutoffs, reproduction, and leakage remain + independent fail-closed gates; +4. no current-state fallback or inferred mapping exists; +5. synthetic fixtures remain test-only; +6. existing current-universe workflows are unchanged; +7. focused, full, release, and hygiene gates pass; +8. exact intentional files are committed and pushed to + `codex/personal-research-mode-mvp`; +9. draft PR #113 has exact-head CI evidence. + +Priority 4 itself exits only after one bounded permitted real dataset passes +all local gates, an independently reviewed expected membership count and digest +reproduce from the immutable manifest, and the exact source rights and +historical coverage are directly evidenced. The local implementation slice +does not satisfy that exit gate by itself. diff --git a/docs/superpowers/specs/2026-07-26-provider-neutral-workspace-authorization-design.md b/docs/superpowers/specs/2026-07-26-provider-neutral-workspace-authorization-design.md new file mode 100644 index 00000000..805203fe --- /dev/null +++ b/docs/superpowers/specs/2026-07-26-provider-neutral-workspace-authorization-design.md @@ -0,0 +1,410 @@ +# Provider-Neutral Workspace Authorization Design + +## Status + +Approved design direction for the first local slice of Roadmap Priority 6. + +This specification defines a deterministic, deny-by-default authorization and +workspace-isolation contract. It is local software evidence only. It does not +implement or prove authentication, hosting, private persistence, audit storage, +retention, monitoring, rollback, incident response, or operated capacity. + +## Problem + +`src.private_beta_readiness` truthfully classifies future hosted capabilities +as external, and `docs/PRIVATE_BETA_ARCHITECTURE.md` states that every hosted +read and write must be authorized server-side. The repository does not yet +have a reusable policy decision that can answer: + +- whether the caller is authenticated; +- whether the caller and membership refer to the same principal; +- whether the membership is active; +- whether the request and membership refer to the same workspace; +- whether the role may perform the requested action on the resource class; +- whether append-only research records are protected from update or deletion; +- and which privacy-safe audit event a future hosted adapter must record. + +Without one shared decision contract, future identity, storage, API, or UI +adapters could independently reconstruct these rules and drift on +cross-workspace denial, revocation, append-only behavior, or audit obligations. + +## Goals + +1. Add one pure provider-neutral authorization decision used by future hosted + adapters. +2. Deny by default for missing, malformed, unauthenticated, inactive, + mismatched, unsupported, or unauthorized requests. +3. Make principal and workspace isolation explicit and independently tested. +4. Preserve append-only behavior for thesis, evidence, catalyst, and outcome + research records. +5. Return stable, non-sensitive reason codes and an audit obligation for every + allow or deny decision. +6. Keep the contract independent from Streamlit, research ledgers, readiness, + source rights, forecasts, probabilities, and generated artifacts. + +## Non-Goals + +This slice does not: + +- choose or integrate an identity provider; +- create sessions, accounts, invitations, password recovery, or MFA; +- choose a hosting, database, storage, logging, monitoring, or secrets vendor; +- read environment variables, tokens, cookies, headers, credentials, or + research content; +- persist audit events; +- implement retention, deletion execution, backup, recovery, or rollback; +- add private workspace UI or connect the public/local dashboard to the + contract; +- migrate local thesis, evidence, catalyst, outcome, watchlist, or scenario + data; +- change readiness, source rights, research conclusions, forecasts, + probabilities, recommendations, ranking, or transaction behavior; +- claim hosted authentication, isolation, commercial launch readiness, or user + validation. + +## Considered Approaches + +### A. Pure policy decision with stable domain contracts — selected + +Create one focused module with immutable request, membership, decision, and +audit-obligation types plus a pure evaluator. This gives future adapters a +single testable boundary without choosing infrastructure or touching local +research data. + +### B. Full hosted-control abstraction + +Define authentication, persistence, retention, audit storage, monitoring, and +rollback interfaces together. This was rejected for the first slice because it +would combine independent operating systems, encourage speculative vendor +abstractions, and make local code look stronger than direct hosted evidence. + +### C. Documentation-only threat model + +Expand `docs/PRIVATE_BETA_ARCHITECTURE.md` without executable policy. This was +rejected because it would not prevent future adapters from drifting on +principal, workspace, revocation, append-only, or audit rules. + +## Threat Boundary + +The contract addresses only authorization-policy risks that can be decided +from already authenticated, provider-neutral facts: + +- an unauthenticated caller reaches a future adapter; +- a caller substitutes another principal identifier; +- a caller substitutes another workspace identifier; +- a stale or revoked membership is reused; +- a caller requests an unknown action or resource class; +- a role exceeds its least-privilege boundary; +- an editor or owner attempts to update or delete append-only research + records; +- a future adapter records a decision without the required privacy-safe audit + fields; +- structural identifiers contain controls, separators, non-scalar values, or + unreasonable lengths that could corrupt logs or comparisons. + +The contract does not validate credentials or establish that an authentication +provider, session, membership record, or audit sink is genuine. A future +adapter must supply those facts from its approved environment. + +## Module Boundary + +Create `src/hosted_access_control.py`. It must depend only on the Python +standard library and must not import dashboard, ledger, readiness, provider, +source-rights, or generated-data modules. + +The module exposes: + +```python +class WorkspaceRole(str, Enum): + VIEWER = "viewer" + EDITOR = "editor" + OWNER = "owner" + + +class WorkspaceAction(str, Enum): + READ = "read" + APPEND = "append" + UPDATE = "update" + DELETE = "delete" + EXPORT = "export" + MANAGE = "manage" + + +class WorkspaceResource(str, Enum): + RESEARCH_RECORD = "research_record" + SAVED_WORKSPACE_STATE = "saved_workspace_state" + WORKSPACE_MEMBERSHIP = "workspace_membership" + WORKSPACE_AUDIT = "workspace_audit" + + +@dataclass(frozen=True) +class PrincipalContext: + principal_id: object + authenticated: object + + +@dataclass(frozen=True) +class WorkspaceMembership: + principal_id: object + workspace_id: object + role: object + active: object + + +@dataclass(frozen=True) +class WorkspaceAccessRequest: + request_id: object + workspace_id: object + action: object + resource: object + + +@dataclass(frozen=True) +class AuditObligation: + event_type: str + outcome: str + reason_code: str + required_fields: tuple[str, ...] + + +@dataclass(frozen=True) +class WorkspaceAccessDecision: + allowed: bool + reason_code: str + audit: AuditObligation + + +def evaluate_workspace_access( + principal: object, + membership: object, + request: object, +) -> WorkspaceAccessDecision: + ... +``` + +The public evaluator accepts `object` at the trust boundary so malformed +caller values fail closed through one stable decision rather than raising +before policy evaluation. The immutable dataclasses remain the supported +well-formed inputs. + +## Structural Input Contract + +`principal_id`, `workspace_id`, and `request_id` are opaque identifiers, not +display labels or secrets. A valid identifier: + +- is a Python `str`; +- is between 1 and 128 Unicode scalar values; +- has no leading or trailing whitespace; +- contains no C0/C1 controls, Unicode line or paragraph separators, or lone + surrogate code points. + +Ordinary non-ASCII scalar identifiers are allowed. The evaluator never +normalizes, truncates, repairs, hashes, aliases, or prints an invalid value. + +`authenticated` and `active` must be exact booleans. Role, action, and resource +must be members of their declared enums; arbitrary strings do not gain +authority. + +Malformed objects or fields return `invalid_request` and never raise an +expected authorization exception. + +## Decision Order + +The evaluator applies one deterministic order: + +1. Validate the three object types and every structural field. Otherwise deny + with `invalid_request`. +2. Require `principal.authenticated is True`. Otherwise deny with + `authentication_required`. +3. Require `membership.principal_id == principal.principal_id`. Otherwise deny + with `principal_mismatch`. +4. Require `membership.active is True`. Otherwise deny with + `membership_inactive`. +5. Require `request.workspace_id == membership.workspace_id`. Otherwise deny + with `workspace_mismatch`. +6. Apply the static role/resource/action policy. If no exact allow rule exists, + deny with the most specific stable policy reason below. +7. Return `allowed` only for an exact allow rule. + +No later rule can override an earlier denial. Input order, object identity, or +caller-supplied labels cannot change the result. + +## Policy Matrix + +| Resource | Viewer | Editor | Owner | +| --- | --- | --- | --- | +| `research_record` | `read` | `read`, `append` | `read`, `append`, `export` | +| `saved_workspace_state` | `read` | `read`, `append`, `update` | `read`, `append`, `update`, `delete`, `export` | +| `workspace_membership` | none | none | `read`, `manage` | +| `workspace_audit` | none | none | `read`, `export` | + +Additional invariant: + +- `update` and `delete` on `research_record` are always denied with + `append_only_mutation_denied`, including for owners. + +Other well-formed actions that are absent from the exact matrix are denied +with `role_action_denied`. The evaluator grants no wildcard, role inheritance +outside the table, implicit owner bypass, or caller-configurable policy. + +The `research_record` class represents thesis, evidence, catalyst, and outcome +records only. This slice does not authorize writes to canonical market data, +readiness, source-rights records, forecasts, probabilities, or proof ledgers. + +## Audit Obligation + +Every decision returns an `AuditObligation`; the evaluator does not persist it. + +The obligation is: + +```python +AuditObligation( + event_type="workspace_access_decision", + outcome="allowed" or "denied", + reason_code=, + required_fields=( + "request_id", + "principal_id", + "workspace_id", + "resource", + "action", + "outcome", + "reason_code", + "occurred_at", + ), +) +``` + +The contract requires identifiers and policy metadata only. It must not accept +or request: + +- credentials, tokens, cookies, API keys, session secrets, or headers; +- thesis, evidence, catalyst, outcome, scenario, watchlist, filing, consensus, + or valuation content; +- source documents, URLs, excerpts, forecasts, probabilities, recommendations, + position data, or transaction data. + +An audit obligation is not an audit event and does not prove that any sink +stored it. + +## Stable Reason Codes + +The first slice supports exactly: + +- `allowed` +- `invalid_request` +- `authentication_required` +- `principal_mismatch` +- `membership_inactive` +- `workspace_mismatch` +- `append_only_mutation_denied` +- `role_action_denied` + +Reason codes contain no caller values. Rendering or logging a decision therefore +cannot echo an identifier, secret, or research content through the reason. + +## Error Handling + +Expected authorization failures return denied decisions. The evaluator does +not: + +- throw for an unauthenticated, inactive, mismatched, unknown, or unauthorized + request; +- contact an external system; +- fall back to viewer, editor, owner, public, operator, or local mode; +- infer a role or workspace; +- repair malformed identifiers; +- retry a decision; +- mutate any input; +- write a file, ledger, report, cache, environment variable, or audit record. + +Unexpected programmer defects remain ordinary exceptions; tests must not catch +broad exceptions and convert them into an allow result. + +## Integration Boundary + +The first slice remains isolated: + +- no dashboard route imports the module; +- no local research ledger calls the evaluator; +- no readiness state is promoted; +- no current public, research, operator, or legacy mode changes; +- no generated data or runtime configuration is added. + +`docs/PRIVATE_BETA_ARCHITECTURE.md`, `ROADMAP.md`, and the continuation contract +will record that a local policy contract exists while all runtime and operated +controls remain external. `src.private_beta_readiness` may describe the local +contract in existing check detail, but authentication, workspaces, user-data +separation, audit, retention, entitlements, monitoring, health checks, +incident response, rollback, and owner capacity must retain their current +external or manual states. + +## Testing Strategy + +Create `tests/test_hosted_access_control.py` with real pure-function tests: + +1. Parameterize every exact allow entry in the policy matrix. +2. Parameterize representative absent entries for every role and resource. +3. Prove research-record update and delete are denied for every role. +4. Prove unauthenticated, principal-mismatched, inactive, and + workspace-mismatched requests fail in the declared order. +5. Prove malformed objects, booleans, enums, empty identifiers, surrounding + whitespace, controls, separators, lone surrogates, and identifiers longer + than 128 values return `invalid_request`. +6. Prove ordinary Unicode scalar identifiers remain deterministic. +7. Prove equal inputs return equal immutable decisions and input objects are + unchanged. +8. Prove every allow and deny result carries the exact audit obligation. +9. Prove the public dataclasses and audit requirements contain no secret, + credential, research-content, recommendation, position, or transaction + field. +10. Prove the module performs no file, environment, ledger, network, provider, + dashboard, or readiness operation. + +Documentation regression tests must preserve: + +- deny-by-default and cross-workspace language; +- append-only research-record protection; +- privacy-safe audit obligations; +- provider-neutral isolation; +- unchanged external hosted and operated gates; +- no claim of runtime authentication, hosting, audit storage, or market + validation. + +## Verification + +After implementation: + +1. Run the new focused authorization tests and existing private-beta readiness + tests. +2. Run documentation regression tests. +3. Run the complete test suite. +4. Run Research route render, commercial-beta, performance, public-wording, + browser-evidence, pilot-readiness, diff-hygiene, staged-hygiene, and + whitespace gates. +5. Confirm the 18 existing generated CSV/report changes and their fingerprint + remain unchanged and unstaged. +6. Synchronize exact intentional code, tests, and documentation only to + `codex/personal-research-mode-mvp`. +7. Keep PR #113 open and draft and require exact-head CI. + +## Acceptance Criteria + +The slice is complete only when direct current evidence proves: + +1. one pure provider-neutral evaluator owns the policy; +2. every missing, malformed, unauthenticated, inactive, mismatched, + unsupported, and unauthorized request is denied; +3. cross-principal and cross-workspace requests cannot pass; +4. append-only research records cannot be updated or deleted by any role; +5. every allow and deny result carries the stable privacy-safe audit + obligation; +6. reason codes never contain caller values; +7. the evaluator has no persistence, network, environment, dashboard, ledger, + readiness, provider, or generated-artifact side effect; +8. existing private-beta checks keep runtime and operated controls external; +9. full local verification and exact-head CI pass; +10. generated artifacts remain excluded; +11. no hosted, authenticated, isolated, audited, retained, monitored, + recoverable, operated, or market-ready capability is claimed from the + local contract. diff --git a/docs/templates/catalyst_evidence.csv b/docs/templates/catalyst_evidence.csv new file mode 100644 index 00000000..9ef4f811 --- /dev/null +++ b/docs/templates/catalyst_evidence.csv @@ -0,0 +1 @@ +schema_version,event_id,profile_key,ticker,event_type,title,effective_at,published_at,retrieved_at,source,source_ref,evidence_state,reviewer,summary diff --git a/docs/templates/earnings_nowcast/prospective_consensus.csv b/docs/templates/earnings_nowcast/prospective_consensus.csv new file mode 100644 index 00000000..df39721f --- /dev/null +++ b/docs/templates/earnings_nowcast/prospective_consensus.csv @@ -0,0 +1 @@ +schema_version,snapshot_id,ticker,fiscal_period,snapshot_at,retrieved_at,source,source_ref,revenue_consensus,eps_consensus,revenue_currency,revenue_unit_scale,revenue_basis,eps_currency,eps_basis,eps_share_basis,eps_operations_basis,split_adjustment_basis,expected_report_date,review_state,supersedes_snapshot_id diff --git a/docs/templates/historical_valuation_observations.csv b/docs/templates/historical_valuation_observations.csv new file mode 100644 index 00000000..941eff13 --- /dev/null +++ b/docs/templates/historical_valuation_observations.csv @@ -0,0 +1 @@ +ticker,metric,numerator,denominator,numerator_as_of,denominator_period_end,denominator_available_at,definition_id,source,source_ref,retrieved_at diff --git a/docs/templates/research_outcome_reviews.csv b/docs/templates/research_outcome_reviews.csv new file mode 100644 index 00000000..e35003b8 --- /dev/null +++ b/docs/templates/research_outcome_reviews.csv @@ -0,0 +1 @@ +schema_version,outcome_id,profile_key,ticker,thesis_id,original_thesis_entry_id,reviewed_at,observation_start,observation_end,reviewer,outcome_state,summary,source,source_ref,source_published_at,learning diff --git a/scripts/dashboard.sh b/scripts/dashboard.sh index c0c0ef46..494a2b1e 100755 --- a/scripts/dashboard.sh +++ b/scripts/dashboard.sh @@ -6,6 +6,7 @@ REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" echo "Repo root: ${REPO_ROOT}" cd "${REPO_ROOT}" +export PYTHONPATH="${REPO_ROOT}${PYTHONPATH:+:${PYTHONPATH}}" exec streamlit run src/dashboard.py \ --client.toolbarMode viewer \ --server.headless true diff --git a/scripts/diff_hygiene.py b/scripts/diff_hygiene.py index 8c67b69e..8af067b8 100644 --- a/scripts/diff_hygiene.py +++ b/scripts/diff_hygiene.py @@ -21,6 +21,7 @@ ) ROOT_PRODUCT_FILES = { + ".github/workflows/commercial-research-beta.yml", ".gitignore", ".streamlit/config.toml", ".streamlit/secrets.toml.example", @@ -33,6 +34,7 @@ "Makefile", "requirements.txt", "config.yaml", + "config/source_rights.yml", "config/hosted_demo.env.example", "config/provider_keys.env.example", "pyproject.toml", @@ -152,6 +154,44 @@ def load_staged_status(repo_root: Path) -> list[StatusEntry]: return [parse_name_status_line(line) for line in result.stdout.splitlines() if line] +def resolve_commit(repo_root: Path, revision: str, *, label: str) -> str: + value = str(revision or "").strip() + if not value: + raise ValueError(f"{label} is required") + try: + result = subprocess.run( + ["git", "rev-parse", "--verify", f"{value}^{{commit}}"], + cwd=repo_root, + check=True, + capture_output=True, + text=True, + ) + except subprocess.CalledProcessError as exc: + raise ValueError(f"{label} does not resolve to a commit: {value}") from exc + return result.stdout.strip() + + +def load_range_status( + repo_root: Path, base_revision: str, head_revision: str +) -> list[StatusEntry]: + """Load committed path changes from one explicit pull-request range.""" + + base_sha = resolve_commit(repo_root, base_revision, label="range base") + head_sha = resolve_commit(repo_root, head_revision, label="range head") + result = subprocess.run( + ["git", "diff", "--name-status", "--find-renames", f"{base_sha}...{head_sha}"], + cwd=repo_root, + check=True, + capture_output=True, + text=True, + ) + return [ + parse_name_status_line(line) + for line in result.stdout.splitlines() + if line + ] + + def load_staged_added_lines(repo_root: Path, path: str) -> list[str]: result = subprocess.run( ["git", "diff", "--cached", "--unified=0", "--", path], @@ -500,6 +540,54 @@ def build_summary_report(entries: list[StatusEntry]) -> str: return "\n".join(lines) +def range_hygiene_has_blockers(entries: list[StatusEntry]) -> bool: + """Generated CSV/JSON churn is never accepted silently in a PR range.""" + + return bool(group_entries(entries)["generated_csv_churn"]) + + +def build_range_check_report( + entries: list[StatusEntry], base_revision: str, head_revision: str +) -> str: + groups = group_entries(entries) + lines = [ + "Pull Request Range Hygiene Check", + "Read-only: this command inspects committed paths and does not modify the working tree.", + f"Range: {base_revision}...{head_revision}", + "", + format_count_line("Product/code/docs/test files", groups["product_candidate"]), + format_count_line("Markdown sample reports", groups["sample_report_candidate"]), + format_count_line("Generated CSV/JSON churn", groups["generated_csv_churn"]), + format_count_line("Manual-review paths", groups["review_manually"]), + ] + if range_hygiene_has_blockers(entries): + lines.extend( + [ + "", + "Pull-request range hygiene failed.", + "Generated CSV/JSON churn is present in the explicit base/head range:", + *format_paths(groups["generated_csv_churn"], limit=80), + ] + ) + else: + lines.extend( + [ + "", + "Pull-request range hygiene passed.", + "No generated CSV/JSON churn is present in the explicit base/head range.", + ] + ) + if groups["review_manually"]: + lines.extend( + [ + "", + "Manual-review paths in the range (reported, not reclassified):", + *format_paths(groups["review_manually"], limit=80), + ] + ) + return "\n".join(lines) + + def staged_hygiene_has_blockers(entries: list[StatusEntry]) -> bool: groups = group_entries(entries) return bool(groups["generated_csv_churn"] or groups["review_manually"]) @@ -1141,6 +1229,8 @@ def main() -> int: action="store_true", help="Fail if the staged diff includes generated CSV/JSON churn or manual-review paths.", ) + parser.add_argument("--range-base", help="Explicit pull-request base commit SHA.") + parser.add_argument("--range-head", help="Explicit pull-request head commit SHA.") parser.add_argument( "--data-release-decision", action="store_true", @@ -1158,6 +1248,15 @@ def main() -> int: ) args = parser.parse_args() repo_root = Path(__file__).resolve().parents[1] + if bool(args.range_base) != bool(args.range_head): + parser.error("--range-base and --range-head are required together") + if args.range_base and args.range_head: + try: + entries = load_range_status(repo_root, args.range_base, args.range_head) + except ValueError as exc: + parser.error(str(exc)) + print(build_range_check_report(entries, args.range_base, args.range_head)) + return 1 if range_hygiene_has_blockers(entries) else 0 if args.staged_check: entries = load_staged_status(repo_root) print(build_staged_check_report(entries, repo_root)) diff --git a/scripts/smoke_dashboard.sh b/scripts/smoke_dashboard.sh index 6435ca8b..809215c2 100755 --- a/scripts/smoke_dashboard.sh +++ b/scripts/smoke_dashboard.sh @@ -20,6 +20,20 @@ CURL_LOG="${TMPDIR:-/tmp}/stock-research-dashboard-smoke-${PORT}-curl.log" echo "Repo root: ${REPO_ROOT}" cd "${REPO_ROOT}" +export PYTHONPATH="${REPO_ROOT}${PYTHONPATH:+:${PYTHONPATH}}" + +REPO_ROOT="${REPO_ROOT}" python3 - <<'PY' +import os +from pathlib import Path + +import src.dashboard as dashboard + +root = Path(os.environ["REPO_ROOT"]).resolve() +module_path = Path(dashboard.__file__).resolve() +if root not in module_path.parents: + raise SystemExit(f"Dashboard import escaped the selected checkout: {module_path}") +print(f"Dashboard import check passed: {module_path}") +PY echo "Starting fresh Streamlit dashboard smoke check on isolated port ${PORT}" streamlit run src/dashboard.py --server.headless true --server.fileWatcherType none --server.port "${PORT}" >"${LOG_FILE}" 2>&1 & diff --git a/src/auto_refresh_orchestrator.py b/src/auto_refresh_orchestrator.py index f61a0b6e..ab3bc9df 100644 --- a/src/auto_refresh_orchestrator.py +++ b/src/auto_refresh_orchestrator.py @@ -6,6 +6,14 @@ from pathlib import Path from typing import Iterable +from src.continuation_gate import ContinuationGate, build_continuation_gate +from src.profile_context import build_profile_context +from src.refresh_operations import ( + ProviderAttempt, + RefreshOperationPlan, + RefreshOperationRequest, + build_refresh_operation_plan, +) from src.session_source_preflight import build_session_source_preflight @@ -63,6 +71,10 @@ class SchedulerPlan: weekly_commands: tuple[str, ...] optional_commands: tuple[str, ...] guardrails: tuple[str, ...] + refresh_operations: tuple[RefreshOperationPlan, ...] + retry_cap: int + session_id: str + provider_availability_proven: bool def build_default_lane_policies() -> tuple[LanePolicy, ...]: @@ -73,7 +85,7 @@ def build_default_lane_policies() -> tuple[LanePolicy, ...]: cadence="daily_after_market_close", provider_order=("stooq", "yahoo", "fmp", "alpha_vantage", "finnhub"), max_batch_size=3500, - auto_apply=True, + auto_apply=False, dry_run_command="make price-refresh-loop DRY_RUN=1 MAX_CANDIDATES=3500 TOP_N=100 PROVIDER=auto", gated_apply_command="make price-refresh-loop MAX_CANDIDATES=3500 TOP_N=100 PROVIDER=auto SLEEP_SECONDS=30", proof_command="make price-coverage TOP_N=25 && make readiness && make status-check TOP_N=5", @@ -85,7 +97,7 @@ def build_default_lane_policies() -> tuple[LanePolicy, ...]: cadence="daily", provider_order=("sec_submissions", "sec_filing_document", "sec_companyfacts"), max_batch_size=25, - auto_apply=True, + auto_apply=False, dry_run_command="make share-count-proof-queue TOP_N=25", gated_apply_command=( "make sec-filing-share-stage TICKERS= && " @@ -104,7 +116,7 @@ def build_default_lane_policies() -> tuple[LanePolicy, ...]: cadence="daily", provider_order=("sec_companyfacts", "yfinance", "fmp", "alpha_vantage", "finnhub"), max_batch_size=25, - auto_apply=True, + auto_apply=False, dry_run_command="make fundamentals-source-ladder-queue TOP_N=25", gated_apply_command=( "make imports-validate IMPORT_TICKERS= IMPORT_FILES=fundamentals.csv && " @@ -134,7 +146,7 @@ def build_default_lane_policies() -> tuple[LanePolicy, ...]: cadence="daily_or_weekly_when_provider_configured", provider_order=("yfinance", "fmp", "alpha_vantage", "finnhub"), max_batch_size=25, - auto_apply=True, + auto_apply=False, dry_run_command="make optional-context-source-ladder-queue TOP_N=10", gated_apply_command=( "make imports-validate IMPORT_TICKERS= && " @@ -202,8 +214,14 @@ def build_scheduler_plan( policies: Iterable[LanePolicy] | None = None, *, schedule: str = "all", + available_providers: Iterable[str] | None = None, + attempts: Iterable[ProviderAttempt] = (), + retry_cap: int = 1, + session_id: str = "scheduler-session", ) -> SchedulerPlan: all_policies = tuple(policies or build_default_lane_policies()) + if retry_cap < 1: + raise ValueError("retry_cap must be positive") def _policy_matches_schedule(policy: LanePolicy) -> bool: if schedule == "all": @@ -217,9 +235,31 @@ def _policy_matches_schedule(policy: LanePolicy) -> bool: return True selected = tuple(policy for policy in all_policies if _policy_matches_schedule(policy)) + provider_attempts = tuple(attempts) + provider_availability_proven = available_providers is not None + available = ( + tuple(available_providers) + if available_providers is not None + else () + ) + refresh_operations = tuple( + build_refresh_operation_plan( + RefreshOperationRequest( + lane=policy.lane, + provider_order=policy.provider_order, + available_providers=available, + batch_limit=policy.max_batch_size, + freshness_policy=policy.cadence, + retry_cap=retry_cap, + session_id=session_id, + attempts=provider_attempts, + ) + ) + for policy in selected + ) - def _commands_for(policy: LanePolicy) -> tuple[str, str]: - return (policy.dry_run_command, policy.gated_apply_command) + def _commands_for(policy: LanePolicy) -> tuple[str, ...]: + return (policy.dry_run_command,) daily = tuple( command @@ -251,18 +291,48 @@ def _commands_for(policy: LanePolicy) -> tuple[str, str]: "No broker integration.", "No auto-trading, order routing, or direct buy/sell instructions.", "No fabricated prices, fundamentals, shares, peers, earnings, estimates, or valuation inputs.", + "Automatic application is disabled; scheduler output is read-only planning and manual-review handoff only.", "Run make session-source-preflight before scheduler batches.", "Free-tier fallback caps: fmp<=250/day and <=25/run; alpha_vantage<=25/day and <=5/run; finnhub<=60/day and <=10/run.", "Do not repeat exhausted source-proof queues; run make coverage-expansion-loop TOP_N=10 and pivot to workflow evidence until new source-backed rows, keyed providers, reviewed manual rows, or changed blockers appear.", "Generated CSV/JSON/report churn stays excluded unless intentionally reviewed evidence.", ), + refresh_operations=refresh_operations, + retry_cap=retry_cap, + session_id=session_id, + provider_availability_proven=provider_availability_proven, ) +def available_refresh_providers(preflight: dict[str, object]) -> frozenset[str]: + sources = preflight.get("sources", {}) + sources = sources if isinstance(sources, dict) else {} + + def _available(source_name: str) -> bool: + status = sources.get(source_name, {}) + return isinstance(status, dict) and status.get("status") == "available" + + available = {"local_industry", "sic", "sector"} + if _available("price_ladder"): + available.update(("stooq", "yahoo")) + if _available("sec"): + available.add("sec_companyfacts") + if _available("sec_submissions"): + available.add("sec_submissions") + if _available("sec") and _available("sec_submissions"): + available.add("sec_filing_document") + if _available("yfinance_stage"): + available.add("yfinance") + for provider in ("fmp", "alpha_vantage", "finnhub"): + if _available(provider): + available.add(provider) + return frozenset(available) + + def render_scheduler_plan(plan: SchedulerPlan) -> str: lines = [ "Auto Refresh Orchestrator Plan", - "Read-only plan: this command prints scheduler-ready coverage commands and deterministic auto-apply gates.", + "Read-only plan: this command prints scheduler-ready coverage commands and manual-review gates.", "Research-only: no broker integration, no auto-trading, no order routing, and no direct buy/sell instructions.", "", "Proof outcomes: auto_supported, human_reviewed_supported, candidate_context_only, still_blocked, skipped, excluded.", @@ -286,8 +356,17 @@ def render_scheduler_plan(plan: SchedulerPlan) -> str: f"auto_apply={str(policy.auto_apply).lower()}; providers={','.join(policy.provider_order)}" ) lines.append(f" source boundary: {policy.source_boundary}") - lines.append(f" gated apply: {policy.gated_apply_command}") - lines.append(f" proof: {policy.proof_command}") + lines.append(" scheduler boundary: scope review only; mutation requires a separate reviewed handoff") + lines.append("Refresh operation plans:") + for operation in plan.refresh_operations: + lines.append( + f"- {operation.lane}: status={operation.status}; provider={operation.selected_provider or '-'}; " + f"skipped={','.join(operation.skipped_providers) or '-'}; " + f"failure_reason={operation.failure_reason or '-'}; " + f"automatic_apply_enabled={str(operation.automatic_apply_enabled).lower()}; " + f"stages={','.join(stage.name for stage in operation.stages)}" + ) + lines.append(f"Retry policy: session_id={plan.session_id}; retry_cap={plan.retry_cap}") lines.append("Guardrails:") lines.extend(f"- {guardrail}" for guardrail in plan.guardrails) return "\n".join(lines) @@ -305,20 +384,44 @@ def _human_source_gate(value: object) -> str: return text -def render_scheduler_runbook(plan: SchedulerPlan, preflight: dict[str, object] | None = None) -> str: +def render_scheduler_runbook( + plan: SchedulerPlan, + preflight: dict[str, object] | None = None, + *, + continuation_gate: ContinuationGate | None = None, +) -> str: schedule_label = plan.schedule.replace("_", " ").title() lines = [ f"Auto Refresh {schedule_label} Runbook", "Compact unattended checklist. Research-only; no broker integration, no auto-trading, no order routing.", "", - "Start:", - "- make session-source-preflight", - "- make readiness-ops-center", - "- make coverage-frontier TOP_N=10", - "", ] + if continuation_gate is not None and continuation_gate.suppress_execution: + lines.extend( + [ + f"Stale readiness continuation gate: {continuation_gate.state}", + f"- Reason: {continuation_gate.reason}", + "- Lane policies below are planning context only.", + f"- Rebuild boundary: {continuation_gate.rebuild_command} is a separate intentional reviewed write.", + f"- Stop rule: {continuation_gate.stop_rule}", + "", + "Start:", + f"- {continuation_gate.next_safe_command}", + "", + ] + ) + else: + lines.extend( + [ + "Start:", + "- make session-source-preflight", + "- make readiness-ops-center", + "- make coverage-frontier TOP_N=10", + "", + ] + ) if preflight is not None: - payload = build_auto_refresh_status_payload(preflight, plan) + payload = build_auto_refresh_status_payload(preflight, plan, continuation_gate=continuation_gate) lines.extend( [ "Current source gate:", @@ -334,17 +437,13 @@ def render_scheduler_runbook(plan: SchedulerPlan, preflight: dict[str, object] | lines.append("Lane loop:") for index, policy in enumerate(plan.policies, start=1): gate_mode = ( - "Run the deterministic gate before apply; if blocked, use " - "ALLOW_BLOCKED_GATE=1 make auto-apply-gate to record still_blocked and pivot." - if policy.auto_apply - else "No auto-apply; keep candidate context separate from trusted proof." + "Automatic application is disabled. This scheduler stops after scope preview; " + "record still_blocked and pivot when the source path is unavailable." ) lines.extend( [ f"{index}. {policy.label}", f" dry-run: {policy.dry_run_command}", - f" gated apply: {policy.gated_apply_command}", - f" proof: {policy.proof_command}", f" gate rule: {gate_mode}", f" source boundary: {policy.source_boundary}", ] @@ -385,13 +484,33 @@ def _join_values(value: object) -> str: return text or "-" -def render_auto_refresh_status(preflight: dict[str, object], plan: SchedulerPlan) -> str: - payload = build_auto_refresh_status_payload(preflight, plan) +def render_auto_refresh_status( + preflight: dict[str, object], + plan: SchedulerPlan, + *, + continuation_gate: ContinuationGate | None = None, +) -> str: + payload = build_auto_refresh_status_payload(preflight, plan, continuation_gate=continuation_gate) categories = payload["source_categories"] lines = [ "Auto Refresh Status", "Read-only scheduler summary. It does not refresh, import, apply, or rewrite local data.", "Research-only: no investment advice, broker actions, auto-trading, order routing, or direct buy/sell instructions.", + ] + if continuation_gate is not None and continuation_gate.suppress_execution: + lines.extend( + [ + "", + f"Stale readiness continuation gate: {continuation_gate.state}", + f"- Next safe preview: {continuation_gate.next_safe_command}", + f"- Reason: {continuation_gate.reason}", + "- refresh_operations below are planning context only; they are not executable routing.", + f"- Rebuild boundary: {continuation_gate.rebuild_command} is a separate intentional reviewed write.", + f"- Stop rule: {continuation_gate.stop_rule}", + ] + ) + lines.extend( + [ "", f"source_activation: {payload['source_activation']}", f"source_activation_reason: {_human_source_gate(payload['source_activation_reason'])}", @@ -411,11 +530,33 @@ def render_auto_refresh_status(preflight: dict[str, object], plan: SchedulerPlan f"free_tier_batch_limits: {payload['free_tier_batch_limits']}", f"pivot_rule: {payload['pivot_rule']}", f"artifact_policy: {payload['artifact_policy']}", - ] + "", + "refresh_operations:", + ] + ) + for operation in payload["refresh_operations"]: + lines.append( + f"- {operation['lane']}: status={operation['status']}; " + f"provider={operation['selected_provider'] or '-'}; " + f"failure_reason={operation['failure_reason'] or '-'}" + ) return "\n".join(lines) -def build_auto_refresh_status_payload(preflight: dict[str, object], plan: SchedulerPlan) -> dict[str, object]: +def build_auto_refresh_status_payload( + preflight: dict[str, object], + plan: SchedulerPlan, + *, + continuation_gate: ContinuationGate | None = None, +) -> dict[str, object]: + if not plan.provider_availability_proven: + plan = build_scheduler_plan( + plan.policies, + schedule=plan.schedule, + available_providers=available_refresh_providers(preflight), + retry_cap=plan.retry_cap, + session_id=plan.session_id, + ) activation = preflight.get("source_activation", {}) activation = activation if isinstance(activation, dict) else {} categories = preflight.get("source_categories", {}) @@ -427,7 +568,7 @@ def build_auto_refresh_status_payload(preflight: dict[str, object], plan: Schedu next_command = _join_values(console.get("next_executable_command") or operator_summary.get("next_step")) schedule = plan.schedule - return { + payload = { "source_activation": _join_values(activation.get("status")), "source_activation_reason": _join_values(activation.get("reason")), "can_run_now": _join_values(operator_summary.get("can_run_now") or console.get("next_executable_lane")), @@ -445,7 +586,23 @@ def build_auto_refresh_status_payload(preflight: dict[str, object], plan: Schedu "free_tier_batch_limits": _join_values(console.get("free_tier_batch_limits")), "pivot_rule": "if a source path is unavailable or already reviewed non-actionable, record the outcome once and move to the next executable lane.", "artifact_policy": "generated CSV/JSON/report churn stays excluded unless intentionally reviewed evidence.", + "refresh_operations": [asdict(operation) for operation in plan.refresh_operations], } + if continuation_gate is not None: + payload["continuation_gate"] = asdict(continuation_gate) + if continuation_gate.suppress_execution: + existing_avoid = str(payload.get("avoid_repeating") or "").strip() + avoid_parts = [] if existing_avoid in {"", "-"} else [existing_avoid] + avoid_parts.extend(["broad_refresh", "source_proof", "readiness_rebuild"]) + payload.update( + { + "can_run_now": continuation_gate.state, + "avoid_repeating": ", ".join(dict.fromkeys(avoid_parts)), + "next_executable_command": continuation_gate.next_safe_command, + "next_step_reason": continuation_gate.reason, + } + ) + return payload def _build_gate_from_args(args: argparse.Namespace) -> AutoGateInput: @@ -463,6 +620,13 @@ def _build_gate_from_args(args: argparse.Namespace) -> AutoGateInput: ) +def _parse_provider_attempt(value: str) -> ProviderAttempt: + parts = tuple(part.strip() for part in value.split(":")) + if len(parts) != 3 or not all(parts): + raise argparse.ArgumentTypeError("provider attempt must be provider:session_id:outcome") + return ProviderAttempt(provider=parts[0], session_id=parts[1], outcome=parts[2]) + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description="Plan and gate unattended source-backed coverage refreshes.") parser.add_argument("--root", default=".") @@ -470,6 +634,20 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--json", action="store_true") parser.add_argument("--runbook", action="store_true") parser.add_argument("--status", action="store_true") + parser.add_argument( + "--available-providers", + default=None, + help="Comma-separated providers proven available for this scheduler session.", + ) + parser.add_argument("--session-id", default="scheduler-session") + parser.add_argument("--retry-cap", type=int, default=1) + parser.add_argument( + "--provider-attempt", + action="append", + type=_parse_provider_attempt, + default=[], + help="Prior attempt as provider:session_id:outcome; repeat for multiple attempts.", + ) parser.add_argument("--gate-lane", default="") parser.add_argument("--changed-rows", type=int, default=0) parser.add_argument("--max-batch-size", type=int, default=25) @@ -489,7 +667,7 @@ def main(argv: list[str] | None = None) -> int: ), ) args = parser.parse_args(argv) - Path(args.root).resolve() + root = Path(args.root).resolve() if args.gate_lane: decision = evaluate_auto_apply_gate(_build_gate_from_args(args)) @@ -509,19 +687,47 @@ def main(argv: list[str] | None = None) -> int: return 0 return 2 - plan = build_scheduler_plan(schedule=args.schedule) + available_providers = None + if args.available_providers is not None: + available_providers = tuple( + provider.strip() for provider in args.available_providers.split(",") if provider.strip() + ) + preflight = None + if available_providers is None: + preflight = build_session_source_preflight(root) + available_providers = available_refresh_providers(preflight) + plan = build_scheduler_plan( + schedule=args.schedule, + available_providers=available_providers, + attempts=args.provider_attempt, + retry_cap=args.retry_cap, + session_id=args.session_id, + ) + continuation_gate = build_continuation_gate(build_profile_context(project_root=root)) if args.status: - preflight = build_session_source_preflight(Path(args.root).resolve()) + if preflight is None: + preflight = build_session_source_preflight(root) if args.json: - print(json.dumps(build_auto_refresh_status_payload(preflight, plan), indent=2, sort_keys=True)) + print( + json.dumps( + build_auto_refresh_status_payload( + preflight, + plan, + continuation_gate=continuation_gate, + ), + indent=2, + sort_keys=True, + ) + ) else: - print(render_auto_refresh_status(preflight, plan)) + print(render_auto_refresh_status(preflight, plan, continuation_gate=continuation_gate)) return 0 if args.json: print(json.dumps(asdict(plan), indent=2, sort_keys=True)) elif args.runbook: - preflight = build_session_source_preflight(Path(args.root).resolve()) - print(render_scheduler_runbook(plan, preflight)) + if preflight is None: + preflight = build_session_source_preflight(root) + print(render_scheduler_runbook(plan, preflight, continuation_gate=continuation_gate)) else: print(render_scheduler_plan(plan)) return 0 diff --git a/src/browser_qa_evidence.py b/src/browser_qa_evidence.py index a66b16af..1ac31247 100644 --- a/src/browser_qa_evidence.py +++ b/src/browser_qa_evidence.py @@ -53,20 +53,20 @@ class BrowserQaCaptureTarget: DEFAULT_BROWSER_QA_EVIDENCE: tuple[BrowserQaEvidence, ...] = ( BrowserQaEvidence( - name="LinkedIn public dashboard thumbnail", + name="LinkedIn Company Workbench thumbnail", path=Path("docs/assets/linkedin-public-dashboard.png"), - route="http://localhost:8501/?mode=public", + route="http://localhost:8501/?mode=research&page=company-workbench&ticker=AVGO&open=1", expected_markers=( - "Saved readiness", - "Step 1 of 5", - "What is this product and where do I start?", + "Company Workbench", + "Use now", + "Still withheld", + "Open Data Health", + "Stop if peer mappings or peer valuation inputs lack source-backed rows.", "Research-only", - "Start with Stock Selector", - "No data, no conclusion", ), min_width=1200, - min_height=600, - use="LinkedIn Featured and GitHub preview image.", + min_height=627, + use="LinkedIn Featured answer-first Company Workbench image; product-flow evidence only.", ), BrowserQaEvidence( name="Public visitor home screenshot", @@ -280,6 +280,38 @@ class BrowserQaCaptureTarget: qa_focus="Operator can see why a fundamentals lane is blocked before opening advanced route-map evidence.", stop_rule="Stop if route links execute commands, expose raw tables first, or imply generated churn belongs in the default staging set.", ), + BrowserQaRouteCheck( + name="Research Desk", + route="http://localhost:8501/?mode=research&page=research-desk", + first_view_markers=("Saved readiness", "RESEARCH DESK", "FOCUSED COHORT", "Next action", "Research-only"), + details_boundary="Research change evidence stays under Advanced; the desk starts with cohort scope and one next action.", + qa_focus="Reviewer can understand the saved research scope and continue to Discover without reading operator evidence.", + stop_rule="Stop if the desk shows a traceback, recommendation ranking, raw source table, or unresolved evidence before the cohort answer.", + ), + BrowserQaRouteCheck( + name="Research Discover", + route="http://localhost:8501/?mode=research&page=discover", + first_view_markers=("Saved readiness", "DISCOVER", "Which stock can I review?", "Search this review queue", "Research-only"), + details_boundary="Advanced filters and full selector rows stay collapsed; the first path is a bounded readiness-backed company queue.", + qa_focus="Reviewer can choose a company because it is reviewable, not because the product predicts expected return.", + stop_rule="Stop if Discover ranks recommendations, exposes raw readiness tables first, or requires horizontal scrolling.", + ), + BrowserQaRouteCheck( + name="Research Company Workbench", + route="http://localhost:8501/?mode=research&page=company-workbench&ticker=NVDA&open=1", + first_view_markers=("Saved readiness", "Company Workbench", "Use now", "Still withheld", "Open Data Health", "Research Decision Lab", "Research-only"), + details_boundary="The `Add a reviewed research record` composer stays below the selected-company and journal/outcome answers; Advanced evidence remains collapsed.", + qa_focus="Reviewer sees the selected company, usable evidence, withheld lanes, and one research task before technical tables. Route-marker screenshots do not prove validation, confirmation, or persistence; the temporary-ledger AppTest and direct persistence tests provide that evidence.", + stop_rule="Stop if the workbench shows traceback text, synthetic evidence as real, an unavailable forecast, an Arrow-incompatible evidence table, a visible confirmation appears before an exact preview, or a route-marker screenshot is presented as validation, confirmation, or persistence evidence.", + ), + BrowserQaRouteCheck( + name="Research Monitor", + route="http://localhost:8501/?mode=research&page=monitor", + first_view_markers=("Saved readiness", "MONITOR", "WEEKLY RESEARCH SUMMARY", "Research Discipline Review", "Research change monitor", "Research-only"), + details_boundary="Research Discipline identity and change evidence stay under separate Advanced sections; an empty monitor remains a truthful wait state rather than a ranking.", + qa_focus="Reviewer can distinguish process work from source changes, no verified change from no data, and knows when to continue or wait.", + stop_rule="Stop if Monitor invents changes, recommends stocks, or exposes raw evidence before the change summary.", + ), ) @@ -329,6 +361,42 @@ class BrowserQaCaptureTarget: mobile_risk="Proof History starts to feel like a second command center instead of evidence review.", stop_rule="Stop if raw ledger rows, command blocks, or data-refresh language appear before evidence cards.", ), + BrowserQaResponsiveRouteCheck( + page="Research Desk", + route="http://localhost:8501/?mode=research&page=research-desk", + desktop_viewport="1280x720", + phone_viewport="390x844", + first_view_must_keep="Saved readiness, Research Desk, focused cohort, freshness, one next action, Research-only", + mobile_risk="Cohort cards or monitoring summaries push the primary Discover path too far below the first answer.", + stop_rule="Stop if the phone view overflows horizontally or opens with raw research-change evidence.", + ), + BrowserQaResponsiveRouteCheck( + page="Discover", + route="http://localhost:8501/?mode=research&page=discover", + desktop_viewport="1280x720", + phone_viewport="390x844", + first_view_must_keep="Saved readiness, Discover, focused cohort boundary, company search, Research-only", + mobile_risk="Company cards, filters, or badges become cramped enough to obscure the review action.", + stop_rule="Stop if the phone view ranks recommendations, exposes full rows first, or forces horizontal scrolling.", + ), + BrowserQaResponsiveRouteCheck( + page="Company Workbench", + route="http://localhost:8501/?mode=research&page=company-workbench&ticker=NVDA&open=1", + desktop_viewport="1280x720", + phone_viewport="390x844", + first_view_must_keep="Saved readiness, Company Workbench, usable evidence, withheld lanes, one Data Health handoff, Research Decision Lab, Research-only", + mobile_risk="Long review-path copy, the below-answer composer, or technical sections obscure the selected-company answer and research boundary.", + stop_rule="Stop if the phone view overflows, shows traceback text, renders technical evidence before the selected-company answer, or shows a visible confirmation before an exact preview.", + ), + BrowserQaResponsiveRouteCheck( + page="Monitor", + route="http://localhost:8501/?mode=research&page=monitor", + desktop_viewport="1280x720", + phone_viewport="390x844", + first_view_must_keep="Saved readiness, Monitor, weekly summary, Research Discipline Review, change state, one next action, Research-only", + mobile_risk="An empty evidence queue looks broken instead of a truthful wait state.", + stop_rule="Stop if the phone view invents changes, exposes raw evidence first, or forces horizontal scrolling.", + ), ) @@ -614,7 +682,7 @@ def browser_qa_share_recommendation_rows( { "Review Item": "Data readiness claim", "State": "blocked_inputs_remain_blocked", - "Recommendation": "Use make status-check TOP_N=5 for current counts; do not treat screenshots as data freshness proof.", + "Recommendation": "Do not publish readiness counts from screenshots; use the live read-only status check for local operating review.", "Boundary": "Screenshots do not unlock fundamentals, peers, earnings, estimates, valuation inputs, or metrics.", }, ] @@ -755,8 +823,8 @@ def main(argv: list[str] | None = None) -> int: print("Manual browser review: use these route checks when a normal local browser can open the Streamlit app.") print(_markdown_table(route_rows, ["Route Check", "Route", "First View Markers", "Details Boundary", "QA Focus", "Stop Rule"])) print() - print("Responsive Public Workflow QA") - print("Review the five public pages at desktop and phone width before changing screenshots or LinkedIn copy.") + print("Responsive Public And Personal Research Workflow QA") + print("Review the five public pages and four personal-research pages at desktop and phone width before changing screenshots or LinkedIn copy.") print( _markdown_table( responsive_route_rows, diff --git a/src/catalyst_evidence_timeline.py b/src/catalyst_evidence_timeline.py new file mode 100644 index 00000000..4046595c --- /dev/null +++ b/src/catalyst_evidence_timeline.py @@ -0,0 +1,263 @@ +"""Source-backed catalyst evidence timeline for research context only.""" + +from __future__ import annotations + +import argparse +import csv +from dataclasses import asdict, dataclass, fields +from datetime import timedelta +from pathlib import Path +from typing import Iterable, Mapping + +from src.commercial_source_rights import ( + SourceRights, + load_source_rights_registry, + review_commercial_field_scope, +) +from src.earnings_nowcast_contract import parse_utc_timestamp +from src.research_ledger_lock import ledger_write_lock + + +SCHEMA_VERSION = "catalyst-evidence-v1" +EVENT_TYPES = {"earnings", "product", "regulatory", "customer", "industry", "capital_allocation", "management", "macro"} +EVIDENCE_STATES = {"supported", "candidate_context_only", "still_blocked", "skipped", "excluded"} + + +@dataclass(frozen=True) +class CatalystEvent: + schema_version: str + event_id: str + profile_key: str + ticker: str + event_type: str + title: str + effective_at: str + published_at: str + retrieved_at: str + source: str + source_ref: str + evidence_state: str + reviewer: str + summary: str + + +FIELDS = tuple(field.name for field in fields(CatalystEvent)) + + +@dataclass(frozen=True) +class EventPreview: + state: str + reason: str + write_performed: bool = False + + +@dataclass(frozen=True) +class CatalystTimeline: + ticker: str + state: str + upcoming: tuple[CatalystEvent, ...] + recent: tuple[CatalystEvent, ...] + rejected_count: int + commercial_blocker_count: int + commercial_blockers: tuple[str, ...] + boundary: str + + +def _validation_error(row: CatalystEvent, existing: Iterable[CatalystEvent] = ()) -> str: + if row.schema_version != SCHEMA_VERSION: + return f"schema_version must be {SCHEMA_VERSION}" + for field in FIELDS: + if not str(getattr(row, field) or "").strip(): + return f"{field} is required" + if row.event_type not in EVENT_TYPES: + return "event_type is unsupported" + if row.evidence_state not in EVIDENCE_STATES: + return "evidence_state is unsupported" + if any(existing_row.event_id == row.event_id for existing_row in existing): + return f"event_id already exists: {row.event_id}" + identity = ( + row.profile_key, row.ticker.upper(), row.event_type, row.effective_at, + row.source, row.source_ref, + ) + if any( + ( + existing_row.profile_key, existing_row.ticker.upper(), existing_row.event_type, + existing_row.effective_at, existing_row.source, existing_row.source_ref, + ) == identity + for existing_row in existing + ): + return "duplicate catalyst evidence already exists for this event and source" + try: + published = parse_utc_timestamp(row.published_at, label="published_at") + retrieved = parse_utc_timestamp(row.retrieved_at, label="retrieved_at") + parse_utc_timestamp(row.effective_at, label="effective_at") + except ValueError as exc: + return str(exc) + if published > retrieved: + return "published_at cannot be after retrieved_at" + return "" + + +def preview_event(row: CatalystEvent, *, existing: Iterable[CatalystEvent]) -> EventPreview: + reason = _validation_error(row, tuple(existing)) + if reason: + return EventPreview("rejected", reason) + return EventPreview(row.evidence_state, "reviewed event is eligible for append") + + +def load_catalyst_events(path: Path | str) -> tuple[CatalystEvent, ...]: + source = Path(path) + if not source.is_file(): + return () + with source.open(newline="", encoding="utf-8") as handle: + reader = csv.DictReader(handle) + if tuple(reader.fieldnames or ()) != FIELDS: + raise ValueError("Catalyst ledger header does not match the append-only contract.") + rows = tuple(CatalystEvent(**{field: str(row.get(field) or "").strip() for field in FIELDS}) for row in reader) + validated: list[CatalystEvent] = [] + for row in rows: + reason = _validation_error(row, validated) + if reason: + raise ValueError(reason) + validated.append(row) + return rows + + +def append_reviewed_event(path: Path | str, row: CatalystEvent, *, confirm_reviewed: bool) -> Path: + if not confirm_reviewed: + raise ValueError("confirm_reviewed is required before recording a catalyst event") + destination = Path(path) + with ledger_write_lock(destination): + existing = load_catalyst_events(destination) + reason = _validation_error(row, existing) + if reason: + raise ValueError(reason) + destination.parent.mkdir(parents=True, exist_ok=True) + exists = destination.exists() and destination.stat().st_size > 0 + with destination.open("a", newline="", encoding="utf-8") as handle: + writer = csv.DictWriter(handle, fieldnames=FIELDS) + if not exists: + writer.writeheader() + writer.writerow(asdict(row)) + return destination + + +def build_catalyst_timeline( + rows: Iterable[CatalystEvent], + *, + profile_key: str, + ticker: str, + as_of: str, + recent_days: int = 90, + commercial_mode: bool = False, + rights_registry: Mapping[str, SourceRights] | None = None, +) -> CatalystTimeline: + cutoff = parse_utc_timestamp(as_of, label="timeline cutoff") + accepted: list[CatalystEvent] = [] + rejected = 0 + commercial_blockers: list[str] = [] + for row in rows: + if row.profile_key != profile_key or row.ticker.upper() != ticker.upper(): + continue + reason = _validation_error(row) + if reason or parse_utc_timestamp(row.published_at) > cutoff or parse_utc_timestamp(row.retrieved_at) > cutoff: + rejected += 1 + continue + accepted.append(row) + upcoming = tuple(sorted((row for row in accepted if parse_utc_timestamp(row.effective_at) >= cutoff), key=lambda row: parse_utc_timestamp(row.effective_at))) + recent_boundary = cutoff - timedelta(days=recent_days) + recent = tuple(sorted((row for row in accepted if recent_boundary <= parse_utc_timestamp(row.effective_at) < cutoff), key=lambda row: parse_utc_timestamp(row.effective_at), reverse=True)) + if commercial_mode and (upcoming or recent): + registry = rights_registry if rights_registry is not None else load_source_rights_registry() + + def commercially_visible(events: tuple[CatalystEvent, ...]) -> tuple[CatalystEvent, ...]: + visible: list[CatalystEvent] = [] + for row in events: + if row.evidence_state != "supported": + visible.append(row) + continue + review = review_commercial_field_scope( + registry, row.source, ("catalyst_evidence",) + ) + if review.commercial_evidence_ready: + visible.append(row) + continue + missing = ", ".join(review.missing_supported_fields) or "catalyst_evidence" + commercial_blockers.append( + f"{row.event_id}: exact source {row.source or '-'} is {review.rights_status}; " + f"registered scope missing: {missing}" + ) + return tuple(visible) + + upcoming = commercially_visible(upcoming) + recent = commercially_visible(recent) + visible = (*upcoming, *recent) + state = "supported" if any(row.evidence_state == "supported" for row in visible) else "candidate_context_only" if visible else "blocked" + return CatalystTimeline( + ticker=ticker.upper(), + state=state, + upcoming=upcoming, + recent=recent, + rejected_count=rejected, + commercial_blocker_count=len(commercial_blockers), + commercial_blockers=tuple(commercial_blockers), + boundary="Catalyst evidence is research context only and cannot change forecasts, valuation inputs, readiness, or recommendations.", + ) + + +def catalyst_timeline_cards(packet: CatalystTimeline) -> list[dict[str, object]]: + if packet.state == "blocked": + title = "No cutoff-safe catalyst evidence is available" + body = ( + f"{packet.commercial_blocker_count} supported event(s) lack approved exact-source rights or registered catalyst-evidence scope." + if packet.commercial_blocker_count + else "Add a reviewed source reference and publication, retrieval, and effective timestamps before showing an event." + ) + else: + title = f"{len(packet.upcoming)} upcoming and {len(packet.recent)} recent reviewed event(s)" + body = packet.boundary + return [{ + "kicker": "CATALYST EVIDENCE", + "title": title, + "body": body, + "badges": [packet.state.replace("_", " "), "source-backed only", "context only"], + "command": "", + }] + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Read a source-backed catalyst evidence timeline.") + parser.add_argument("--ledger", default="data/catalyst_evidence.csv") + parser.add_argument("--profile-key", default="default") + parser.add_argument("--ticker") + parser.add_argument("--as-of") + parser.add_argument("--preview-input") + parser.add_argument("--record-input") + parser.add_argument("--confirm-reviewed", action="store_true") + args = parser.parse_args(argv) + if args.preview_input or args.record_input: + input_path = args.preview_input or args.record_input + existing = load_catalyst_events(args.ledger) + candidates = load_catalyst_events(input_path) + if args.preview_input: + for row in candidates: + preview = preview_event(row, existing=existing) + print(f"{row.event_id}: {preview.state} {preview.reason}".rstrip()) + print("Preview only: no file was changed.") + return 0 + if not args.confirm_reviewed: + raise ValueError("record requires --confirm-reviewed after preview and source review") + for row in candidates: + append_reviewed_event(args.ledger, row, confirm_reviewed=True) + print(f"Appended reviewed catalyst events to {args.ledger}") + return 0 + if not args.ticker or not args.as_of: + raise ValueError("--ticker and --as-of are required for status") + packet = build_catalyst_timeline(load_catalyst_events(args.ledger), profile_key=args.profile_key, ticker=args.ticker, as_of=args.as_of) + print(f"Catalyst evidence timeline\nTicker: {packet.ticker}\nState: {packet.state}\nUpcoming: {len(packet.upcoming)}\nRecent: {len(packet.recent)}") + print(packet.boundary) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/commercial_source_rights.py b/src/commercial_source_rights.py new file mode 100644 index 00000000..c7290c33 --- /dev/null +++ b/src/commercial_source_rights.py @@ -0,0 +1,340 @@ +"""Fail-closed source-rights registry for Commercial Research mode.""" + +from __future__ import annotations + +import argparse +import os +from dataclasses import dataclass +from pathlib import Path +from types import MappingProxyType +from typing import Any, Mapping, Sequence + +import yaml + +from src.point_in_time_universe_manifest import _bounded_snapshot +from src.point_in_time_universe_identifiers import is_control_free + + +REQUIRED_FIELDS = ( + "source_id", + "display_name", + "permitted_use", + "commercial_use", + "redistribution", + "storage_limits", + "attribution", + "rate_limits", + "authentication", + "expected_freshness", + "supported_fields", + "fallback_priority", +) +APPROVED_COMMERCIAL_USE = "approved" +COMMERCIAL_RESEARCH_MODE_ENV = "COMMERCIAL_RESEARCH_MODE" +_COMMERCIAL_MODE_ENABLED_VALUES = frozenset({"1", "true", "yes", "on", "commercial"}) +_COMMERCIAL_MODE_DISABLED_VALUES = frozenset({"", "0", "false", "no", "off", "research"}) +DEFAULT_REGISTRY_PATH = Path(__file__).resolve().parent.parent / "config" / "source_rights.yml" +MAX_SOURCE_RIGHTS_REGISTRY_BYTES = 4 * 1024 * 1024 + + +def _normalize_source_id(source_id: object) -> str: + raw_source_id = str(source_id or "") + if not is_control_free(raw_source_id): + raise ValueError("source_id_control_character_invalid") + return raw_source_id.strip() + + +class _UniqueKeySafeLoader(yaml.SafeLoader): + pass + + +def _construct_unique_mapping( + loader: _UniqueKeySafeLoader, + node: yaml.nodes.MappingNode, + deep: bool = False, +) -> dict[Any, Any]: + loader.flatten_mapping(node) + result: dict[Any, Any] = {} + for key_node, value_node in node.value: + key = loader.construct_object(key_node, deep=deep) + try: + duplicate = key in result + except TypeError as exc: + raise yaml.constructor.ConstructorError( + "while constructing a mapping", + node.start_mark, + "found an unhashable key", + key_node.start_mark, + ) from exc + if duplicate: + raise ValueError("source_rights_registry_duplicate_key") + result[key] = loader.construct_object(value_node, deep=deep) + return result + + +_UniqueKeySafeLoader.add_constructor( + yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, + _construct_unique_mapping, +) + + +@dataclass(frozen=True) +class SourceRights: + source_id: str + display_name: str + permitted_use: str + commercial_use: str + redistribution: str + storage_limits: str + attribution: str + rate_limits: str + authentication: str + expected_freshness: str + supported_fields: tuple[str, ...] + fallback_priority: int + + +@dataclass(frozen=True) +class CommercialEligibility: + source_id: str + allowed: bool + status: str + reason: str + + +@dataclass(frozen=True) +class CommercialFieldScopeReview: + source_id: str + rights_status: str + commercial_rights_approved: bool + required_supported_fields: tuple[str, ...] + missing_supported_fields: tuple[str, ...] + commercial_evidence_ready: bool + + +def _missing_fields(record: Mapping[str, Any]) -> list[str]: + missing: list[str] = [] + for field in REQUIRED_FIELDS: + value = record.get(field) + if value is None or value == "" or value == []: + missing.append(field) + return missing + + +def _source_rights_from_record(record: Mapping[str, Any]) -> SourceRights: + missing = _missing_fields(record) + if missing: + raise ValueError(f"source rights record missing required fields: {', '.join(missing)}") + + supported_fields = record["supported_fields"] + if not isinstance(supported_fields, list) or not all(isinstance(field, str) and field.strip() for field in supported_fields): + raise ValueError("source rights record supported_fields must be a non-empty list of strings") + fallback_priority = record["fallback_priority"] + if isinstance(fallback_priority, bool) or not isinstance(fallback_priority, int): + raise ValueError("source rights record fallback_priority must be an integer") + + text_fields = set(REQUIRED_FIELDS) - {"supported_fields", "fallback_priority"} + if any(not isinstance(record[field], str) or not record[field].strip() for field in text_fields): + raise ValueError("source rights record text fields must be non-empty strings") + + return SourceRights( + source_id=_normalize_source_id(record["source_id"]), + display_name=record["display_name"].strip(), + permitted_use=record["permitted_use"].strip(), + commercial_use=record["commercial_use"].strip(), + redistribution=record["redistribution"].strip(), + storage_limits=record["storage_limits"].strip(), + attribution=record["attribution"].strip(), + rate_limits=record["rate_limits"].strip(), + authentication=record["authentication"].strip(), + expected_freshness=record["expected_freshness"].strip(), + supported_fields=tuple(field.strip() for field in supported_fields), + fallback_priority=fallback_priority, + ) + + +def build_source_rights_registry(records: Sequence[Mapping[str, Any]]) -> Mapping[str, SourceRights]: + """Validate source-rights records and return an immutable source-id registry.""" + + registry: dict[str, SourceRights] = {} + for raw_record in records: + if not isinstance(raw_record, Mapping): + raise ValueError("source rights records must be mappings") + record = _source_rights_from_record(raw_record) + if record.source_id in registry: + raise ValueError(f"duplicate source rights record: {record.source_id}") + registry[record.source_id] = record + if not registry: + raise ValueError("source rights registry must contain at least one record") + return MappingProxyType(registry) + + +def parse_source_rights_registry(snapshot: bytes) -> Mapping[str, SourceRights]: + """Build an immutable source-rights registry from one verified byte snapshot.""" + + try: + raw = yaml.load( + snapshot.decode("utf-8"), + Loader=_UniqueKeySafeLoader, + ) or {} + except ( + UnicodeDecodeError, + yaml.YAMLError, + RecursionError, + ) as exc: + raise ValueError("source_rights_registry_unreadable") from exc + if not isinstance(raw, Mapping) or not isinstance(raw.get("sources"), list): + raise ValueError("source rights registry must contain a sources list") + return build_source_rights_registry(raw["sources"]) + + +def load_source_rights_registry(path: Path | str = DEFAULT_REGISTRY_PATH) -> Mapping[str, SourceRights]: + """Load the checked-in registry without reading credentials or license documents.""" + + registry_path = Path(path) + snapshot = _bounded_snapshot( + registry_path, + maximum_bytes=MAX_SOURCE_RIGHTS_REGISTRY_BYTES, + size_error="source_rights_registry_size_limit_exceeded", + unreadable_error="source_rights_registry_unreadable", + _no_follow=True, + ) + return parse_source_rights_registry(snapshot) + + +def commercial_eligibility( + registry: Mapping[str, SourceRights], source_id: str, +) -> CommercialEligibility: + """Return the fail-closed commercial-mode decision for one registered source.""" + + normalized_source_id = _normalize_source_id(source_id) + record = registry.get(normalized_source_id) + if record is None: + return CommercialEligibility( + source_id=normalized_source_id, + allowed=False, + status="unknown_source", + reason="Commercial mode requires a registered source with explicitly approved commercial rights.", + ) + if record.commercial_use != APPROVED_COMMERCIAL_USE: + return CommercialEligibility( + source_id=record.source_id, + allowed=False, + status="commercial_rights_unverified", + reason="Commercial mode refuses sources whose commercial rights are not explicitly approved.", + ) + return CommercialEligibility( + source_id=record.source_id, + allowed=True, + status="approved", + reason="Commercial rights are explicitly approved in the checked-in registry.", + ) + + +def review_commercial_field_scope( + registry: Mapping[str, SourceRights], + source_id: str, + required_fields: Sequence[str], +) -> CommercialFieldScopeReview: + """Review exact-source rights and registered field scope without fetching data.""" + + normalized_source_id = _normalize_source_id(source_id) + normalized_fields = tuple(str(field or "").strip() for field in required_fields) + if any(not field for field in normalized_fields) or len(set(normalized_fields)) != len( + normalized_fields + ): + raise ValueError("required fields must be non-empty unique strings") + + rights = commercial_eligibility(registry, normalized_source_id) + record = registry.get(normalized_source_id) + supported_fields = set(record.supported_fields) if record is not None else set() + missing_fields = tuple( + field for field in normalized_fields if field not in supported_fields + ) + return CommercialFieldScopeReview( + source_id=normalized_source_id, + rights_status=rights.status, + commercial_rights_approved=rights.allowed, + required_supported_fields=normalized_fields, + missing_supported_fields=missing_fields, + commercial_evidence_ready=rights.allowed and not missing_fields, + ) + + +def commercial_mode_enabled(environ: Mapping[str, str] | None = None) -> bool: + """Return whether the explicit commercial-mode environment gate is enabled.""" + + value = (environ or os.environ).get(COMMERCIAL_RESEARCH_MODE_ENV, "").strip().lower() + if value in _COMMERCIAL_MODE_ENABLED_VALUES: + return True + if value in _COMMERCIAL_MODE_DISABLED_VALUES: + return False + raise ValueError( + f"{COMMERCIAL_RESEARCH_MODE_ENV} must be one of " + "1, true, yes, on, commercial, 0, false, no, off, or research." + ) + + +def enforce_commercial_source_rights(source_id: str) -> None: + """Block unapproved sources before provider code executes in explicit commercial mode.""" + + if not commercial_mode_enabled(): + return + + decision = commercial_eligibility(load_source_rights_registry(), source_id) + if not decision.allowed: + raise RuntimeError( + f"{decision.source_id or source_id}: commercial rights are not explicitly approved " + f"for this source ({decision.status})." + ) + + +def render_source_rights_status( + registry: Mapping[str, SourceRights], source_id: str | None = None, +) -> str: + """Render registry status without fetching data, changing configuration, or exposing credentials.""" + + if source_id is not None: + decision = commercial_eligibility(registry, source_id) + return "\n".join( + [ + "Commercial Source Rights", + "Read-only: this command does not fetch data, change rights, or expose credentials.", + f"source_id: {decision.source_id or '-'}", + f"commercial_mode_allowed: {str(decision.allowed).lower()}", + f"status: {decision.status}", + f"reason: {decision.reason}", + ] + ) + + lines = [ + "Commercial Source Rights", + "Read-only: this command does not fetch data, change rights, or expose credentials.", + ] + for record in sorted(registry.values(), key=lambda item: item.fallback_priority): + decision = commercial_eligibility(registry, record.source_id) + lines.append( + f"- {record.source_id}: {decision.status}; commercial_mode_allowed={str(decision.allowed).lower()}" + ) + return "\n".join(lines) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Print the read-only commercial source-rights status.") + parser.add_argument("--config", type=Path, default=DEFAULT_REGISTRY_PATH, help="Source-rights YAML path.") + parser.add_argument("--source", help="Optional source id to evaluate in commercial mode.") + args = parser.parse_args(argv) + try: + registry = load_source_rights_registry(args.config) + rendered = render_source_rights_status( + registry, + args.source, + ) + except ValueError as exc: + parser.error(str(exc)) + print(rendered) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/company_analysis_scope.py b/src/company_analysis_scope.py index 96283b2e..1e2c2f2a 100644 --- a/src/company_analysis_scope.py +++ b/src/company_analysis_scope.py @@ -11,24 +11,34 @@ COMPANY_DCF_EXCLUDED_ASSET_TYPES = {"etf", "index_proxy", "fund"} -COMPANY_DCF_EXCLUDED_TEXT_PATTERNS = ( - re.compile( +COMPANY_DCF_EXCLUSION_PATTERNS = ( + ( + "acquisition_or_spac", + re.compile( r"\b(\w*acquisitions?\b.{0,36}\b(co|corp|corporation|company|inc|limited|ltd)|spac|blank check)\b", re.IGNORECASE, + ), ), - re.compile(r"\bclosed[- ]end fund\b", re.IGNORECASE), - re.compile( - r"\b\w*bank\w*|\b(banc\w*|bankshares|bankholding|bank holding)\b", - re.IGNORECASE, + ("closed_end_fund", re.compile(r"\bclosed[- ]end fund\b", re.IGNORECASE)), + ( + "bank_or_bancorp", + re.compile( + r"\b\w*bank\w*|\b(banc\w*|bankshares|bankholding|bank holding)\b", + re.IGNORECASE, + ), ), - re.compile( - r"\b(financial\b|finance\b|insurance|reinsurance|mortgage)\b", - re.IGNORECASE, + ( + "financial_insurance_or_mortgage", + re.compile( + r"\b(financial\b|finance\b|insurance|reinsurance|mortgage)\b", + re.IGNORECASE, + ), ), - re.compile(r"\b(real estate investment trust|reit)\b", re.IGNORECASE), - re.compile(r"\b(realty trust|business development company)\b", re.IGNORECASE), - re.compile(r"\b(capital corp|capital corporation)\b", re.IGNORECASE), + ("reit", re.compile(r"\b(real estate investment trust|reit)\b", re.IGNORECASE)), + ("realty_trust_or_bdc", re.compile(r"\b(realty trust|business development company)\b", re.IGNORECASE)), + ("capital_corporation", re.compile(r"\b(capital corp|capital corporation)\b", re.IGNORECASE)), ) +COMPANY_DCF_EXCLUDED_TEXT_PATTERNS = tuple(pattern for _, pattern in COMPANY_DCF_EXCLUSION_PATTERNS) def _metadata_value(metadata: Mapping[str, Any] | Any, column: str) -> str: @@ -72,12 +82,24 @@ def _numeric_value(values: Mapping[str, Any] | Any, column: str) -> float | None return None -def excludes_company_dcf(asset_type: object, metadata: Mapping[str, Any] | Any) -> bool: +def company_dcf_exclusion_reasons( + asset_type: object, + metadata: Mapping[str, Any] | Any, + fundamentals: Mapping[str, Any] | Any | None = None, +) -> tuple[str, ...]: normalized_asset_type = str(asset_type or "").strip().lower() + reasons: list[str] = [] if normalized_asset_type in COMPANY_DCF_EXCLUDED_ASSET_TYPES: - return True + reasons.append("non_operating_asset_type") text = " ".join(_metadata_value(metadata, column) for column in ("name", "security_type", "industry")) - return any(pattern.search(text) for pattern in COMPANY_DCF_EXCLUDED_TEXT_PATTERNS) + reasons.extend(reason for reason, pattern in COMPANY_DCF_EXCLUSION_PATTERNS if pattern.search(text)) + if fundamentals is not None and excludes_revenue_margin_dcf(fundamentals): + reasons.append("nonpositive_revenue_margin_model") + return tuple(reasons) + + +def excludes_company_dcf(asset_type: object, metadata: Mapping[str, Any] | Any) -> bool: + return bool(company_dcf_exclusion_reasons(asset_type, metadata)) def excludes_revenue_margin_dcf(fundamentals: Mapping[str, Any] | Any) -> bool: @@ -97,4 +119,4 @@ def excludes_company_dcf_for_inputs( metadata: Mapping[str, Any] | Any, fundamentals: Mapping[str, Any] | Any, ) -> bool: - return excludes_company_dcf(asset_type, metadata) or excludes_revenue_margin_dcf(fundamentals) + return bool(company_dcf_exclusion_reasons(asset_type, metadata, fundamentals)) diff --git a/src/company_workbench_cash_generation_preview.py b/src/company_workbench_cash_generation_preview.py new file mode 100644 index 00000000..59460e0c --- /dev/null +++ b/src/company_workbench_cash_generation_preview.py @@ -0,0 +1,332 @@ +"""Pure Company Workbench composition for non-production cash previews.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from src.earnings_nowcast_contract import parse_utc_timestamp +from src.quarterly_business_trend import build_quarterly_trend_packet +from src.sec_quarterly_cash_generation_pilot import SecQuarterlyPilotPreview + + +@dataclass(frozen=True) +class CashGenerationPreviewMetric: + metric: str + status: str + value: float | None + fiscal_period: str + source_refs: tuple[str, ...] + withheld_reason: str + + +@dataclass(frozen=True) +class CashGenerationPreviewComponent: + metric: str + value: float + currency: str + fiscal_period: str + source_ref: str + published_at: str + retrieved_at: str + accounting_basis: str + duration_basis: str + q4_evidence_state: str + + +@dataclass(frozen=True) +class CompanyWorkbenchCashGenerationPreview: + ticker: str + fiscal_period: str + status: str + message: str + operating_margin: CashGenerationPreviewMetric + free_cash_flow: CashGenerationPreviewMetric + fcf_margin: CashGenerationPreviewMetric + blockers: tuple[str, ...] + withheld_metrics: tuple[str, ...] + accession: str + source_url: str + accepted_at: str + cutoff: str + capex_sign_evidence: str + components: tuple[CashGenerationPreviewComponent, ...] + production_activation: bool = False + readiness_promotions: tuple[str, ...] = () + persistence: bool = False + + +def company_workbench_cash_preview_requested(value: object) -> bool: + """Enable the bounded preview only for the exact opt-in query value.""" + + if isinstance(value, (list, tuple)): + value = value[0] if value else "" + return str(value or "").strip() == "1" + + +def _withheld_metric(metric: str, reason: str) -> CashGenerationPreviewMetric: + return CashGenerationPreviewMetric( + metric=metric, + status="withheld", + value=None, + fiscal_period="", + source_refs=(), + withheld_reason=reason, + ) + + +def blocked_company_workbench_cash_generation_preview( + ticker: str, + *, + fiscal_period: str = "", + as_of: str = "", + blockers: tuple[str, ...] = (), + accession: str = "", + source_url: str = "", + accepted_at: str = "", + capex_sign_evidence: str = "", +) -> CompanyWorkbenchCashGenerationPreview: + """Return an all-or-nothing withheld preview with no numeric leakage.""" + + stable_blockers = tuple(dict.fromkeys(str(item) for item in blockers if str(item))) + reason = "; ".join(stable_blockers) or "cash_generation_preview_unavailable" + return CompanyWorkbenchCashGenerationPreview( + ticker=str(ticker or "").strip().upper(), + fiscal_period=str(fiscal_period or "").strip().upper(), + status="withheld", + message="Cash-generation review preview is withheld until complete evidence passes.", + operating_margin=_withheld_metric("operating_margin", reason), + free_cash_flow=_withheld_metric("free_cash_flow", reason), + fcf_margin=_withheld_metric("fcf_margin", reason), + blockers=stable_blockers, + withheld_metrics=("operating_margin", "free_cash_flow", "fcf_margin"), + accession=str(accession or "").strip(), + source_url=str(source_url or "").strip(), + accepted_at=str(accepted_at or "").strip(), + cutoff=str(as_of or "").strip(), + capex_sign_evidence=str(capex_sign_evidence or "").strip(), + components=(), + ) + + +def _metric(metric: str, trend) -> CashGenerationPreviewMetric: + return CashGenerationPreviewMetric( + metric=metric, + status="preview_available", + value=trend.latest_value, + fiscal_period=trend.latest_fiscal_period, + source_refs=tuple( + source_ref + for source_ref in str(trend.latest_source_ref or "").split(";") + if source_ref + ), + withheld_reason=trend.withheld_reason, + ) + + +def compose_company_workbench_cash_generation_preview( + pilot: SecQuarterlyPilotPreview, + *, + selected_ticker: str, + as_of: str, +) -> CompanyWorkbenchCashGenerationPreview: + """Compose one accepted pilot result without persistence or activation.""" + + extraction = pilot.extraction + try: + cutoff = parse_utc_timestamp( + as_of, + label="cash preview cutoff", + ).isoformat() + except ValueError: + return blocked_company_workbench_cash_generation_preview( + extraction.ticker, + fiscal_period=extraction.fiscal_period, + as_of=str(as_of or ""), + blockers=("cutoff_invalid",), + accession=extraction.accession, + source_url=extraction.source_url, + accepted_at=extraction.accepted_at, + capex_sign_evidence=extraction.capex_sign_evidence, + ) + if pilot.status != "accepted_for_review": + return blocked_company_workbench_cash_generation_preview( + extraction.ticker, + fiscal_period=extraction.fiscal_period, + as_of=cutoff, + blockers=(f"pilot_status:{pilot.status}", *pilot.blockers), + accession=extraction.accession, + source_url=extraction.source_url, + accepted_at=extraction.accepted_at, + capex_sign_evidence=extraction.capex_sign_evidence, + ) + blockers: list[str] = [] + acceptance = pilot.acceptance + selected_symbol = str(selected_ticker or "").strip().upper() + if acceptance is None: + blockers.append("adapter_acceptance_required") + else: + if acceptance.status != "accepted_for_review": + blockers.append(f"adapter_status:{acceptance.status}") + blockers.extend(f"adapter_blocker:{item}" for item in acceptance.blockers) + if acceptance.source_id != "sec_companyfacts": + blockers.append(f"adapter_source:{acceptance.source_id}") + if acceptance.rights_status != "approved": + blockers.append(f"source_rights:{acceptance.rights_status}") + if not { + "operating_income", + "cash_from_operations", + "capital_expenditures", + }.issubset(acceptance.reviewed_metrics): + blockers.append("adapter_reviewed_metrics_incomplete") + if acceptance.derived_point_count < 3: + blockers.append("adapter_derived_points_incomplete") + if acceptance.ticker != extraction.ticker: + blockers.append(f"adapter_ticker:{acceptance.ticker}") + if acceptance.accepted_observation_count != len(extraction.observations): + blockers.append("accepted_observation_count_mismatch") + if acceptance.production_activation: + blockers.append("production_activation_forbidden") + if acceptance.readiness_promotions: + blockers.append("readiness_promotions_forbidden") + if pilot.production_activation: + blockers.append("production_activation_forbidden") + if pilot.readiness_promotions: + blockers.append("readiness_promotions_forbidden") + blockers.extend(f"pilot_blocker:{item}" for item in pilot.blockers) + if selected_symbol != extraction.ticker: + blockers.append(f"ticker_mismatch:{extraction.ticker}") + blockers.extend(f"sec_extraction:{item}" for item in extraction.blockers) + if not str(extraction.accession or "").strip(): + blockers.append("accession_required") + if not str(extraction.source_url or "").strip(): + blockers.append("source_url_required") + if extraction.capex_sign_evidence != "explicit_filed_table_outflow": + blockers.append("capex_sign_evidence_required") + try: + accepted_at = parse_utc_timestamp( + extraction.accepted_at, + label="cash preview accepted_at", + ) + except ValueError: + blockers.append("accepted_at_invalid") + else: + if accepted_at > parse_utc_timestamp(cutoff): + blockers.append("accepted_after_cutoff") + if not extraction.observations: + blockers.append("observations_required") + if not extraction.revenue_actuals: + blockers.append("revenue_actual_required") + for row in extraction.observations: + if row.ticker != extraction.ticker: + blockers.append(f"observation_ticker:{row.ticker}") + if row.source != "sec_companyfacts": + blockers.append(f"observation_source:{row.source}") + if parse_utc_timestamp(row.published_at) > parse_utc_timestamp(cutoff): + blockers.append(f"observation_after_cutoff:{row.metric}") + if row.metric == "capital_expenditures" and row.value >= 0: + blockers.append("capital_expenditures_outflow_required") + for row in extraction.revenue_actuals: + if row.ticker != extraction.ticker: + blockers.append(f"revenue_ticker:{row.ticker}") + if row.source != "sec_companyfacts": + blockers.append(f"revenue_source:{row.source}") + if parse_utc_timestamp(row.reported_at) > parse_utc_timestamp(cutoff): + blockers.append("revenue_after_cutoff") + if ( + extraction.fiscal_period.endswith("-Q4") + and ( + acceptance is None + or extraction.fiscal_period not in acceptance.explicit_q4_periods + ) + ): + blockers.append("explicit_q4_evidence_required") + if blockers: + return blocked_company_workbench_cash_generation_preview( + extraction.ticker, + fiscal_period=extraction.fiscal_period, + as_of=cutoff, + blockers=tuple(blockers), + accession=extraction.accession, + source_url=extraction.source_url, + accepted_at=extraction.accepted_at, + capex_sign_evidence=extraction.capex_sign_evidence, + ) + trend = build_quarterly_trend_packet( + selected_ticker, + extraction.revenue_actuals, + as_of=cutoff, + business_observations=extraction.observations, + ) + metrics = ( + _metric("operating_margin", trend.operating_margin), + _metric("free_cash_flow", trend.free_cash_flow), + _metric("fcf_margin", trend.fcf_margin), + ) + if any( + metric.value is None or metric.fiscal_period != extraction.fiscal_period + for metric in metrics + ): + return blocked_company_workbench_cash_generation_preview( + extraction.ticker, + fiscal_period=extraction.fiscal_period, + as_of=cutoff, + blockers=("complete_cash_generation_preview_required",), + accession=extraction.accession, + source_url=extraction.source_url, + accepted_at=extraction.accepted_at, + capex_sign_evidence=extraction.capex_sign_evidence, + ) + revenue = extraction.revenue_actuals[0] + components = ( + CashGenerationPreviewComponent( + metric="revenue", + value=float(revenue.revenue_actual), + currency=revenue.revenue_currency, + fiscal_period=revenue.fiscal_period, + source_ref=revenue.source_ref, + published_at=revenue.reported_at, + retrieved_at=revenue.retrieved_at, + accounting_basis=revenue.revenue_basis, + duration_basis="three_months", + q4_evidence_state=( + "explicit_filed_quarter" + if revenue.fiscal_period.endswith("-Q4") + else "not_q4" + ), + ), + *( + CashGenerationPreviewComponent( + metric=row.metric, + value=row.value, + currency=row.currency, + fiscal_period=row.fiscal_period, + source_ref=row.source_ref, + published_at=row.published_at, + retrieved_at=row.retrieved_at, + accounting_basis=row.accounting_basis, + duration_basis=row.duration_basis, + q4_evidence_state=row.q4_evidence_state, + ) + for row in extraction.observations + ), + ) + return CompanyWorkbenchCashGenerationPreview( + ticker=extraction.ticker, + fiscal_period=extraction.fiscal_period, + status="accepted_for_review", + message=( + "Accepted SEC evidence supports a cash-generation review preview. " + "Production evidence and readiness remain unchanged." + ), + operating_margin=metrics[0], + free_cash_flow=metrics[1], + fcf_margin=metrics[2], + blockers=(), + withheld_metrics=(), + accession=extraction.accession, + source_url=extraction.source_url, + accepted_at=extraction.accepted_at, + cutoff=cutoff, + capex_sign_evidence=extraction.capex_sign_evidence, + components=components, + ) diff --git a/src/company_workbench_cash_generation_preview_loader.py b/src/company_workbench_cash_generation_preview_loader.py new file mode 100644 index 00000000..8ada4a77 --- /dev/null +++ b/src/company_workbench_cash_generation_preview_loader.py @@ -0,0 +1,128 @@ +"""Bounded live loader for explicit reviewed Workbench preview filings.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +from types import MappingProxyType +from typing import Mapping + +from src.commercial_source_rights import load_source_rights_registry +from src.company_workbench_cash_generation_preview import ( + CompanyWorkbenchCashGenerationPreview, + blocked_company_workbench_cash_generation_preview, + compose_company_workbench_cash_generation_preview, +) +from src.providers.sec_companyfacts import SECUserAgentError +from src.sec_quarterly_cash_generation_pilot import ( + extract_sec_quarterly_cash_generation, + preview_sec_quarterly_cash_generation, +) +from src.sec_quarterly_cash_generation_preview import ( + SecQuarterlyPreviewFetchError, + fetch_sec_quarterly_pilot_payloads, +) + + +@dataclass(frozen=True) +class CashGenerationPreviewFiling: + ticker: str + cik: str + fiscal_period: str + period_start: str + period_end: str + accession: str + primary_document: str + as_of: str + + +CASH_GENERATION_PREVIEW_FILINGS: Mapping[str, CashGenerationPreviewFiling] = ( + MappingProxyType({ + "NVDA": CashGenerationPreviewFiling( + ticker="NVDA", + cik="0001045810", + fiscal_period="2027-Q1", + period_start="2026-01-26", + period_end="2026-04-26", + accession="0001045810-26-000052", + primary_document="nvda-20260426.htm", + as_of="2026-07-20T23:59:59-04:00", + ), + "AMD": CashGenerationPreviewFiling( + ticker="AMD", + cik="0000002488", + fiscal_period="2026-Q1", + period_start="2025-12-28", + period_end="2026-03-28", + accession="0000002488-26-000076", + primary_document="amd-20260328.htm", + as_of="2026-07-20T23:59:59-04:00", + ), + }) +) + + +def load_company_workbench_cash_generation_preview( + ticker: str, + *, + user_agent: str | None = None, + fetcher=None, + retrieved_at: str | None = None, +) -> CompanyWorkbenchCashGenerationPreview: + """Load one exact filing in memory or return a fully withheld preview.""" + + symbol = str(ticker or "").strip().upper() + filing = CASH_GENERATION_PREVIEW_FILINGS.get(symbol) + if filing is None: + return blocked_company_workbench_cash_generation_preview( + symbol, + blockers=(f"unsupported_preview_ticker:{symbol or 'missing'}",), + ) + try: + payloads = fetch_sec_quarterly_pilot_payloads( + cik=filing.cik, + accession=filing.accession, + primary_document=filing.primary_document, + user_agent=user_agent, + fetcher=fetcher, + ) + extraction = extract_sec_quarterly_cash_generation( + ticker=filing.ticker, + cik=filing.cik, + fiscal_period=filing.fiscal_period, + period_start_date=filing.period_start, + period_end_date=filing.period_end, + accession=filing.accession, + primary_document=filing.primary_document, + companyfacts_payload=payloads["companyfacts"], + submissions_payload=payloads["submissions"], + filing_html=payloads["filing_html"], + retrieved_at=( + retrieved_at + or datetime.now(timezone.utc).isoformat() + ), + as_of=filing.as_of, + ) + pilot = preview_sec_quarterly_cash_generation( + extraction=extraction, + rights_registry=load_source_rights_registry(), + as_of=filing.as_of, + ) + return compose_company_workbench_cash_generation_preview( + pilot, + selected_ticker=symbol, + as_of=filing.as_of, + ) + except ( + SECUserAgentError, + SecQuarterlyPreviewFetchError, + TypeError, + ValueError, + ) as exc: + return blocked_company_workbench_cash_generation_preview( + symbol, + fiscal_period=filing.fiscal_period, + as_of=filing.as_of, + blockers=(f"preview_load_blocked:{type(exc).__name__}",), + accession=filing.accession, + ) diff --git a/src/continuation_gate.py b/src/continuation_gate.py new file mode 100644 index 00000000..791daa32 --- /dev/null +++ b/src/continuation_gate.py @@ -0,0 +1,48 @@ +"""Fail-closed continuation routing for selected-profile readiness.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from src.profile_context import ProfileContext + + +READINESS_PREVIEW_COMMAND = "make readiness-preview TOP_N=20" + + +@dataclass(frozen=True) +class ContinuationGate: + state: str + next_safe_command: str + reason: str + rebuild_command: str + stop_rule: str + suppress_execution: bool + + +def build_continuation_gate(context: ProfileContext) -> ContinuationGate: + """Return the one safe continuation action for the selected profile.""" + + rebuild_command = context.refresh_command or "make readiness" + if context.freshness_state == "current": + return ContinuationGate( + state="current", + next_safe_command="", + reason=context.freshness_message, + rebuild_command=rebuild_command, + stop_rule="", + suppress_execution=False, + ) + + state = "inspection_only" if context.freshness_state == "stale" else "inspection_required" + return ContinuationGate( + state=state, + next_safe_command=READINESS_PREVIEW_COMMAND, + reason=context.freshness_message, + rebuild_command=rebuild_command, + stop_rule=( + "Do not start broad refresh, source-proof, apply, or readiness-rebuild work from stale or " + "incomplete readiness counts." + ), + suppress_execution=True, + ) diff --git a/src/dashboard.py b/src/dashboard.py index 58f902cb..9cb1cd89 100644 --- a/src/dashboard.py +++ b/src/dashboard.py @@ -16,6 +16,7 @@ from src.artifact_freshness import generated_artifact_stale_warning from src.action_queue import write_action_queue_output from src.auto_refresh_orchestrator import build_auto_refresh_status_payload, build_scheduler_plan +from src.continuation_gate import build_continuation_gate from src.source_activation_guide import build_provider_setup_checklist, build_source_activation_guide from src.data_onboarding import write_onboarding_outputs from src.data_health_console import ( @@ -129,20 +130,27 @@ PUBLIC_DEMO_MODE, PUBLIC_PATH_LABELS, PUBLIC_PATH_PAGE_TITLES, + RESEARCH_MODE, + RESEARCH_PATH_PAGE_TITLES, STOCK_SELECTOR_PATH_TITLE, + LEGACY_RESEARCH_UTILITY_PAGES, advanced_page_titles as _advanced_page_titles, dashboard_mode_from_query as _dashboard_mode_from_query, dashboard_mode_label, dashboard_page_from_query as _dashboard_page_from_query, dashboard_page_slug, + legacy_research_utility_label, page_title_from_public_path, public_path_label, public_workflow_position, public_workflow_step, route_rail_query_update, + research_path_label, + research_path_options, selected_page_from_route_rail, sidebar_path_index as _sidebar_path_index, sidebar_path_options as _sidebar_path_options, + workspace_page_for_mode, ) from src.data_health_proof_checklist import ( proof_checklist_summary_cards as data_health_proof_checklist_summary_cards, @@ -198,6 +206,21 @@ from src.dcf_input_proof_queue import build_dcf_input_proof_handoff, build_dcf_input_proof_queue_from_files from src.dcf_input_proof_queue import build_dcf_input_source_review_rows from src.earnings_nowcast_ui import nowcast_data_health_card, nowcast_summary_cards +from src.earnings_nowcast_report import build_nowcast_packet +from src.earnings_nowcast_cohort import build_cohort_readiness, cohort_readiness_cards +from src.research_outcome_review import derive_outcome_status, load_outcomes, outcome_status_cards +from src.research_record_authoring import AuthoringPaths +from src.research_record_authoring_ui import render_research_record_authoring +from src.catalyst_evidence_timeline import ( + build_catalyst_timeline, + catalyst_timeline_cards, + load_catalyst_events, +) +from src.historical_valuation_regime import ( + build_valuation_regime, + load_valuation_observations, + valuation_regime_cards, +) from src.monthly_picks import build_monthly_research_picks from src.monthly_picks import MonthlyPickConfig from src.providers.local_data_catalog import LocalDataCatalog @@ -267,11 +290,36 @@ derive_journal_state, load_journal_entries, ) +from src.focused_research_cohort import FocusedCohort, build_focused_cohort, focused_cohort_frame +from src.focused_cohort_coverage import ( + FocusedCohortCoverage, + build_focused_cohort_coverage, + derive_cohort_evidence, + focused_cohort_coverage_frame, +) +from src.forward_view import build_forward_view, forward_view_cards, forward_view_rows +from src.quarterly_business_trend import ( + QuarterlyTrendPacket, + build_quarterly_trend_packet_from_load, + build_quarterly_trend_packet, + load_quarterly_actuals_csv, + quarterly_trend_rows, +) +from src.weekly_research_summary import WeeklyResearchSummary, build_weekly_research_summary, weekly_summary_rows from src.decision_process_scorecard import ( DecisionProcessScorecard, build_decision_process_scorecard, decision_process_rows, ) +from src.research_decision_lab import ( + ResearchDisciplineRow, + build_research_discipline_rows, + build_research_decision_lab_state, + decision_lab_cards, + decision_lab_rows, + research_discipline_rows, + unavailable_research_decision_lab_state, +) from src.scenario_lab import ( ScenarioLabResult, ScenarioParameters, @@ -289,6 +337,10 @@ latest_reviewed_batch_proof, load_reviewed_batch_proofs, ) +from src.proof_readiness_reconciliation import ( + ProofReadinessReconciliationSummary, + load_proof_readiness_reconciliation, +) from src.peer_mapping_source_review import ( PeerMappingSourceReviewPacket, build_peer_mapping_source_review_packet, @@ -302,6 +354,31 @@ from src.risk_context_workflow import data_health_risk_context_cards, split_risk_context_by_price_ready from src.project_status import PROJECT_STATUS_NEXT_STEPS_CSV, build_project_status_payload from src.profile_context import ProfileContext, build_profile_context +from src.company_workbench_cash_generation_preview import ( + CompanyWorkbenchCashGenerationPreview, + company_workbench_cash_preview_requested, +) +from src.company_workbench_cash_generation_preview_loader import ( + load_company_workbench_cash_generation_preview, +) +from src.research_workspace import ( + advanced_evidence_links_html, + cash_generation_preview_cards, + cash_generation_preview_rows, + company_change_answer, + company_next_research_task, + company_workbench_section_contract, + focused_cohort_cards, + focused_cohort_coverage_cards, + focused_ticker_coverage_cards, + quarterly_trend_cards, + research_desk_cards, + research_desk_cards_html, + research_evidence_return_link, + research_monitor_frame, + research_workspace_header_html, + weekly_summary_cards, +) from src.purpose_evaluation import PURPOSE_EVALUATION_SUMMARY_CSV, build_purpose_evaluation_drilldown from src.paths import resolve_data_dir, resolve_data_profile, resolve_outputs_dir from src.stock_report import DCF_INPUT_TRIAGE, build_provider, build_stock_report, export_stock_report_json @@ -352,8 +429,16 @@ "Data Health", "Universe Manager", ] -USER_PAGE_TITLES = ["Home", STOCK_SELECTOR_PATH_TITLE] + DASHBOARD_TAB_TITLES -ADVANCED_PAGE_TITLES = _advanced_page_titles(USER_PAGE_TITLES) +USER_PAGE_TITLES = ["Home", STOCK_SELECTOR_PATH_TITLE] + RESEARCH_PATH_PAGE_TITLES + DASHBOARD_TAB_TITLES +ADVANCED_PAGE_TITLES = [ + title for title in _advanced_page_titles(USER_PAGE_TITLES) if title not in RESEARCH_PATH_PAGE_TITLES +] +RESEARCH_PAGE_RENDER_TARGETS = { + "Research Desk": "Research Desk", + "Discover": STOCK_SELECTOR_PATH_TITLE, + "Company Workbench": "Single-Stock Report", + "Monitor": "Monitor", +} DATA_SOURCE_FILES = { "data_source_status.csv": "Data Source Status", "data_gap_report.csv": "Data Gap Report", @@ -375,6 +460,33 @@ def dashboard_mode_from_query(value: object, initial_page: str = "Home") -> str: return _dashboard_mode_from_query(value, initial_page, ADVANCED_PAGE_TITLES) +def workspace_default_page(initial_page: str, *, mode: str, has_explicit_page_query: bool) -> str: + initial_page = workspace_page_for_mode(initial_page, mode) + if has_explicit_page_query: + return initial_page + if mode == RESEARCH_MODE and initial_page == "Home": + return "Research Desk" + return initial_page + + +def workspace_path_options(initial_page: str, mode: str) -> list[str]: + if mode == RESEARCH_MODE: + return research_path_options(initial_page) + return sidebar_path_options(initial_page) + + +def workspace_path_label(page_title: str, mode: str) -> str: + if mode == RESEARCH_MODE: + return research_path_label(page_title) + return public_path_label(page_title) + + +def workspace_content_page(selected_page: str, mode: str) -> str: + if mode == RESEARCH_MODE: + return RESEARCH_PAGE_RENDER_TARGETS.get(selected_page, selected_page) + return selected_page + + def dashboard_generated_artifact_stale_warning(root: Path = BASE_DIR) -> str: data_path = root / "data" output_path = root / "outputs" @@ -2018,6 +2130,11 @@ def apply_dashboard_theme() -> None: outline-offset: 3px !important; box-shadow: 0 0 0 5px rgba(34, 197, 94, 0.20) !important; } + [data-testid="stTooltipHoverTarget"] button, + [data-testid="stDataFrame"] button { + min-width: 24px !important; + min-height: 24px !important; + } .public-skip-link { position: absolute; top: 0.55rem; @@ -2868,6 +2985,14 @@ def apply_dashboard_theme() -> None: [data-testid="stMarkdownContainer"] p { color: var(--research-muted); } + [data-testid="stLinkButton"] a[kind="primary"] { + background: #0b3b36 !important; + border-color: #0b3b36 !important; + color: #ffffff !important; + } + [data-testid="stLinkButton"] a[kind="primary"] * { + color: #ffffff !important; + } .block-container { padding-top: 1.25rem; max-width: 1380px; @@ -5194,12 +5319,24 @@ def profile_advanced_details(context: ProfileContext) -> dict[str, object]: } -def render_profile_trust_strip(context: ProfileContext, *, compact: bool = False) -> None: - st.markdown(profile_trust_strip_html(context, compact=compact), unsafe_allow_html=True) +def render_profile_trust_details(context: ProfileContext) -> None: + """Keep technical profile evidence after the page's primary answer.""" + with st.expander("Advanced: profile identity and freshness evidence", expanded=False): st.json(profile_advanced_details(context)) +def render_profile_trust_strip( + context: ProfileContext, + *, + compact: bool = False, + include_advanced: bool = True, +) -> None: + st.markdown(profile_trust_strip_html(context, compact=compact), unsafe_allow_html=True) + if include_advanced: + render_profile_trust_details(context) + + def research_change_home_summary( queue: list[ResearchReviewItem] | tuple[ResearchReviewItem, ...], *, @@ -5354,6 +5491,246 @@ def load_dashboard_research_change_state(context: ProfileContext) -> dict[str, o } +def load_dashboard_focused_cohort(context: ProfileContext) -> FocusedCohort: + """Build the personal review cohort from saved readiness evidence only.""" + + try: + readiness = pd.read_csv(DATA_DIR / "reports" / "ticker_readiness_report.csv") + universe = pd.read_csv(DATA_DIR / "universe_master.csv") + except (FileNotFoundError, OSError, UnicodeError, pd.errors.ParserError): + readiness = pd.DataFrame() + universe = pd.DataFrame() + return build_focused_cohort( + readiness, + universe, + target_size=25, + minimum_size=25, + profile_freshness=context.freshness_state, + ) + + +def load_dashboard_focused_cohort_coverage(cohort: FocusedCohort) -> FocusedCohortCoverage: + """Compose saved cohort lane evidence without refreshing or repairing data.""" + + def optional_csv(path: Path) -> pd.DataFrame: + try: + return pd.read_csv(path) + except (FileNotFoundError, OSError, UnicodeError, pd.errors.ParserError): + return pd.DataFrame() + + readiness = optional_csv(DATA_DIR / "reports" / "ticker_readiness_report.csv") + universe = optional_csv(DATA_DIR / "universe_master.csv") + fundamentals = optional_csv(DATA_DIR / "fundamentals.csv") + price_path = DATA_DIR / "prices.csv" + try: + price_columns = tuple(pd.read_csv(price_path, nrows=0).columns) + except (FileNotFoundError, OSError, UnicodeError, pd.errors.ParserError): + price_columns = () + price_lineage_missing_fields = tuple( + field + for field in ("source", "source_ref", "retrieved_at") + if field not in price_columns + ) + prices = ( + pd.DataFrame(columns=price_columns) + if price_columns and price_lineage_missing_fields + else optional_csv(price_path) + ) + consensus = optional_csv(DATA_DIR / "earnings_nowcast" / "consensus_snapshots.csv") + earnings = optional_csv(DATA_DIR / "earnings.csv") + peers = optional_csv(DATA_DIR / "peers.csv") + peer_candidates = optional_csv(DATA_DIR / "peer_candidates.csv") + actuals = load_quarterly_actuals_csv(DATA_DIR / "earnings_nowcast" / "quarterly_actuals.csv") + tickers = tuple(member.ticker for member in cohort.members) + evidence = derive_cohort_evidence( + tickers, + fundamentals=fundamentals, + prices=prices, + price_lineage_missing_fields=price_lineage_missing_fields, + readiness=readiness, + universe=universe, + consensus=consensus, + earnings=earnings, + peers=peers, + peer_candidates=peer_candidates, + quarterly_actuals=actuals.actuals if not actuals.rejected_count else None, + as_of=pd.Timestamp.now(tz="UTC").isoformat(), + commercial_mode=True, + ) + packets = { + ticker: build_quarterly_trend_packet_from_load(ticker, actuals) + for ticker in tickers + } + return build_focused_cohort_coverage( + cohort, + readiness, + quarterly_packets=packets, + evidence_by_ticker=evidence, + ) + + +def load_dashboard_quarterly_trend(ticker: str) -> QuarterlyTrendPacket: + """Load real canonical quarterly actuals; absent evidence fails closed.""" + + loaded = load_quarterly_actuals_csv(DATA_DIR / "earnings_nowcast" / "quarterly_actuals.csv") + return build_quarterly_trend_packet_from_load( + ticker, + loaded, + as_of=pd.Timestamp.now(tz="UTC").isoformat(), + ) + + +def load_dashboard_nowcast_packet( + report_payload: dict[str, object], + *, + ticker: str, +) -> dict[str, object] | None: + """Load one exact-period real-company nowcast packet or fail closed.""" + + earnings = report_payload.get("earnings_summary") + fiscal_period = ( + str(earnings.get("fiscal_period") or "").strip().upper() + if isinstance(earnings, dict) + else "" + ) + as_of = str(report_payload.get("generated_at") or "").strip() + if not fiscal_period or not as_of: + return None + try: + packet = build_nowcast_packet( + DATA_DIR / "earnings_nowcast", + ticker=ticker, + fiscal_period=fiscal_period, + as_of_timestamp=as_of, + ) + except (FileNotFoundError, KeyError, TypeError, ValueError): + return None + if packet.get("evidence_scope") != "source_backed_preview_only": + return None + return packet + + +def load_dashboard_weekly_summary( + context: ProfileContext, + cohort: FocusedCohort, + review_items, +) -> WeeklyResearchSummary: + """Compose a traceable weekly queue from reviewed events and journal dates.""" + + as_of = pd.Timestamp.now(tz="UTC").isoformat() + try: + entries = load_journal_entries(DATA_DIR / "research_thesis_journal.csv") + except ValueError: + entries = () + journal_rows: list[dict[str, object]] = [] + for member in cohort.members: + try: + state = derive_journal_state( + entries, + profile_key=context.profile_key, + ticker=member.ticker, + as_of=as_of, + ) + except ValueError: + continue + source_ref = "" + if state.invalidation_conditions: + source_ref = state.invalidation_conditions[-1].source_ref + journal_rows.append( + { + "ticker": member.ticker, + "review_due_date": state.review_due_date, + "source_ref": source_ref or f"journal:{context.profile_key}:{member.ticker}", + "invalidation_triggered": False, + "invalidation_condition": "", + } + ) + return build_weekly_research_summary( + cohort, + review_items, + journal_rows=journal_rows, + as_of=as_of, + ) + + +def load_dashboard_research_discipline_rows( + context: ProfileContext, + cohort: FocusedCohort, + review_items, + *, + as_of: str | None = None, +) -> tuple[ResearchDisciplineRow, ...]: + """Compose saved per-ticker process states without fetching or building reports.""" + + selected_as_of = as_of or pd.Timestamp.now(tz="UTC").isoformat() + ledger_error = "" + try: + journal_entries = load_journal_entries(DATA_DIR / "research_thesis_journal.csv") + except (OSError, UnicodeError, ValueError) as exc: + journal_entries = () + ledger_error = f"Research thesis journal could not be verified: {exc}" + try: + outcomes = load_outcomes(DATA_DIR / "research_outcome_reviews.csv") + except (OSError, UnicodeError, ValueError) as exc: + outcomes = () + ledger_error = ledger_error or f"Research outcome ledger could not be verified: {exc}" + + saved_review_items = tuple(review_items) + states_by_ticker = {} + for member in cohort.members: + ticker = member.ticker.upper() + if ledger_error: + states_by_ticker[ticker] = unavailable_research_decision_lab_state( + profile_key=context.profile_key, + ticker=ticker, + reason=ledger_error, + ) + continue + try: + journal_state = derive_journal_state( + journal_entries, + profile_key=context.profile_key, + ticker=ticker, + as_of=selected_as_of, + ) + outcome_status = derive_outcome_status( + outcomes, + profile_key=context.profile_key, + ticker=ticker, + commercial_mode=True, + ) + report_context = { + "ticker": ticker, + "asset_type": "company", + "valuation_readiness": {"dcf_ready": "dcf" in member.usable_lanes}, + "valuation_snapshot": {}, + } + scorecard = build_decision_process_scorecard( + report_context, + profile_key=context.profile_key, + journal_state=journal_state, + review_items=saved_review_items, + ) + states_by_ticker[ticker] = build_research_decision_lab_state( + profile_key=context.profile_key, + journal_state=journal_state, + scorecard=scorecard, + outcome_status=outcome_status, + review_items=saved_review_items, + ) + except (KeyError, TypeError, ValueError) as exc: + states_by_ticker[ticker] = unavailable_research_decision_lab_state( + profile_key=context.profile_key, + ticker=ticker, + reason=str(exc), + ) + + return build_research_discipline_rows( + states_by_ticker, + focused_tickers=(member.ticker for member in cohort.members), + ) + + def render_research_change_route_summary( page_title: str, state: dict[str, object], @@ -5522,6 +5899,63 @@ def load_dashboard_journal_state( ) +def load_dashboard_nowcast_cohort(as_of: str | None = None): + """Load the five-company readiness board without fetching or applying data.""" + + return build_cohort_readiness( + DATA_DIR / "earnings_nowcast", + tickers=("NVDA", "AMD", "AVGO", "MU", "QCOM"), + as_of=as_of or pd.Timestamp.now(tz="UTC").isoformat(), + ) + + +def load_dashboard_valuation_regime(ticker: str, *, as_of: str | None = None): + """Load aligned point-in-time valuation observations or fail closed.""" + + try: + observations = load_valuation_observations(DATA_DIR / "historical_valuation_observations.csv") + except (OSError, UnicodeError, ValueError): + observations = () + return build_valuation_regime( + observations, + ticker=ticker, + metric="price_to_fcf_per_share", + as_of=as_of or pd.Timestamp.now(tz="UTC").isoformat(), + commercial_mode=True, + ) + + +def load_dashboard_outcome_status(context: ProfileContext, *, ticker: str): + """Load selected-profile outcome learning without grading performance.""" + + try: + outcomes = load_outcomes(DATA_DIR / "research_outcome_reviews.csv") + except (OSError, UnicodeError, ValueError): + outcomes = () + return derive_outcome_status( + outcomes, + profile_key=context.profile_key, + ticker=ticker, + commercial_mode=True, + ) + + +def load_dashboard_catalyst_timeline(context: ProfileContext, *, ticker: str, as_of: str | None = None): + """Load cutoff-safe reviewed catalyst context without provider calls.""" + + try: + events = load_catalyst_events(DATA_DIR / "catalyst_evidence.csv") + except (OSError, UnicodeError, ValueError): + events = () + return build_catalyst_timeline( + events, + profile_key=context.profile_key, + ticker=ticker, + as_of=as_of or pd.Timestamp.now(tz="UTC").isoformat(), + commercial_mode=True, + ) + + def scenario_lab_input_from_report(report_payload: dict[str, object]) -> ValuationInput: """Build a scenario input from the selected report without inventing fields.""" @@ -5665,7 +6099,9 @@ def peer_read_through_summary_cards(read_through: PeerReadThroughMap) -> list[di else f"{read_through.reviewable_count} peer results ready for contextual review" ) body = ( - f"{read_through.withheld_count} relationship(s) still need proof. Reviewable context does not change " + f"{read_through.valuation_anchor_count} valuation anchor eligible; " + f"{read_through.comparability_withheld_count} trusted relationship(s) still need role or comparability proof. " + "Reviewable context does not change " "a forecast, valuation, readiness state, or action." ) badges = ["Context only", f"{read_through.trusted_count} trusted"] @@ -5685,6 +6121,9 @@ def peer_read_through_frame(read_through: PeerReadThroughMap) -> pd.DataFrame: columns=[ "Peer", "Relationship", + "Peer Role", + "Comparability", + "Valuation Anchor", "Business Overlap", "Fiscal Timing", "Peer Result", @@ -5699,7 +6138,7 @@ def render_peer_read_through_map(report_payload: dict[str, object], *, profile_k st.markdown("#### Peer Read-Through Map") render_context_note( "Which peer results can be reviewed as context?", - "Trusted relationships, source-backed actual results, and explicit fiscal periods are checked separately. Candidate peers never become trusted automatically.", + "Relationship provenance, peer role, economic comparability, valuation-anchor eligibility, source-backed actual results, and fiscal periods are checked separately. Candidate peers never become trusted automatically.", ) render_signal_cards(peer_read_through_summary_cards(read_through), show_commands=False) frame = peer_read_through_frame(read_through) @@ -6416,8 +6855,11 @@ def render_public_shell_mode_styles() -> None: } .public-ticker-summary { grid-template-columns: 1fr; - gap: 0.85rem; - padding: 0.9rem 0; + gap: 0.25rem; + padding: 0.125rem 0 0.5rem; + } + .public-ticker-action .public-primary-action { + order: -1; } .public-ticker-name strong { font-size: 1.3rem; } } @@ -6548,36 +6990,15 @@ def public_workflow_skip_href( *, mode: str = PUBLIC_DEMO_MODE, ) -> str: - skip_mode = OPERATOR_DEMO_MODE if mode == OPERATOR_DEMO_MODE else PUBLIC_DEMO_MODE - params: list[tuple[str, str]] = [("mode", skip_mode)] - if page_title != "Home": - params.append(("page", dashboard_page_slug(page_title))) - - getter = getattr(query_params, "get", None) - if callable(getter): - for key in ( - "ticker", - "open", - "lane", - "drawer", - "queue_details", - "batch_details", - "metric_details", - "proof_details", - ): - value = getter(key) - if isinstance(value, (list, tuple)): - value = value[-1] if value else "" - text = str(value or "").strip() - if text: - params.append((key, text)) - return "?" + urlencode(params) + "#public-page-answer" + # A fragment-only destination preserves every current route parameter and + # avoids a Streamlit rerun that would reset keyboard focus to the document. + return "#public-page-answer" def public_workflow_skip_link_html(skip_href: str = "#public-page-answer") -> str: return ( f"Skip to page answer" + "target='_self' aria-label='Skip to page answer'>Skip to page answer" ) @@ -7017,7 +7438,7 @@ def single_stock_public_answer_cards(frame: pd.DataFrame) -> list[dict[str, obje ] -def single_stock_public_summary_html(frame: pd.DataFrame) -> str: +def single_stock_public_summary_html(frame: pd.DataFrame, *, target_mode: str = "public") -> str: """Return one public ticker answer with a visible evidence handoff.""" if frame is None or frame.empty: @@ -7033,11 +7454,13 @@ def single_stock_public_summary_html(frame: pd.DataFrame) -> str: context = format_missing(row.get("Context Only"), "No context-only note reported.") next_action = public_safe_next_action_text(row.get("Next Safe Action")) boundary = format_missing(row.get("Review Boundary"), "Keep the review research-only.") - href = html.escape(f"?mode=public&page=data-health&ticker={ticker}", quote=True) + safe_mode = RESEARCH_MODE if target_mode == RESEARCH_MODE else PUBLIC_DEMO_MODE + summary_class = "public-ticker-summary research" if safe_mode == RESEARCH_MODE else "public-ticker-summary" + href = html.escape(f"?mode={safe_mode}&page=data-health&ticker={ticker}", quote=True) return ( - "
" + f"
" "
" - "Selected ticker" + "Selected ticker " f"{html.escape(ticker)}" "
" "
" @@ -7058,6 +7481,22 @@ def single_stock_public_summary_html(frame: pd.DataFrame) -> str: ) +def render_single_stock_public_summary( + frame: pd.DataFrame, + *, + research_mode: bool, + selected_answer_target=None, +) -> None: + """Render one selected-ticker answer at its route-selected target.""" + + rendered = single_stock_public_summary_html( + frame, + target_mode=RESEARCH_MODE if research_mode else "public", + ) + target = selected_answer_target if selected_answer_target is not None else st + target.markdown(rendered, unsafe_allow_html=True) + + def stock_report_public_answer_cards(frame: pd.DataFrame) -> list[dict[str, object]]: """Convert loaded-report answer rows into responsive cards for public mode.""" @@ -7206,6 +7645,7 @@ def command_center_header_html( latest_price: str, compact: bool = False, current_page: str = "Home", + current_mode: str = PUBLIC_DEMO_MODE, ) -> str: summary = summary or {} normalized_current_page = str(current_page or "Home").strip() @@ -7230,6 +7670,8 @@ def command_center_header_html( ) compact_class = " compact" if compact else "" data_health_current_attr = " aria-current='page'" if normalized_current_page == "Data Health" else "" + data_health_mode = RESEARCH_MODE if current_mode == RESEARCH_MODE else PUBLIC_DEMO_MODE + data_health_href = f"?mode={data_health_mode}&page=data-health" topbar_html = ( f"
" f"
" "" "" ) @@ -7445,6 +7887,7 @@ def render_app_header( *, compact: bool = False, current_page: str = "Home", + current_mode: str = OPERATOR_DEMO_MODE, ) -> None: universe = catalog.load_dataframe("universe") tickers = 0 if universe is None or universe.empty else len(universe) @@ -7463,6 +7906,7 @@ def render_app_header( latest_price=latest_price, compact=compact, current_page=current_page, + current_mode=current_mode, ), unsafe_allow_html=True, ) @@ -10107,7 +10551,7 @@ def stock_report_next_step_cards( if not has_prices: cards.append( { - "kicker": "NEXT STEP", + "kicker": "RESEARCH PRIORITY", "title": "Fix price coverage", "body": ( f"{ticker} still needs stronger verified local price history before broader trust. " @@ -10115,26 +10559,28 @@ def stock_report_next_step_cards( "fallbacks are tried before the last manual import-file path." ), "badges": ["prices", "data moat"], + "state": "wait_for_evidence", "command": ticker_focus_command("prices", ticker, fallback=f"make price-refresh TICKERS={ticker} PROVIDER=auto"), } ) elif is_etf_like: cards.append( { - "kicker": "NEXT STEP", + "kicker": "RESEARCH PRIORITY", "title": "Review ETF / market proxy", "body": ( f"{ticker} should be reviewed as market, theme, liquidity, or risk context. " "Operating-company DCF and peer-relative valuation are excluded rather than failed." ), "badges": ["monitor context", "DCF excluded"], + "state": "review_now", "command": f"make stock-report-md TICKER={ticker}", } ) elif not readiness.get("dcf_ready") and not has_fundamentals: cards.append( { - "kicker": "NEXT STEP", + "kicker": "RESEARCH PRIORITY", "title": "Review fundamentals import file" if staged_fundamentals_import else "Stage fundamentals", "body": ( f"{ticker} already has fundamentals import file rows in {fundamentals_target_file}. " @@ -10146,13 +10592,14 @@ def stock_report_next_step_cards( ) ), "badges": ["fundamentals", "import file" if staged_fundamentals_import else "sec queue"], + "state": "wait_for_evidence", "command": fundamentals_command, } ) elif not readiness.get("peer_ready") or not peer_summary.get("peer_dataset_present"): cards.append( { - "kicker": "NEXT STEP", + "kicker": "RESEARCH PRIORITY", "title": "Review peer import file" if staged_peer_import else "Add peer mappings", "body": ( f"{ticker} already has peer mapping import file rows in {peer_target_file}. " @@ -10164,19 +10611,21 @@ def stock_report_next_step_cards( ) ), "badges": ["peers", "import file" if staged_peer_import else "manual research"], + "state": "wait_for_evidence", "command": peer_command, } ) else: cards.append( { - "kicker": "NEXT STEP", + "kicker": "RESEARCH PRIORITY", "title": "Review full report", "body": ( f"{ticker} already has the minimum local context for a deeper single-name pass. " "Move through valuation, trend and risk context, and source readiness together." ), "badges": ["ready", "single name"], + "state": "review_now", "command": f"make stock-report-md TICKER={ticker}", } ) @@ -11092,13 +11541,13 @@ def data_health_coverage_summary_frame( "lane": "Peers", "coverage_tier": "peer_candidate_context / trusted_peer_ready", "state": _coverage_lane_state(peer_ready, master, locked_when_empty=True), - "one_clear_answer": "Use only where source-backed peer mappings and required peer data exist.", + "one_clear_answer": "Use relationship context only with source-backed mappings; use peer medians only after role, comparability, and anchor review.", "ready_coverage": _coverage_summary_fraction(peer_ready, master), "supporting_coverage": f"Peer valuation comparisons {peer_valuation_ready:,}", "blocked_or_limited": f"{max(master - peer_ready, 0):,} still need peer mapping or peer inputs", - "why_blocked_or_limited": "Candidate peers are context only; trusted peers need source-backed mappings plus trusted peer inputs.", - "proof_to_unlock": "Reviewed trusted peer mapping rows plus mapped-peer price, fundamentals, and valuation inputs.", - "stop_rule": "Stop if candidate peers are promoted to trusted peers or mapped-peer inputs are missing.", + "why_blocked_or_limited": "Candidate and legacy-unreviewed peers stay context-only and cannot enter peer medians.", + "proof_to_unlock": "Reviewed peer role, rationale, comparability, anchor decision, and required mapped-peer inputs.", + "stop_rule": "Stop if candidate or legacy-unreviewed peers enter peer medians or required inputs are missing.", "operator_step": "make project-status", }, { @@ -11544,7 +11993,12 @@ def data_health_auto_refresh_status_cards( "artifact_policy": "generated CSV/JSON/report churn stays excluded unless intentionally reviewed evidence.", } ) - status_payload = build_auto_refresh_status_payload(preflight, build_scheduler_plan(schedule=schedule)) + project_root = root or BASE_DIR + status_payload = build_auto_refresh_status_payload( + preflight, + build_scheduler_plan(schedule=schedule), + continuation_gate=build_continuation_gate(build_profile_context(project_root=project_root)), + ) return overview_console.auto_refresh_status_cards(status_payload) @@ -11554,13 +12008,19 @@ def data_health_source_activation_setup_cards() -> list[dict[str, object]]: def data_health_provider_setup_checklist_cards(root: Path | None = None) -> list[dict[str, object]]: return overview_console.provider_setup_checklist_cards( - build_provider_setup_checklist(_cached_session_source_preflight_payload(root)) + build_provider_setup_checklist( + _cached_session_source_preflight_payload(root), + root=root or BASE_DIR, + ) ) def data_health_provider_setup_first_answer_frame(root: Path | None = None) -> pd.DataFrame: return overview_console.provider_setup_first_answer_frame( - build_provider_setup_checklist(_cached_session_source_preflight_payload(root)) + build_provider_setup_checklist( + _cached_session_source_preflight_payload(root), + root=root or BASE_DIR, + ) ) @@ -13941,6 +14401,98 @@ def data_health_reviewed_batch_proof_cards(ledger_path: Path | None = None) -> l ] +def proof_readiness_reconciliation_cards( + summary: ProofReadinessReconciliationSummary, + *, + ticker: str = "", +) -> list[dict[str, object]]: + def label(value: str) -> str: + return str(value or "").replace("_", " ") + + conflicts = [ + row + for row in summary.rows + if row.state == "historical_supported_currently_blocked" + ] + selected = str(ticker or "").strip().upper() + selected_conflicts = [row for row in conflicts if selected and row.ticker == selected] + if conflicts: + blocker_counts = [ + (blocker_code, int(count)) + for blocker_code, count in summary.current_blocker_counts + if blocker_code != "none" + ] + largest_blocker_code, largest_blocker_count = max( + blocker_counts, + key=lambda item: item[1], + default=("none", 0), + ) + largest_blocker_label = label(largest_blocker_code) + title = f"{len(conflicts):,} historical-support/current-readiness conflict(s)" + body = ( + "Historical support is not current readiness. Current saved readiness remains authoritative. " + f"Largest observable current blocker: {largest_blocker_label} ({largest_blocker_count:,}). " + "Observable current blockers describe saved inputs; they do not establish the historical cause, " + "restore data, promote readiness, or rewrite proof history. Historical evidence does not establish " + "the historical cause." + ) + badges = [summary.input_status, "current state wins"] + else: + title = "No historical-support/current-readiness conflict found" + body = ( + "The current reconciliation found no mapped conflict. This does not prove source rights, field scope, " + "provenance, or payload truth, and current saved readiness remains authoritative." + ) + badges = [summary.input_status, "read-only"] + cards: list[dict[str, object]] = [ + { + "kicker": "PROOF / CURRENT STATE", + "title": title, + "body": body, + "badges": badges, + "command": "", + } + ] + if selected_conflicts: + lanes = ", ".join(dict.fromkeys(label(row.lane) for row in selected_conflicts)) + applicability = ", ".join( + dict.fromkeys(label(row.proof_applicability) for row in selected_conflicts) + ) + blockers = ", ".join( + dict.fromkeys( + f"{label(row.current_blocker_code)}" + + ( + f" ({', '.join(label(field) for field in row.current_blocker_fields)})" + if row.current_blocker_fields + else "" + ) + for row in selected_conflicts + ) + ) + next_safe_review = next( + ( + row.next_safe_review + for row in selected_conflicts + if row.next_safe_review + ), + "No safe next review is recorded.", + ) + cards.append( + { + "kicker": "SELECTED TICKER", + "title": f"{selected}: historical proof conflicts with current readiness", + "body": ( + f"Current blocked lane(s): {lanes}. Proof applicability: {applicability}. " + f"Current blocker(s): {blockers}. Next safe review: {next_safe_review} " + "Reconciliation itself does not unlock the lane." + ), + "badges": [selected, "currently blocked"], + "command": "", + } + ) + return cards + + def _proof_history_first_text(frame: pd.DataFrame | None, *columns: str, fallback: str = "Not recorded") -> str: if frame is None or frame.empty: return fallback @@ -14264,6 +14816,8 @@ def render_proof_history(*, public_mode: bool = True) -> None: ) proof_timeline = data_health_reviewed_proof_timeline_frame() batch_proof_frame = data_health_reviewed_batch_proof_frame() + proof_reconciliation = load_proof_readiness_reconciliation(root=BASE_DIR, data_dir=DATA_DIR) + proof_ticker = str(st.query_params.get("ticker") or "").strip().upper() if public_mode: st.markdown( proof_history_public_timeline_html(proof_timeline, batch_proof_frame), @@ -14287,6 +14841,11 @@ def render_proof_history(*, public_mode: bool = True) -> None: proof_history_public_summary_html(proof_timeline, batch_proof_frame), unsafe_allow_html=True, ) + render_signal_cards( + proof_readiness_reconciliation_cards(proof_reconciliation, ticker=proof_ticker), + show_commands=False, + variant="queue", + ) with st.expander("Advanced: proof ledger details", expanded=False): render_section_header("Reviewed Data Proof Ledger", "Durable lane proof rows, not generated CSV churn.") if proof_timeline.empty: @@ -15966,7 +16525,7 @@ def peer_input_ladder_cards(peer_input_ladder: pd.DataFrame | None) -> list[dict total_rows = int(pd.to_numeric(frame.get("Priority Count"), errors="coerce").fillna(0).sum()) active_rows = int(pd.to_numeric(frame.get("Active Universe Count"), errors="coerce").fillna(0).sum()) dcf_rows = int(pd.to_numeric(frame.get("DCF-Ready Count"), errors="coerce").fillna(0).sum()) - peer_schema_guide = "ticker, peer_ticker, peer_group, sector, industry, source, as_of_date" + peer_schema_guide = "ticker, peer_ticker, peer_group, sector, industry, peer_role, relationship_rationale, comparability_basis, valuation_anchor_eligible, source, as_of_date" return [ { "kicker": "PEER INPUT LADDER", @@ -16045,7 +16604,7 @@ def first_peer_mapping_unlock_frame(peer_unlock_worklist_frame: pd.DataFrame | N "Step": "2. Add source-backed mappings only", "Why It Matters": ( "Peer relationships must come from trusted research context; sector or industry fallback is not trusted peer data. " - "Schema guide for data/imports/peers.csv: ticker, peer_ticker, peer_group, sector, industry, source, as_of_date." + "Schema guide for data/imports/peers.csv: ticker, peer_ticker, peer_group, sector, industry, peer_role, relationship_rationale, comparability_basis, valuation_anchor_eligible, source, as_of_date." ), "Copy Command": "make templates", "Trusted Input": "data/imports/peers.csv", @@ -16578,7 +17137,7 @@ def data_health_peer_unlock_frame( unlock_rows = frame.loc[missing_mapping | peer_not_ready | missing_peer_inputs].copy() if unlock_rows.empty: return pd.DataFrame(columns=columns) - peer_schema_guide = "ticker, peer_ticker, peer_group, sector, industry, source, as_of_date" + peer_schema_guide = "ticker, peer_ticker, peer_group, sector, industry, peer_role, relationship_rationale, comparability_basis, valuation_anchor_eligible, source, as_of_date" if "priority" in unlock_rows.columns: scope_text = unlock_rows.get("workflow_scope", pd.Series("", index=unlock_rows.index)).fillna("").astype(str).str.lower() workflow_text = unlock_rows.get("workflow_group", pd.Series("", index=unlock_rows.index)).fillna("").astype(str).str.lower() @@ -16694,7 +17253,7 @@ def data_health_peer_unlock_cards(peer_unlock_frame: pd.DataFrame | None) -> lis sequence_summary = validation_sequence_summary(sequence) validation_summary = validation_sequence_summary(first.get("Validation Path")) command = format_missing(first.get("Copy-Only Command"), f"make focus-peers TICKER={ticker}") - peer_schema_guide = "ticker, peer_ticker, peer_group, sector, industry, source, as_of_date" + peer_schema_guide = "ticker, peer_ticker, peer_group, sector, industry, peer_role, relationship_rationale, comparability_basis, valuation_anchor_eligible, source, as_of_date" return [ { "kicker": "PEER QUEUE", @@ -16762,7 +17321,7 @@ def data_health_peer_source_review_frame(packet: PeerMappingSourceReviewPacket | freshness_status = format_missing(packet.freshness.status, "unknown").replace("_", " ") review_gate = "blocked by freshness" if packet.freshness.status in {"missing", "stale"} else "source proof required" required_fills = ( - "proposed_peer_ticker, peer_group, source, as_of_date, relationship_rationale, reviewer, review_date" + "proposed_peer_ticker, peer_group, peer_role, source, as_of_date, relationship_rationale, comparability_basis, valuation_anchor_eligible, reviewer, review_date" ) rows: list[dict[str, object]] = [] for row in packet.rows[: max(limit, 0)]: @@ -28516,7 +29075,14 @@ def research_comparison_frame(comparison: ResearchComparison) -> pd.DataFrame: return pd.DataFrame(comparison_matrix_rows(comparison)) -def stock_selector_result_table_html(frame: pd.DataFrame, *, total_count: int, limit: int = 30) -> str: +def stock_selector_result_table_html( + frame: pd.DataFrame, + *, + total_count: int, + limit: int = 30, + target_mode: str = PUBLIC_DEMO_MODE, + target_page: str = "single-stock-report", +) -> str: """Render selector rows as a compact public review queue.""" if frame is None or frame.empty: @@ -28536,7 +29102,9 @@ def stock_selector_result_table_html(frame: pd.DataFrame, *, total_count: int, l if theme.strip().lower() in {"", "not available", "unclassified"}: theme = "" supported = _selector_public_fragment(row.get("Supported Now", "Supported analysis not listed."), max_chars=110) - report_href = html.escape(f"?mode=public&page=single-stock-report&ticker={ticker}&open=1") + report_href = html.escape( + f"?mode={target_mode}&page={target_page}&ticker={ticker}&open=1" + ) state_label = stock_selector_public_state_label(row) readiness_class = _selector_readiness_class(state_label) identity_html = ( @@ -28680,10 +29248,27 @@ def _selector_option_index(options: list[str], preferred: str) -> int: return options.index(preferred_text) if preferred_text in options else 0 +def filter_selector_to_tickers( + frame: pd.DataFrame, + allowed_tickers: tuple[str, ...] | None, +) -> pd.DataFrame: + """Limit a selector queue without changing its source order.""" + + if allowed_tickers is None: + return frame + if frame is None or frame.empty or "Ticker" not in frame.columns: + return pd.DataFrame(columns=list(frame.columns) if frame is not None else []) + allowed = {str(ticker or "").strip().upper() for ticker in allowed_tickers} + return frame.loc[frame["Ticker"].astype(str).str.upper().isin(allowed)].copy() + + def render_stock_selector( output_frames: dict[str, tuple[pd.DataFrame | None, str | None]], *, public_mode: bool = True, + target_mode: str = PUBLIC_DEMO_MODE, + target_page: str = "single-stock-report", + allowed_tickers: tuple[str, ...] | None = None, ) -> None: ticker_readiness_frame, ticker_readiness_message = load_ticker_readiness_report() dcf_readiness_frame, _ = load_dcf_readiness() @@ -28701,6 +29286,7 @@ def render_stock_selector( ticker_readiness_frame, ) selector_frame = stock_selector_queue_frame(decisions_frame, final_frame, ticker_readiness_frame, limit=120) + selector_frame = filter_selector_to_tickers(selector_frame, allowed_tickers) if not public_mode: render_section_header( @@ -28857,7 +29443,12 @@ def render_stock_selector( ) st.dataframe(research_comparison_frame(comparison), width="stretch", hide_index=True) st.markdown( - stock_selector_result_table_html(filtered, total_count=len(selector_frame), limit=10 if public_mode else 15), + stock_selector_result_table_html(filtered, + total_count=len(selector_frame), + limit=10 if public_mode else 15, + target_mode=target_mode, + target_page=target_page, + ), unsafe_allow_html=True, ) with st.expander("Advanced: full filtered selector rows", expanded=False): @@ -29479,7 +30070,29 @@ def render_home_page( ) +def render_legacy_research_utility_shell(title: str) -> None: + render_section_header(title, "Retained only for deterministic compatibility and operator regression review.") + render_context_note( + "Legacy research utility — not part of Personal Research Mode", + "This retained view cannot provide recommendations, company ranking for action, position sizing, transaction direction, readiness, or Decision Lab evidence.", + tone="warning", + ) + + def render_monthly_picks(catalog: LocalDataCatalog) -> None: + render_legacy_research_utility_shell("Monthly Picks") + with st.expander("Advanced: legacy compatibility output", expanded=False): + show_legacy_output = st.checkbox( + "Load legacy monthly candidate and track-record output", + value=False, + help="Compatibility review only. This output is excluded from Personal Research Mode and product conclusions.", + key="legacy-monthly-picks-output", + ) + if show_legacy_output: + _render_monthly_picks_legacy_output(catalog) + + +def _render_monthly_picks_legacy_output(catalog: LocalDataCatalog) -> None: render_advanced_page_shell( "Monthly Picks", "A compact, data-gated research-candidate list. Scores help decide what to review next; they are not advice, allocation guidance, or a conclusion list.", @@ -29650,6 +30263,19 @@ def render_advanced_page_shell(title: str, caption: str) -> None: def render_output_tab(title: str, output_frames: dict[str, tuple[pd.DataFrame | None, str | None]], show_reason_details: bool) -> None: + render_legacy_research_utility_shell(title) + with st.expander("Advanced: legacy compatibility output", expanded=False): + show_legacy_output = st.checkbox( + f"Load legacy {title.lower()} output", + value=False, + help="Compatibility review only. This output is excluded from Personal Research Mode and product conclusions.", + key=f"legacy-output-{dashboard_page_slug(title)}", + ) + if show_legacy_output: + _render_legacy_output_tab(title, output_frames, show_reason_details) + + +def _render_legacy_output_tab(title: str, output_frames: dict[str, tuple[pd.DataFrame | None, str | None]], show_reason_details: bool) -> None: filename = TAB_TO_FILE[title] frame, message = output_frames[filename] render_advanced_page_shell(title, OUTPUT_TAB_GUIDANCE.get(title, "Search, filter, and inspect the most important columns first.")) @@ -29688,6 +30314,11 @@ def render_single_stock_report( *, public_mode: bool = True, profile_context: ProfileContext | None = None, + research_mode: bool = False, + research_review_items=(), + quarterly_trend_packet: QuarterlyTrendPacket | None = None, + cash_generation_preview: CompanyWorkbenchCashGenerationPreview | None = None, + selected_answer_target=None, ) -> None: show_card_commands = not public_mode local_tickers = provider.list_local_tickers() if provider is not None and hasattr(provider, "list_local_tickers") else [] @@ -29759,7 +30390,12 @@ def render_single_stock_report( single_stock_loading_placeholder = None if public_mode and compact_public_open_report and not report_payload: fast_snapshot = single_stock_fast_readiness_snapshot(ticker) - render_signal_cards(single_stock_quick_read_cards(fast_snapshot)[:3], show_commands=False, variant="queue") + fast_answer_frame = single_stock_one_answer_frame(fast_snapshot) + render_single_stock_public_summary( + fast_answer_frame, + research_mode=research_mode, + selected_answer_target=selected_answer_target, + ) single_stock_loading_placeholder = st.empty() with single_stock_loading_placeholder.container(): render_signal_cards(single_stock_loading_contract_cards(ticker), show_commands=False, variant="queue") @@ -29874,33 +30510,243 @@ def open_selected_report() -> None: coverage if provider is not None and ticker else None, peer_summary if provider is not None and ticker else None, ) + journal_state = None + journal_error = "" + try: + journal_state = load_dashboard_journal_state( + profile_context or build_profile_context(project_root=BASE_DIR), + ticker=ticker, + ) + except ValueError as exc: + journal_error = str(exc) + trend_packet = quarterly_trend_packet or load_dashboard_quarterly_trend(ticker) + peer_read_through = build_peer_read_through_map( + report_payload, + profile_key=(profile_context or build_profile_context(project_root=BASE_DIR)).profile_key, + ) + nowcast_packet = load_dashboard_nowcast_packet(report_payload, ticker=ticker) + selected_context = profile_context or build_profile_context(project_root=BASE_DIR) + valuation_regime = load_dashboard_valuation_regime(ticker) + outcome_status = load_dashboard_outcome_status(selected_context, ticker=ticker) + catalyst_timeline = load_dashboard_catalyst_timeline(selected_context, ticker=ticker) + decision_process_scorecard = None + decision_process_error = journal_error + if journal_state is not None: + try: + decision_process_scorecard = build_decision_process_scorecard( + report_payload, + profile_key=selected_context.profile_key, + journal_state=journal_state, + review_items=tuple(research_review_items), + ) + decision_lab_state = build_research_decision_lab_state( + profile_key=selected_context.profile_key, + journal_state=journal_state, + scorecard=decision_process_scorecard, + outcome_status=outcome_status, + review_items=tuple(research_review_items), + ) + except ValueError as exc: + decision_process_error = str(exc) + decision_lab_state = unavailable_research_decision_lab_state( + profile_key=selected_context.profile_key, + ticker=ticker, + reason=decision_process_error, + ) + else: + decision_lab_state = unavailable_research_decision_lab_state( + profile_key=selected_context.profile_key, + ticker=ticker, + reason=journal_error or "No reviewed journal evidence is available.", + ) + forward_view_packet = build_forward_view( + report_payload, + trend_packet, + journal_state=journal_state, + peer_map=peer_read_through, + nowcast_packet=nowcast_packet, + freshness_state=(profile_context or build_profile_context(project_root=BASE_DIR)).freshness_state, + ) if public_mode: - st.markdown(single_stock_public_summary_html(single_answer_frame), unsafe_allow_html=True) - render_signal_cards(nowcast_summary_cards(None, ticker=ticker), show_commands=False, variant="queue") + render_single_stock_public_summary( + single_answer_frame, + research_mode=research_mode, + selected_answer_target=selected_answer_target, + ) + if research_mode: + change_answer = company_change_answer(ticker, research_review_items) + change_context_badge = { + "none": "no queued change", + "snapshot_only": "snapshot evidence only", + "source_backed": "source-backed change", + }.get(str(change_answer["change_context_kind"]), "no queued change") + st.markdown("## What Changed") + render_signal_cards( + [ + { + "kicker": "EVIDENCE CHANGE", + "title": str(change_answer["answer"]), + "body": str(change_answer["next_task"]), + "badges": [ + str(change_answer["state"]).replace("_", " "), + change_context_badge, + ], + "command": "", + } + ], + show_commands=False, + variant="queue", + ) + st.markdown("## Research Decision Lab") + render_signal_cards(decision_lab_cards(decision_lab_state), show_commands=False, variant="queue") + with st.expander("Advanced: Decision Lab evidence", expanded=False): + st.dataframe( + pd.DataFrame(decision_lab_rows(decision_lab_state)), + width="stretch", + hide_index=True, + ) + st.caption(f"Decision Lab identity: {decision_lab_state.identity}") + st.caption(decision_lab_state.boundary) + st.markdown("## Business Trend") + render_signal_cards(quarterly_trend_cards(trend_packet), show_commands=False, variant="queue") + if cash_generation_preview is not None: + render_signal_cards( + cash_generation_preview_cards(cash_generation_preview), + show_commands=False, + variant="queue", + ) + with st.expander("Advanced: cash-generation preview evidence", expanded=False): + st.dataframe( + pd.DataFrame( + cash_generation_preview_rows(cash_generation_preview) + ), + width="stretch", + hide_index=True, + ) + st.caption( + "Preview only: production activation is false; readiness promotions are none; " + "no persistence or readiness rebuild occurs." + ) + with st.expander("Advanced: quarterly trend evidence", expanded=False): + st.dataframe(pd.DataFrame(quarterly_trend_rows(trend_packet)), width="stretch", hide_index=True) + if trend_packet.canonical_rejected_rows: + st.dataframe( + pd.DataFrame( + { + "Canonical row": [ + row["row_number"] + for row in trend_packet.canonical_rejected_rows + ], + "Rejection reason": [ + row["reason"] + for row in trend_packet.canonical_rejected_rows + ], + } + ), + width="stretch", + hide_index=True, + ) + st.caption("Q4 is never derived. Only explicit versioned quarterly source rows can unlock a comparison.") + st.markdown("## Valuation") + render_signal_cards( + stock_report_valuation_boundary_cards(report_payload), + show_commands=False, + variant="queue", + ) + render_signal_cards(valuation_regime_cards(valuation_regime), show_commands=False, variant="queue") + with st.expander("Advanced: historical valuation evidence", expanded=False): + if valuation_regime.source_refs: + st.dataframe( + pd.DataFrame({"Source Reference": list(valuation_regime.source_refs)}), + width="stretch", + hide_index=True, + ) + if valuation_regime.commercial_blockers: + st.dataframe( + pd.DataFrame({"Commercial blocker": list(valuation_regime.commercial_blockers)}), + width="stretch", + hide_index=True, + ) + st.caption(valuation_regime.boundary) + st.markdown("## Forward View") + render_signal_cards(forward_view_cards(forward_view_packet), show_commands=False, variant="queue") + render_signal_cards(catalyst_timeline_cards(catalyst_timeline), show_commands=False, variant="queue") + with st.expander("Advanced: Forward View evidence", expanded=False): + st.dataframe(pd.DataFrame(forward_view_rows(forward_view_packet)), width="stretch", hide_index=True) + if catalyst_timeline.upcoming or catalyst_timeline.recent: + st.dataframe( + pd.DataFrame([asdict(row) for row in (*catalyst_timeline.upcoming, *catalyst_timeline.recent)]), + width="stretch", + hide_index=True, + ) + if catalyst_timeline.commercial_blockers: + st.dataframe( + pd.DataFrame({"Commercial blocker": list(catalyst_timeline.commercial_blockers)}), + width="stretch", + hide_index=True, + ) + st.caption( + f"Model {forward_view_packet.model_version}; cutoff {forward_view_packet.source_cutoff or 'unavailable'}. " + "Candidate context never changes numerical scenarios." + ) + else: + render_signal_cards(nowcast_summary_cards(None, ticker=ticker), show_commands=False, variant="queue") + if research_mode: + st.markdown("## What Remains Withheld") + withheld = [ + lane.replace("_", " ") + for lane, ready in ( + ("quarterly trend", (quarterly_trend_packet or load_dashboard_quarterly_trend(ticker)).status != "blocked"), + ("trusted peers", bool(report_readiness.get("peer_ready"))), + ("earnings context", bool(report_readiness.get("earnings_available") or report_readiness.get("earnings_ready"))), + ("analyst estimates", bool(report_readiness.get("analyst_estimates_available") or report_readiness.get("analyst_estimates_ready"))), + ) + if not ready + ] + render_signal_cards( + [ + { + "kicker": "WITHHELD INPUTS", + "title": ", ".join(withheld) if withheld else "No listed review lane is withheld", + "body": "Unavailable or unverified inputs remain withheld; the workbench does not infer them from price, peers, or narrative context.", + "badges": ["fail closed", "no inference"], + "command": "", + } + ], + show_commands=False, + variant="queue", + ) else: render_signal_cards(operator_single_stock_workflow_cards(workflow_fit_cards), show_commands=False) with st.expander("Advanced: review summary details", expanded=False): st.table(clean_display_frame(single_answer_frame)) st.table(clean_display_frame(report_answer_frame)) render_signal_cards(at_a_glance_cards, show_commands=show_card_commands) - journal_state = None - try: - journal_state = load_dashboard_journal_state( - profile_context or build_profile_context(project_root=BASE_DIR), - ticker=ticker, - ) + if journal_state is not None: journal_summary = research_thesis_journal_summary(journal_state) - except ValueError as exc: + else: journal_summary = { "status": "unavailable", "title": "Research journal unavailable", "answer": "The reviewed journal could not be verified for this selected profile and ticker.", "evidence": "No thesis or evidence claim is shown.", - "boundary": str(exc), + "boundary": journal_error or "No reviewed journal evidence is available.", "confidence": "", "primary_action": "Review the journal contract before using it", } st.markdown(research_thesis_journal_html(journal_summary), unsafe_allow_html=True) + if research_mode: + render_signal_cards(outcome_status_cards(outcome_status), show_commands=False, variant="queue") + render_research_record_authoring( + st_api=st, + profile_key=selected_context.profile_key, + ticker=ticker, + paths=AuthoringPaths( + journal=DATA_DIR / "research_thesis_journal.csv", + catalysts=DATA_DIR / "catalyst_evidence.csv", + outcomes=DATA_DIR / "research_outcome_reviews.csv", + ), + ) with st.expander("Advanced: thesis and evidence history", expanded=False): if journal_state is not None and journal_state.entries: st.dataframe( @@ -29910,24 +30756,58 @@ def open_selected_report() -> None: ) else: st.caption("No verified journal history is available for this selected profile and ticker.") + if outcome_status.commercial_blockers: + st.dataframe( + pd.DataFrame({"Commercial blocker": list(outcome_status.commercial_blockers)}), + width="stretch", + hide_index=True, + ) with st.expander("Decision-process scorecard", expanded=False): - if journal_state is None: - st.caption("The decision-process checks remain unavailable until the selected-profile journal can be verified.") + if decision_process_scorecard is None: + st.caption( + "The decision-process checks remain unavailable until the selected-profile journal can be verified." + ) + if decision_process_error: + st.caption(f"Decision-process checks unavailable: {decision_process_error}") else: - try: - scorecard = build_decision_process_scorecard( - report_payload, - profile_key=(profile_context or build_profile_context(project_root=BASE_DIR)).profile_key, - journal_state=journal_state, - review_items=tuple(ACTIVE_RESEARCH_CHANGE_STATE.get("queue") or ()), - ) - render_signal_cards(decision_process_scorecard_cards(scorecard), show_commands=False) - st.dataframe(decision_process_scorecard_frame(scorecard), width="stretch", hide_index=True) - with st.expander("Advanced: process identity", expanded=False): - st.caption(f"Process identity: {scorecard.scorecard_identity}") - st.caption(scorecard.boundary) - except ValueError as exc: - st.caption(f"Decision-process checks unavailable: {exc}") + render_signal_cards(decision_process_scorecard_cards(decision_process_scorecard), show_commands=False) + st.dataframe( + decision_process_scorecard_frame(decision_process_scorecard), + width="stretch", + hide_index=True, + ) + with st.expander("Advanced: process identity", expanded=False): + st.caption(f"Process identity: {decision_process_scorecard.scorecard_identity}") + st.caption(decision_process_scorecard.boundary) + if research_mode: + st.markdown("## Research Conclusion") + conclusion_cards = stock_report_next_step_cards( + report_payload, + coverage if provider is not None and ticker else None, + peer_summary if provider is not None and ticker else None, + ) + render_signal_cards( + conclusion_cards, + show_commands=False, + variant="queue", + ) + change_answer = company_change_answer(ticker, research_review_items) + authoritative_task = company_next_research_task(change_answer, conclusion_cards) + st.markdown("## Next Research Task") + render_signal_cards( + [ + { + "kicker": "ONE NEXT TASK", + "title": str(authoritative_task["title"]), + "body": str(authoritative_task["body"]), + "badges": list(authoritative_task["badges"]), + "state": str(authoritative_task["state"]), + "command": "", + } + ], + show_commands=False, + variant="queue", + ) if public_mode and report_payload and not single_stock_detail_sections_visible(ticker): render_context_note( "Detailed report stays closed.", @@ -33490,6 +34370,409 @@ def render_universe_manager(universe_summary: dict[str, Any]) -> None: ) +def render_research_workspace_styles() -> None: + st.markdown( + """ + + """, + unsafe_allow_html=True, + ) + + +def render_research_workspace_header( + page_title: str, + context: ProfileContext, + *, + ticker: str = "", + primary_action: str, + compact: bool = False, +) -> None: + st.markdown( + research_workspace_header_html( + page_title, + ticker=ticker, + profile_label=context.profile_label, + freshness=context.freshness_state.replace("_", " ").title(), + primary_action=primary_action, + compact=compact, + ), + unsafe_allow_html=True, + ) + + +def render_research_desk( + state: dict[str, object], + context: ProfileContext, + cohort: FocusedCohort, + coverage: FocusedCohortCoverage, + weekly_summary: WeeklyResearchSummary, +) -> None: + render_research_workspace_header( + "Research Desk", + context, + primary_action="Open Discover and choose one readiness-backed company", + ) + st.markdown("## Weekly research summary") + render_signal_cards(weekly_summary_cards(weekly_summary), show_commands=False, variant="queue") + cards = research_desk_cards( + change_status=str(state.get("status") or "unavailable"), + review_items=state.get("queue") or (), + readiness_summary=_header_readiness_summary(), + ) + st.markdown(research_desk_cards_html(cards), unsafe_allow_html=True) + st.link_button("Open Discover", "?mode=research&page=discover", type="primary") + with st.expander("Advanced Evidence", expanded=False): + render_signal_cards(focused_cohort_cards(cohort), show_commands=False, variant="queue") + render_signal_cards(focused_cohort_coverage_cards(coverage), show_commands=False, variant="queue") + cohort_frame = focused_cohort_frame(cohort) + if not cohort_frame.empty: + st.dataframe(cohort_frame, width="stretch", hide_index=True) + coverage_frame = focused_cohort_coverage_frame(coverage) + if not coverage_frame.empty: + st.dataframe(coverage_frame, width="stretch", hide_index=True) + weekly_rows = weekly_summary_rows(weekly_summary) + if weekly_rows: + st.dataframe(pd.DataFrame(weekly_rows), width="stretch", hide_index=True) + st.markdown(advanced_evidence_links_html(""), unsafe_allow_html=True) + st.caption("Detailed change events remain in the separate research change evidence drawer below.") + render_research_change_route_summary("Research Desk", state) + + +def render_research_monitor( + state: dict[str, object], + context: ProfileContext, + weekly_summary: WeeklyResearchSummary, + cohort: FocusedCohort, +) -> None: + render_research_workspace_header( + "Monitor", + context, + primary_action="Review unresolved evidence changes; otherwise wait for new source evidence", + ) + render_signal_cards(weekly_summary_cards(weekly_summary), show_commands=False, variant="queue") + discipline = load_dashboard_research_discipline_rows( + context, + cohort, + state.get("queue") or (), + ) + st.markdown("## Research Discipline Review") + discipline_frame = pd.DataFrame(research_discipline_rows(discipline)) + if discipline_frame.empty or not any(row.due_lanes for row in discipline): + render_context_note( + "No process item is currently due from saved reviewer-authored evidence.", + "This does not claim that no market event, risk, or external research need exists.", + ) + if not discipline_frame.empty: + st.dataframe(discipline_frame, width="stretch", hide_index=True) + with st.expander("Advanced: Research Discipline evidence", expanded=False): + if discipline: + st.dataframe( + pd.DataFrame( + [ + { + "Cohort order": row.cohort_order, + "Ticker": row.ticker, + "Decision Lab identity": row.identity, + } + for row in discipline + ] + ), + width="stretch", + hide_index=True, + ) + else: + st.caption("No focused-cohort process identity is available from saved evidence.") + st.caption( + "Rows preserve saved focused-cohort order. Process state does not rank companies, " + "estimate returns, or replace source-change review." + ) + st.markdown("## Research change monitor") + frame = research_monitor_frame(state.get("queue") or ()) + if frame.empty: + render_context_note( + "No unresolved evidence change is queued.", + "This is a monitoring state, not a stock ranking. Continue with Discover or wait for a comparable source-backed change.", + ) + st.link_button("Open Discover", "?mode=research&page=discover", type="primary") + else: + st.dataframe(frame, width="stretch", hide_index=True) + nowcast_cohort = load_dashboard_nowcast_cohort() + with st.expander("Advanced: five-company Earnings Nowcast readiness", expanded=False): + st.markdown("### Earnings evidence readiness") + render_signal_cards(cohort_readiness_cards(nowcast_cohort), show_commands=False, variant="queue") + st.dataframe(pd.DataFrame([asdict(row) for row in nowcast_cohort]), width="stretch", hide_index=True) + st.caption("This board creates no forecast. Missing consensus, Q4, split, backtest, and calibration evidence remain separate blockers.") + with st.expander("Advanced Evidence", expanded=False): + st.caption("Source identifiers and raw change rows remain in the separate evidence drawer below.") + render_research_change_route_summary("Monitor", state) + + +def render_company_workbench( + provider, + context: ProfileContext, + state: dict[str, object], + coverage: FocusedCohortCoverage, +) -> None: + ticker = str(st.query_params.get("ticker") or "").strip().upper() + cash_generation_preview = None + if company_workbench_cash_preview_requested(st.query_params.get("cash_preview")): + cash_generation_preview = load_company_workbench_cash_generation_preview(ticker) + render_research_workspace_header( + "Company Workbench", + context, + ticker=ticker, + primary_action="Review usable evidence, then record what remains uncertain", + compact=True, + ) + selected_answer_target = st.empty() + section_names = [section["title"] for section in company_workbench_section_contract()] + with st.expander("Review path", expanded=False): + st.caption(" -> ".join(section_names[:-1])) + with st.expander("Advanced: selected-company lane coverage", expanded=False): + render_signal_cards( + focused_ticker_coverage_cards(coverage, ticker), + show_commands=False, + variant="queue", + ) + st.caption( + "Lane coverage is technical evidence only; blocked and candidate-only states remain separate." + ) + render_single_stock_report( + provider, + False, + public_mode=True, + profile_context=context, + research_mode=True, + research_review_items=state.get("queue") or (), + quarterly_trend_packet=load_dashboard_quarterly_trend(ticker), + cash_generation_preview=cash_generation_preview, + selected_answer_target=selected_answer_target, + ) + st.markdown("## Advanced Evidence") + with st.expander("Advanced Evidence", expanded=False): + st.markdown(advanced_evidence_links_html(ticker), unsafe_allow_html=True) + st.caption("Detailed ticker-change evidence remains in the separate evidence drawer below.") + render_research_change_route_summary("Single-Stock Report", state, ticker=ticker) + + def main() -> None: global ACTIVE_RESEARCH_CHANGE_STATE st.set_page_config(page_title="Stock Research Command Center", layout="wide") @@ -33498,42 +34781,69 @@ def main() -> None: profile_context = build_profile_context(project_root=BASE_DIR) research_change_state = load_dashboard_research_change_state(profile_context) ACTIVE_RESEARCH_CHANGE_STATE = research_change_state + focused_cohort = load_dashboard_focused_cohort(profile_context) + focused_cohort_coverage = load_dashboard_focused_cohort_coverage(focused_cohort) + weekly_research_summary = load_dashboard_weekly_summary( + profile_context, + focused_cohort, + research_change_state.get("queue") or (), + ) catalog = LocalDataCatalog(BASE_DIR, data_dir=DATA_DIR, outputs_dir=OUTPUTS_DIR) provider = get_local_provider() page_query_value = st.query_params.get("page") mode_query_value = st.query_params.get("mode") - initial_page = dashboard_page_from_query(page_query_value) - initial_mode = dashboard_mode_from_query(mode_query_value, initial_page) - bootstrap_placeholder = render_public_route_bootstrap(initial_page, initial_mode) + has_explicit_page_query = dashboard_query_value_present(page_query_value) + has_explicit_mode_query = dashboard_query_value_present(mode_query_value) + queried_page = dashboard_page_from_query(page_query_value) + initial_mode = dashboard_mode_from_query(mode_query_value, queried_page) + initial_page = workspace_default_page( + queried_page, + mode=initial_mode, + has_explicit_page_query=has_explicit_page_query, + ) + bootstrap_placeholder = ( + render_public_route_bootstrap(initial_page, initial_mode) + if initial_mode == PUBLIC_DEMO_MODE + else None + ) with st.sidebar: render_sidebar_nav_header() - has_explicit_page_query = dashboard_query_value_present(page_query_value) - has_explicit_mode_query = dashboard_query_value_present(mode_query_value) - public_demo_mode = st.toggle( - "Public visitor mode", - value=initial_mode == PUBLIC_DEMO_MODE, - help="Keeps the dashboard focused on the real visitor workflow. Turn off for operator workflows, detailed boards, and local command runbooks.", + mode_options = [RESEARCH_MODE, PUBLIC_DEMO_MODE, OPERATOR_DEMO_MODE] + mode_selection = st.radio( + "Workspace", + mode_options, + index=mode_options.index(initial_mode), + format_func=dashboard_mode_label, + help="Personal research is the working default. Public keeps the guided demo; Operator keeps proof and maintenance tools.", + key="dashboard-workspace-mode", ) if has_explicit_mode_query: - public_demo_mode = initial_mode == PUBLIC_DEMO_MODE - mode = PUBLIC_DEMO_MODE if public_demo_mode else OPERATOR_DEMO_MODE - if not public_demo_mode: + mode_selection = initial_mode + mode = mode_selection + research_mode = mode == RESEARCH_MODE + public_demo_mode = mode == PUBLIC_DEMO_MODE + operator_mode = mode == OPERATOR_DEMO_MODE + if operator_mode: render_public_workflow_skip_link(initial_page, st.query_params, mode=OPERATOR_DEMO_MODE) - if not public_demo_mode: - st.caption(f"Mode: {dashboard_mode_label(mode)}") + st.caption(f"Workspace: {dashboard_mode_label(mode)}") st.caption(f"Data profile: {data_profile.name}") - path_options = sidebar_path_options(initial_page) - default_path = "Home" if public_demo_mode and initial_page in ADVANCED_PAGE_TITLES else initial_page + path_options = workspace_path_options(initial_page, mode) + if public_demo_mode and initial_page in ADVANCED_PAGE_TITLES: + default_path = "Home" + elif research_mode and initial_page not in path_options: + default_path = "Research Desk" + else: + default_path = initial_page route_signature = f"{mode}:{initial_page}" path_state_key = "dashboard-path-selection" path_widget_key = f"{path_state_key}-{dashboard_page_slug(route_signature)}" path_selection = st.radio( "Choose your path", path_options, - index=sidebar_path_index(default_path, path_options), - format_func=public_path_label, - help="Most visitors only need these paths: review one stock, explore ready names, check data coverage, or inspect proof.", + index=path_options.index(default_path) if default_path in path_options else 0, + format_func=lambda page: workspace_path_label(page, mode), + help="Choose the next research step. Evidence and maintenance remain secondary to the company review path.", key=path_widget_key, label_visibility="collapsed", ) @@ -33543,15 +34853,20 @@ def main() -> None: path_selection=path_selection, has_explicit_page_query=has_explicit_page_query, ) - route_query_update = route_rail_query_update(selected_page=selected_page, initial_page=initial_page, mode=mode) + route_query_update = route_rail_query_update( + selected_page=selected_page, + initial_page=initial_page, + mode=mode, + allowed_pages=path_options, + ) if route_query_update: st.query_params.clear() for query_key, query_value in route_query_update.items(): st.query_params[query_key] = query_value - if not public_demo_mode and initial_page == "Data Health": + if operator_mode and initial_page == "Data Health": selected_page = "Data Health" - show_sidebar_operator_guides = not public_demo_mode and selected_page != "Data Health" - if public_demo_mode or selected_page != "Data Health": + show_sidebar_operator_guides = operator_mode and selected_page != "Data Health" + if public_demo_mode or operator_mode and selected_page != "Data Health": render_sidebar_product_intro() if show_sidebar_operator_guides: with st.expander("Advanced: operator tools", expanded=False): @@ -33561,6 +34876,7 @@ def main() -> None: index=(["Keep current path"] + ADVANCED_PAGE_TITLES).index(initial_page) if initial_page in ADVANCED_PAGE_TITLES else 0, + format_func=legacy_research_utility_label, help="Additional research views remain available, but first-time visitors can stay with the main path.", ) if advanced_page != "Keep current path": @@ -33581,27 +34897,27 @@ def main() -> None: language="bash", ) show_reason_details = False - if not public_demo_mode and selected_page != "Data Health": + if operator_mode and selected_page != "Data Health": show_reason_details = st.checkbox( "Show reader tips", value=False, help="Adds extra explanation and review sections. Most visitors can leave this off.", ) show_source_details = False - if selected_page == "Single-Stock Report" and not public_demo_mode: + if selected_page == "Single-Stock Report" and operator_mode: show_source_details = st.checkbox( "Show data source details", value=False, help="Adds extra data-source and missing-input checks under Sources & Gaps. Most users can leave this off.", ) st.divider() - if not public_demo_mode and selected_page == "Data Health": + if operator_mode and selected_page == "Data Health": render_context_note( "Data Health operator.", "Use the lane buttons on the page. Copy-only commands stay inside evidence drawers.", tone="success", ) - else: + elif not research_mode: note_title, note_body = sidebar_navigation_note(selected_page) render_context_note(note_title, note_body, tone="success") if public_demo_mode: @@ -33609,8 +34925,15 @@ def main() -> None: "Clean visitor workflow.", "Home -> Stock Selector -> Single-Stock Report -> Data Health -> Proof History. Operator mode restores detailed boards; Data Health keeps commands inside evidence drawers.", ) + if research_mode: + render_context_note( + "Personal research workspace.", + "Research Desk -> Discover -> Company Workbench -> Monitor. Data Health and Proof History remain secondary evidence routes.", + tone="success", + ) - output_frames = dashboard_output_frames_for_page(selected_page) + content_page = workspace_content_page(selected_page, mode) + output_frames = dashboard_output_frames_for_page(content_page) if public_demo_mode: if bootstrap_placeholder is not None: bootstrap_placeholder.empty() @@ -33618,19 +34941,28 @@ def main() -> None: render_public_shell_mode_styles() render_public_workflow_skip_link(selected_page, st.query_params) render_public_app_shell(selected_page) - render_profile_trust_strip(profile_context, compact=True) + render_profile_trust_strip(profile_context, compact=True, include_advanced=False) render_public_workflow_skip_target() else: + if research_mode: + render_public_workflow_skip_link( + selected_page, + st.query_params, + mode=RESEARCH_MODE, + ) render_app_header( catalog, output_frames, compact=True, current_page=selected_page, + current_mode=mode, ) - render_profile_trust_strip(profile_context, compact=True) + render_profile_trust_strip(profile_context, compact=True, include_advanced=operator_mode) render_public_workflow_skip_target() + if research_mode: + render_research_workspace_styles() - if selected_page in PUBLIC_PATH_PAGE_TITLES: + if selected_page in PUBLIC_PATH_PAGE_TITLES and operator_mode: render_research_change_route_summary( selected_page, research_change_state, @@ -33640,10 +34972,52 @@ def main() -> None: project_status_payload = load_saved_project_status_payload(BASE_DIR) universe_summary = None - if selected_page in {"Overview", "Universe Manager"}: + if content_page in {"Overview", "Universe Manager"}: universe_summary = summarize_universe_manager(BASE_DIR) - if selected_page == "Home": + if research_mode and selected_page == "Research Desk": + render_research_desk( + research_change_state, + profile_context, + focused_cohort, + focused_cohort_coverage, + weekly_research_summary, + ) + elif research_mode and selected_page == "Discover": + render_research_workspace_header( + "Discover", + profile_context, + primary_action="Choose one readiness-backed company and open its workbench", + ) + st.markdown("## Which stock can I review?") + render_stock_selector( + output_frames, + public_mode=True, + target_mode=RESEARCH_MODE, + target_page="company-workbench", + allowed_tickers=tuple(member.ticker for member in focused_cohort.members), + ) + with st.expander("Advanced: cohort readiness context", expanded=False): + render_signal_cards(focused_cohort_cards(focused_cohort), show_commands=False, variant="queue") + render_signal_cards( + focused_cohort_coverage_cards(focused_cohort_coverage), + show_commands=False, + variant="queue", + ) + st.caption( + "Cohort membership and lane coverage remain evidence context only; " + "they do not rank expected return or create a recommendation." + ) + elif research_mode and selected_page == "Company Workbench": + render_company_workbench(provider, profile_context, research_change_state, focused_cohort_coverage) + elif research_mode and selected_page == "Monitor": + render_research_monitor( + research_change_state, + profile_context, + weekly_research_summary, + focused_cohort, + ) + elif content_page == "Home": render_home_page( catalog, output_frames, @@ -33651,27 +35025,57 @@ def main() -> None: public_mode=public_demo_mode, project_status_payload=project_status_payload, ) - elif selected_page == STOCK_SELECTOR_PATH_TITLE: + elif content_page == STOCK_SELECTOR_PATH_TITLE: render_stock_selector(output_frames, public_mode=public_demo_mode) - elif selected_page == "Overview": + elif content_page == "Overview": render_overview(output_frames, catalog, universe_summary or summarize_universe_manager(BASE_DIR), project_status_payload) - elif selected_page == "Monthly Picks": + elif content_page == "Monthly Picks": render_monthly_picks(catalog) - elif selected_page in {"Market Direction", "Momentum Leaders", "Portfolio Review", "Value / Re-rating", "Final Watchlist"}: - render_output_tab(selected_page, output_frames, show_reason_details) - elif selected_page == "Single-Stock Report": + elif content_page in {"Market Direction", "Momentum Leaders", "Portfolio Review", "Value / Re-rating", "Final Watchlist"}: + render_output_tab(content_page, output_frames, show_reason_details) + elif content_page == "Single-Stock Report": render_single_stock_report( provider, show_source_details, public_mode=public_demo_mode, profile_context=profile_context, ) - elif selected_page == "Data Health": - render_data_health(provider, project_status_payload, show_reason_details, public_mode=public_demo_mode) - elif selected_page == PROOF_HISTORY_PATH_TITLE: - render_proof_history(public_mode=public_demo_mode) - elif selected_page == "Universe Manager": + elif content_page == "Data Health": + if research_mode: + ticker = str(st.query_params.get("ticker") or "").strip().upper() + render_research_workspace_header( + "Data Health", + profile_context, + ticker=ticker, + primary_action="Inspect the blocked lane, then return to Company Workbench", + ) + return_link = research_evidence_return_link(ticker) + st.link_button(return_link["label"], return_link["href"], type="primary") + st.caption(return_link["purpose"]) + render_data_health(provider, project_status_payload, show_reason_details, public_mode=not operator_mode) + elif content_page == PROOF_HISTORY_PATH_TITLE: + if research_mode: + ticker = str(st.query_params.get("ticker") or "").strip().upper() + render_research_workspace_header( + "Proof History", + profile_context, + ticker=ticker, + primary_action="Confirm what evidence changed, then return to Company Workbench", + ) + return_link = research_evidence_return_link(ticker) + st.link_button(return_link["label"], return_link["href"], type="primary") + st.caption(return_link["purpose"]) + render_proof_history(public_mode=not operator_mode) + elif content_page == "Universe Manager": render_universe_manager(universe_summary or summarize_universe_manager(BASE_DIR)) + if public_demo_mode: + render_profile_trust_details(profile_context) + if selected_page in PUBLIC_PATH_PAGE_TITLES: + render_research_change_route_summary( + selected_page, + research_change_state, + ticker=str(st.query_params.get("ticker") or ""), + ) if bootstrap_placeholder is not None: bootstrap_placeholder.empty() diff --git a/src/dashboard_navigation.py b/src/dashboard_navigation.py index 8cc95dc6..9e05b34a 100644 --- a/src/dashboard_navigation.py +++ b/src/dashboard_navigation.py @@ -14,6 +14,19 @@ "Data Health", PROOF_HISTORY_PATH_TITLE, ] +RESEARCH_PATH_PAGE_TITLES = [ + "Research Desk", + "Discover", + "Company Workbench", + "Monitor", +] +LEGACY_RESEARCH_UTILITY_PAGES = ( + "Monthly Picks", + "Momentum Leaders", + "Portfolio Review", + "Value / Re-rating", + "Final Watchlist", +) PUBLIC_PATH_LABELS = { "Home": "Home", "Single-Stock Report": "Single-Stock Report", @@ -32,7 +45,9 @@ } PUBLIC_DEMO_MODE = "public" OPERATOR_DEMO_MODE = "operator" +RESEARCH_MODE = "research" DEMO_MODE_LABELS = { + RESEARCH_MODE: "Personal research mode", PUBLIC_DEMO_MODE: "Public visitor mode", OPERATOR_DEMO_MODE: "Operator mode", } @@ -125,6 +140,12 @@ def dashboard_page_from_query(value: object, user_page_titles: list[str]) -> str "explore-ready-names": STOCK_SELECTOR_PATH_TITLE, "ready-names": STOCK_SELECTOR_PATH_TITLE, "research-queue": STOCK_SELECTOR_PATH_TITLE, + "research-desk": "Research Desk", + "desk": "Research Desk", + "discover": "Discover", + "company-workbench": "Company Workbench", + "workbench": "Company Workbench", + "monitor": "Monitor", "selector": STOCK_SELECTOR_PATH_TITLE, "single": "Single-Stock Report", "single-stock": "Single-Stock Report", @@ -159,15 +180,34 @@ def dashboard_mode_from_query(value: object, initial_page: str, advanced_titles: return OPERATOR_DEMO_MODE if slug in {"public", "demo", "visitor", "share"}: return PUBLIC_DEMO_MODE + if slug in {"research", "personal", "workspace"}: + return RESEARCH_MODE if initial_page in advanced_titles: return OPERATOR_DEMO_MODE - return PUBLIC_DEMO_MODE + return RESEARCH_MODE def dashboard_mode_label(mode: str) -> str: return DEMO_MODE_LABELS.get(mode, DEMO_MODE_LABELS[PUBLIC_DEMO_MODE]) +def legacy_research_utility_label(page_title: str) -> str: + if page_title in LEGACY_RESEARCH_UTILITY_PAGES: + return f"Legacy utility · {page_title}" + return page_title + + +def workspace_page_for_mode(page_title: str, mode: str) -> str: + """Fail closed when a compatibility-only route is requested outside Operator.""" + if page_title not in LEGACY_RESEARCH_UTILITY_PAGES: + return page_title + if mode == OPERATOR_DEMO_MODE: + return page_title + if mode == RESEARCH_MODE: + return "Research Desk" + return "Home" + + def sidebar_path_options(initial_page: str, advanced_titles: list[str]) -> list[str]: """Return visitor paths plus the active advanced route when one is open.""" if initial_page in advanced_titles: @@ -175,6 +215,17 @@ def sidebar_path_options(initial_page: str, advanced_titles: list[str]) -> list[ return PUBLIC_PATH_PAGE_TITLES +def research_path_options(initial_page: str) -> list[str]: + """Return the personal research path plus an active evidence route.""" + if initial_page in {"Data Health", PROOF_HISTORY_PATH_TITLE}: + return RESEARCH_PATH_PAGE_TITLES + [initial_page] + return RESEARCH_PATH_PAGE_TITLES + + +def research_path_label(page_title: str) -> str: + return page_title if page_title in RESEARCH_PATH_PAGE_TITLES else PUBLIC_PATH_LABELS.get(page_title, page_title) + + def sidebar_path_index(initial_page: str, path_options: list[str], advanced_titles: list[str]) -> int: if initial_page in path_options: return path_options.index(initial_page) @@ -212,11 +263,17 @@ def selected_page_from_route_rail( return selected_path -def route_rail_query_update(*, selected_page: str, initial_page: str, mode: str) -> dict[str, str]: +def route_rail_query_update( + *, + selected_page: str, + initial_page: str, + mode: str, + allowed_pages: list[str] | None = None, +) -> dict[str, str]: selected_page = page_title_from_public_path(selected_page) if selected_page == initial_page: return {} - if selected_page not in PUBLIC_PATH_PAGE_TITLES: + if selected_page not in (allowed_pages or PUBLIC_PATH_PAGE_TITLES): return {} return { "mode": dashboard_page_slug(mode), diff --git a/src/dashboard_render_smoke.py b/src/dashboard_render_smoke.py index 3d8e40a6..ff27c338 100644 --- a/src/dashboard_render_smoke.py +++ b/src/dashboard_render_smoke.py @@ -22,10 +22,31 @@ class DashboardRenderResult: name: str exceptions: tuple[str, ...] missing_markers: tuple[str, ...] + forbidden_markers: tuple[str, ...] = () + expanded_advanced: tuple[str, ...] = () + rendered_blocks: tuple[str, ...] = () @property def passed(self) -> bool: - return not self.exceptions and not self.missing_markers + return not ( + self.exceptions + or self.missing_markers + or self.forbidden_markers + or self.expanded_advanced + ) + + +FORBIDDEN_RENDER_MARKERS: tuple[str, ...] = ( + "Traceback (most recent call last)", + "ArrowInvalid", + "ModuleNotFoundError", + "ImportError:", + "SYN1", + "SYN2", + "SYN3", + "SYN4", + "SYN5", +) PUBLIC_RENDER_ROUTES: tuple[DashboardRenderRoute, ...] = ( @@ -62,8 +83,94 @@ def passed(self) -> bool: ) +RESEARCH_RENDER_ROUTES: tuple[DashboardRenderRoute, ...] = ( + DashboardRenderRoute( + name="Research Desk", + query_params=(("mode", "research"),), + required_markers=("Research Desk", "Focused cohort", "Weekly research summary", "What changed?", "What should I review next?", "Research-only"), + ), + DashboardRenderRoute( + name="Discover", + query_params=(("mode", "research"), ("page", "discover")), + required_markers=("Discover", "Focused cohort", "Which stock can I review?", "Research-only"), + ), + DashboardRenderRoute( + name="Company Workbench", + query_params=( + ("mode", "research"), + ("page", "company-workbench"), + ("ticker", "NVDA"), + ("open", "1"), + ), + required_markers=( + "Company Workbench", + "What Changed", + "Business Trend", + "Valuation", + "Forward View", + "What Remains Withheld", + "Research Conclusion", + "Next Research Task", + "Advanced Evidence", + "Research-only", + ), + ), + DashboardRenderRoute( + name="Monitor", + query_params=(("mode", "research"), ("page", "monitor")), + required_markers=("Monitor", "Research change monitor", "Research-only"), + ), + DashboardRenderRoute( + name="Research Data Health", + query_params=(("mode", "research"), ("page", "data-health"), ("ticker", "NVDA")), + required_markers=( + "Data Health", + "Continue the selected-company review without changing evidence state.", + "Research-only", + ), + ), + DashboardRenderRoute( + name="Research Proof History", + query_params=(("mode", "research"), ("page", "proof-history"), ("ticker", "NVDA")), + required_markers=( + "Proof History", + "Continue the selected-company review without changing evidence state.", + "Research-only", + ), + ), +) + + +def _rendered_blocks(app: AppTest) -> tuple[str, ...]: + collections = ( + "markdown", + "text", + "caption", + "title", + "header", + "subheader", + "error", + "exception", + ) + values: list[str] = [] + for collection in collections: + for item in getattr(app, collection): + value = getattr(item, "value", "") + if value: + values.append(str(value)) + return tuple(values) + + def _rendered_markdown(app: AppTest) -> str: - return "\n".join(item.value for item in app.markdown) + return "\n".join(_rendered_blocks(app)) + + +def _expanded_advanced_sections(app: AppTest) -> tuple[str, ...]: + return tuple( + str(item.label) + for item in app.expander + if str(item.label).lower().startswith("advanced") and bool(item.proto.expanded) + ) def render_public_routes( @@ -80,24 +187,33 @@ def render_public_routes( app = AppTest.from_file(str(dashboard_path), default_timeout=timeout) app.query_params.update(dict(route.query_params)) app.run(timeout=timeout) - rendered = _rendered_markdown(app) + rendered_blocks = _rendered_blocks(app) + rendered = "\n".join(rendered_blocks) exceptions = tuple(str(item.value) for item in app.exception) missing_markers = tuple(marker for marker in route.required_markers if marker not in rendered) + forbidden_markers = tuple(marker for marker in FORBIDDEN_RENDER_MARKERS if marker in rendered) results.append( DashboardRenderResult( name=route.name, exceptions=exceptions, missing_markers=missing_markers, + forbidden_markers=forbidden_markers, + expanded_advanced=_expanded_advanced_sections(app), + rendered_blocks=rendered_blocks, ) ) return results -def render_dashboard_smoke(results: Iterable[DashboardRenderResult]) -> str: +def render_dashboard_smoke( + results: Iterable[DashboardRenderResult], + *, + contract_name: str = "Dashboard render smoke", +) -> str: lines = [ - "Dashboard render smoke", - "Read-only: renders the public route contract with Streamlit AppTest; it does not refresh data, import rows, apply changes, stage files, commit, or push.", + contract_name, + "Read-only: renders the selected route contract with Streamlit AppTest; it does not refresh data, import rows, apply changes, stage files, commit, or push.", ] for result in results: status = "passed" if result.passed else "failed" @@ -106,16 +222,28 @@ def render_dashboard_smoke(results: Iterable[DashboardRenderResult]) -> str: lines.append(f" exceptions: {' | '.join(result.exceptions)}") if result.missing_markers: lines.append(f" missing markers: {', '.join(result.missing_markers)}") + if result.forbidden_markers: + lines.append(f" forbidden markers: {', '.join(result.forbidden_markers)}") + if result.expanded_advanced: + lines.append(f" expanded advanced sections: {', '.join(result.expanded_advanced)}") return "\n".join(lines) def main() -> int: parser = argparse.ArgumentParser(description="Render the public Streamlit routes without starting a long-lived server.") parser.add_argument("--root", default=".", help="Project root containing src/dashboard.py") + parser.add_argument( + "--routes", + choices=("public", "research"), + default="public", + help="Route contract to render.", + ) args = parser.parse_args() - results = render_public_routes(Path(args.root)) - print(render_dashboard_smoke(results)) + routes = RESEARCH_RENDER_ROUTES if args.routes == "research" else PUBLIC_RENDER_ROUTES + contract_name = "Research dashboard render smoke" if args.routes == "research" else "Dashboard render smoke" + results = render_public_routes(Path(args.root), routes=routes) + print(render_dashboard_smoke(results, contract_name=contract_name)) return 0 if all(result.passed for result in results) else 1 diff --git a/src/data_health_overview_console.py b/src/data_health_overview_console.py index 34ab2710..57150dcc 100644 --- a/src/data_health_overview_console.py +++ b/src/data_health_overview_console.py @@ -644,6 +644,20 @@ def auto_refresh_status_cards(status_payload: dict[str, object] | None) -> list[ "Generated CSV/JSON/report churn stays excluded unless intentionally reviewed evidence.", max_chars=190, ) + continuation_gate = payload.get("continuation_gate", {}) + inspection_only = bool( + isinstance(continuation_gate, dict) and continuation_gate.get("suppress_execution") + ) + next_card_command = next_command if inspection_only else next_runbook + next_card_body = ( + "Inspect the saved-versus-proposed readiness impact without writing files. Scheduled operations remain " + "planning context until readiness is current or a separate reviewed rebuild is authorized." + if inspection_only + else ( + "Use the compact runbook for the selected schedule. It keeps validation, preview, apply boundary, " + "proof, and pivot rules visible before any data-changing step." + ) + ) return [ { @@ -669,12 +683,9 @@ def auto_refresh_status_cards(status_payload: dict[str, object] | None) -> list[ { "kicker": "NEXT SCHEDULER STEP", "title": next_command, - "body": ( - "Use the compact runbook for the selected schedule. It keeps validation, preview, apply boundary, " - "proof, and pivot rules visible before any data-changing step." - ), + "body": next_card_body, "badges": ["runbook", "no broad retry loops"], - "command": next_runbook, + "command": next_card_command, }, ] diff --git a/src/data_health_peer_unlock.py b/src/data_health_peer_unlock.py index 07f10856..f5da0173 100644 --- a/src/data_health_peer_unlock.py +++ b/src/data_health_peer_unlock.py @@ -137,7 +137,10 @@ def peer_unlock_operator_cards( "make templates -> make imports-validate IMPORT_TICKERS= -> " "make imports-preview IMPORT_TICKERS= -> make imports-apply IMPORT_TICKERS=", ) - peer_schema = "ticker, peer_ticker, peer_group, sector, industry, source, as_of_date" + peer_schema = ( + "ticker, peer_ticker, peer_group, sector, industry, peer_role, relationship_rationale, " + "comparability_basis, valuation_anchor_eligible, source, as_of_date" + ) priority_text = ", ".join(f"P{int(key)}: {int(value)}" for key, value in priority_counts.head(4).items()) workflow_text = ", ".join(f"{str(key).replace('_', ' ')}: {int(value)}" for key, value in workflow_counts.head(3).items()) scope_text = ", ".join(f"{str(key).replace('_', ' ')}: {int(value)}" for key, value in scope_counts.head(3).items()) diff --git a/src/data_update.py b/src/data_update.py index d82ce70c..5c848d12 100644 --- a/src/data_update.py +++ b/src/data_update.py @@ -5,29 +5,60 @@ import os import re import shutil +import tempfile import time from dataclasses import dataclass, field from datetime import datetime, timezone from io import StringIO from pathlib import Path from time import sleep -from typing import Any, Callable, Protocol +from typing import Any, Callable, Mapping, Protocol from urllib.error import HTTPError, URLError from urllib.parse import urlencode from urllib.request import Request, urlopen import pandas as pd +from src.commercial_source_rights import ( + SourceRights, + commercial_eligibility, + commercial_mode_enabled, + load_source_rights_registry, + review_commercial_field_scope, +) from src.config import AppConfig from src.paths import format_path_context, resolve_data_dir, resolve_outputs_dir, resolve_project_root +from src.price_lineage_temporal import review_daily_price_retrieval from src.provider_env import load_provider_environment from src.providers.alternative_fundamentals import ALPHA_VANTAGE_API_KEY_ENV, FMP_API_KEY_ENV, FINNHUB_API_KEY_ENV PRICE_COLUMNS = ["date", "ticker", "open", "high", "low", "close", "adj_close", "volume"] PRICE_IMPORT_REQUIRED_COLUMNS = ["date", "ticker", "open", "high", "low", "close", "volume"] -PRICE_IMPORT_OPTIONAL_COLUMNS = ["adjusted_close", "adj_close", "source", "as_of_date", "notes"] -PRICE_IMPORT_OUTPUT_COLUMNS = ["date", "ticker", "open", "high", "low", "close", "adj_close", "volume", "source", "as_of_date", "notes"] +PRICE_IMPORT_OPTIONAL_COLUMNS = [ + "adjusted_close", + "adj_close", + "source", + "source_ref", + "retrieved_at", + "as_of_date", + "notes", +] +PRICE_IMPORT_OUTPUT_COLUMNS = [ + "date", + "ticker", + "open", + "high", + "low", + "close", + "adj_close", + "volume", + "source", + "source_ref", + "retrieved_at", + "as_of_date", + "notes", +] PRICE_STATUS_COLUMNS = [ "run_timestamp", "ticker", @@ -141,6 +172,8 @@ def _ibkr_bars_to_price_frame(ticker: str, bars: Any) -> pd.DataFrame: class PriceHistorySource(Protocol): + source_id: str + def fetch_history(self, ticker: str) -> tuple[pd.DataFrame, list[str]]: ... @@ -160,6 +193,8 @@ class PriceUpdateResult: class StooqDailyPriceSource: + source_id = "stooq" + def __init__( self, base_url: str = "https://stooq.com/q/d/l/", @@ -223,6 +258,8 @@ def fetch_history(self, ticker: str) -> tuple[pd.DataFrame, list[str]]: class YahooChartDailyPriceSource: """Unofficial research-grade daily OHLCV source using Yahoo's chart endpoint.""" + source_id = "yahoo" + def __init__( self, base_url: str = "https://query1.finance.yahoo.com/v8/finance/chart", @@ -313,6 +350,8 @@ def fetch_history(self, ticker: str) -> tuple[pd.DataFrame, list[str]]: class FMPDailyPriceSource: """Research-grade daily OHLCV fallback using FMP's historical price endpoint.""" + source_id = "fmp" + def __init__( self, base_url: str = "https://financialmodelingprep.com/api/v3/historical-price-full", @@ -385,6 +424,8 @@ def fetch_history(self, ticker: str) -> tuple[pd.DataFrame, list[str]]: class AlphaVantageDailyPriceSource: """Research-grade daily OHLCV fallback using Alpha Vantage daily adjusted prices.""" + source_id = "alpha_vantage" + def __init__( self, base_url: str = "https://www.alphavantage.co/query", @@ -471,6 +512,8 @@ def fetch_history(self, ticker: str) -> tuple[pd.DataFrame, list[str]]: class FinnhubDailyPriceSource: """Research-grade daily OHLCV fallback using Finnhub's stock candle endpoint.""" + source_id = "finnhub" + def __init__( self, base_url: str = "https://finnhub.io/api/v1/stock/candle", @@ -563,6 +606,7 @@ def fetch_history(self, ticker: str) -> tuple[pd.DataFrame, list[str]]: class IBKRDailyPriceSource: """Read-only daily OHLCV source using IBKR Gateway/TWS historical bars.""" + source_id = "ibkr" provider_name = "ibkr" def __init__( @@ -672,17 +716,29 @@ def fetch_history(self, ticker: str) -> tuple[pd.DataFrame, list[str]]: class PriceSourceLadder: + source_id = "price_source_ladder" + def __init__(self, sources: list[tuple[str, PriceHistorySource]]) -> None: if not sources: raise ValueError("PriceSourceLadder requires at least one source.") + for label, source in sources: + exact_source_id = _price_source_id(source) + if label != exact_source_id: + raise ValueError( + "price source label/source_id mismatch: " + f"label={label!r}, source_id={exact_source_id!r}" + ) self.sources = sources self.provider_names = [name for name, _source in sources] + self.source_ids = tuple(self.provider_names) self.provider_name = "auto" self.last_provider_name = self.provider_name + self.last_source_id = "" def fetch_history(self, ticker: str) -> tuple[pd.DataFrame, list[str]]: warnings: list[str] = [] self.last_provider_name = f"auto:{','.join(self.provider_names)}" + self.last_source_id = "" for index, (provider_name, source) in enumerate(self.sources): try: frame, provider_warnings = source.fetch_history(ticker) @@ -692,6 +748,7 @@ def fetch_history(self, ticker: str) -> tuple[pd.DataFrame, list[str]]: warnings.extend(provider_warnings) if not frame.empty: self.last_provider_name = provider_name + self.last_source_id = _price_source_id(source) if index > 0: warnings.append( f"{ticker}: source ladder resolved price rows from {provider_name} after " @@ -711,24 +768,120 @@ def _price_source_status_name(source: PriceHistorySource) -> str: return source.__class__.__name__ -def make_price_source(provider: str) -> PriceHistorySource: +def _price_source_id(source: PriceHistorySource) -> str: + source_id = str(getattr(source, "source_id", "") or "").strip() + if not source_id: + raise RuntimeError("commercial_price_source_id_required: exact source_id is missing") + return source_id + + +def _require_commercial_price_source( + registry: Mapping[str, SourceRights], + source_id: str, +) -> None: + review = review_commercial_field_scope(registry, source_id, ("prices",)) + if not review.commercial_rights_approved: + raise RuntimeError( + "commercial_price_source_review_required: " + f"source_id={source_id}, rights_status={review.rights_status}" + ) + if review.missing_supported_fields: + raise RuntimeError( + "commercial_price_scope_review_required: " + f"source_id={source_id}, missing_supported_fields=" + f"{','.join(review.missing_supported_fields)}" + ) + + +def _commercial_price_source_is_allowed( + registry: Mapping[str, SourceRights], + source_id: str, +) -> bool: + return review_commercial_field_scope( + registry, source_id, ("prices",) + ).commercial_evidence_ready + + +def _reachable_price_source_ids(source: PriceHistorySource) -> tuple[str, ...]: + if isinstance(source, PriceSourceLadder): + return source.source_ids + return (_price_source_id(source),) + + +def _selected_price_source_id(source: PriceHistorySource) -> str: + if isinstance(source, PriceSourceLadder): + selected = str(source.last_source_id or "").strip() + if not selected: + raise RuntimeError( + "commercial_price_source_id_required: ladder selected source_id is missing" + ) + return selected + return _price_source_id(source) + + +def make_price_source( + provider: str, + *, + commercial_mode: bool | None = None, + rights_registry: Mapping[str, SourceRights] | None = None, +) -> PriceHistorySource: load_provider_environment() normalized = str(provider or "auto").strip().lower() + commercial = commercial_mode_enabled() if commercial_mode is None else commercial_mode + registry = rights_registry + if commercial and registry is None: + registry = load_source_rights_registry() + + exact_provider_ids = { + "stooq": "stooq", + "yahoo": "yahoo", + "fmp": "fmp", + "financial_modeling_prep": "fmp", + "alpha_vantage": "alpha_vantage", + "alphavantage": "alpha_vantage", + "finnhub": "finnhub", + "ibkr": "ibkr", + } + if commercial and normalized not in {"auto", "ladder", "source_ladder"}: + exact_source_id = exact_provider_ids.get(normalized) + if exact_source_id is not None: + assert registry is not None + _require_commercial_price_source(registry, exact_source_id) + if normalized in {"auto", "ladder", "source_ladder"}: - sources: list[tuple[str, PriceHistorySource]] = [ - ("stooq", StooqDailyPriceSource()), - ("yahoo", YahooChartDailyPriceSource()), + source_factories: list[tuple[str, bool, Callable[[], PriceHistorySource]]] = [ + ("stooq", True, StooqDailyPriceSource), + ("yahoo", True, YahooChartDailyPriceSource), + ("ibkr", _ibkr_auto_configured(), IBKRDailyPriceSource), + ("fmp", bool(os.environ.get(FMP_API_KEY_ENV, "").strip()), FMPDailyPriceSource), + ( + "alpha_vantage", + bool(os.environ.get(ALPHA_VANTAGE_API_KEY_ENV, "").strip()), + AlphaVantageDailyPriceSource, + ), + ( + "finnhub", + bool(os.environ.get(FINNHUB_API_KEY_ENV, "").strip()), + FinnhubDailyPriceSource, + ), ] - if _ibkr_auto_configured(): - sources.append(("ibkr", IBKRDailyPriceSource())) - if os.environ.get(FMP_API_KEY_ENV, "").strip(): - sources.append(("fmp", FMPDailyPriceSource())) - if os.environ.get(ALPHA_VANTAGE_API_KEY_ENV, "").strip(): - sources.append(("alpha_vantage", AlphaVantageDailyPriceSource())) - if os.environ.get(FINNHUB_API_KEY_ENV, "").strip(): - sources.append(("finnhub", FinnhubDailyPriceSource())) + configured_factories = [item for item in source_factories if item[1]] + if commercial: + assert registry is not None + allowed_factories = [ + item + for item in configured_factories + if _commercial_price_source_is_allowed(registry, item[0]) + ] + if not allowed_factories: + configured_ids = ",".join(item[0] for item in configured_factories) + raise RuntimeError( + "commercial_price_source_review_required: no configured automatic " + f"price source has approved rights and prices scope; sources={configured_ids}" + ) + configured_factories = allowed_factories return PriceSourceLadder( - sources + [(source_id, factory()) for source_id, _configured, factory in configured_factories] ) if normalized == "stooq": return StooqDailyPriceSource() @@ -1151,13 +1304,28 @@ def update_local_price_data( retry_attempts: int = 1, retry_backoff_seconds: float = 0.25, progress_callback: Callable[[dict[str, object]], None] | None = None, + commercial_mode: bool | None = None, + rights_registry: Mapping[str, SourceRights] | None = None, ) -> PriceUpdateResult: base_dir = resolve_project_root(base_dir) data_dir = resolve_data_dir(data_dir, base_dir) output_dir = resolve_outputs_dir(output_dir, base_dir) + commercial = commercial_mode_enabled() if commercial_mode is None else commercial_mode + registry = rights_registry + if commercial and registry is None: + registry = load_source_rights_registry() + source = source or make_price_source( + "auto", commercial_mode=commercial, rights_registry=registry + ) + reviewed_source_ids: tuple[str, ...] = () + if commercial: + assert registry is not None + reviewed_source_ids = _reachable_price_source_ids(source) + for source_id in reviewed_source_ids: + _require_commercial_price_source(registry, source_id) + config = AppConfig.load(base_dir / "config.yaml") prices_path = data_dir / "prices.csv" - source = source or make_price_source("auto") provider_name = _price_source_status_name(source) run_timestamp = datetime.now(timezone.utc).isoformat() requested_end = pd.Timestamp.now(tz="UTC").date().isoformat() @@ -1236,6 +1404,23 @@ def update_local_price_data( except Exception as exc: # pragma: no cover - defensive runtime path fetch_warnings = [f"{ticker}: update failed ({exc})"] frame = pd.DataFrame(columns=PRICE_COLUMNS) + if commercial: + assert registry is not None + if frame.empty and not isinstance(source, PriceSourceLadder): + current_source_id = _price_source_id(source) + if current_source_id not in reviewed_source_ids: + raise RuntimeError( + "commercial_price_source_changed: " + f"reviewed={','.join(reviewed_source_ids)}, selected={current_source_id}" + ) + if not frame.empty: + selected_source_id = _selected_price_source_id(source) + if selected_source_id not in reviewed_source_ids: + raise RuntimeError( + "commercial_price_source_changed: " + f"reviewed={','.join(reviewed_source_ids)}, selected={selected_source_id}" + ) + _require_commercial_price_source(registry, selected_source_id) if frame.empty and attempt < retry_attempts: sleep(retry_backoff_seconds * (attempt + 1)) continue @@ -1365,7 +1550,182 @@ def _serialize_price_date(value: Any) -> str: return timestamp.date().isoformat() -def _normalize_price_import_frame(frame: pd.DataFrame) -> tuple[pd.DataFrame, dict[str, Any]]: +def _price_temporal_summary( + frame: pd.DataFrame, + *, + review_cutoff: str | None, +) -> tuple[pd.DataFrame, dict[str, Any]]: + if frame.empty: + return frame.copy(), { + "price_temporal_status": "no_valid_rows", + "price_temporal_complete_rows": 0, + "price_temporal_review_required_rows": 0, + "price_temporal_invalid_rows": 0, + "price_temporal_blocker_counts": {}, + "price_review_cutoff": "", + } + + reviewed = frame.copy() + blocker_counts: dict[str, int] = {} + complete_rows = 0 + invalid_rows = 0 + normalized_cutoff = "" + for index, row in reviewed.iterrows(): + temporal = review_daily_price_retrieval( + row.get("date"), + row.get("retrieved_at"), + review_cutoff=review_cutoff, + ) + if temporal.review_cutoff: + normalized_cutoff = temporal.review_cutoff + if not temporal.blockers: + complete_rows += 1 + reviewed.at[index, "retrieved_at"] = temporal.retrieved_at + continue + reviewed.at[index, "retrieved_at"] = temporal.retrieved_at + if temporal.blockers != ("missing_retrieved_at",): + invalid_rows += 1 + for blocker in temporal.blockers: + blocker_counts[blocker] = blocker_counts.get(blocker, 0) + 1 + + required_rows = len(reviewed) - complete_rows + return reviewed, { + "price_temporal_status": ( + "temporal_complete" if required_rows == 0 else "temporal_review_required" + ), + "price_temporal_complete_rows": complete_rows, + "price_temporal_review_required_rows": required_rows, + "price_temporal_invalid_rows": invalid_rows, + "price_temporal_blocker_counts": dict(sorted(blocker_counts.items())), + "price_review_cutoff": normalized_cutoff, + } + + +def _price_lineage_summary(frame: pd.DataFrame) -> dict[str, Any]: + if frame.empty: + return { + "lineage_status": "no_valid_rows", + "lineage_complete_rows": 0, + "lineage_review_required_rows": 0, + "lineage_missing_fields": [], + } + + present: dict[str, pd.Series] = {} + for field in ("source", "source_ref", "retrieved_at"): + if field not in frame.columns: + present[field] = pd.Series(False, index=frame.index, dtype=bool) + continue + values = frame[field].astype("string").str.strip() + present[field] = values.notna() & values.ne("") + + complete_mask = present["source"] & present["source_ref"] & present["retrieved_at"] + complete_rows = int(complete_mask.sum()) + missing_fields = sorted(field for field, field_present in present.items() if not bool(field_present.all())) + return { + "lineage_status": "lineage_complete" if complete_rows == len(frame) else "lineage_review_required", + "lineage_complete_rows": complete_rows, + "lineage_review_required_rows": len(frame) - complete_rows, + "lineage_missing_fields": missing_fields, + } + + +def _price_source_evidence_summary( + frame: pd.DataFrame, + rights_registry: Mapping[str, SourceRights], +) -> dict[str, Any]: + if frame.empty: + return { + "commercial_rights_status": "no_valid_rows", + "rights_approved_rows": 0, + "rights_review_required_rows": 0, + "rights_status_counts": {}, + "price_scope_status": "no_valid_rows", + "price_scope_complete_rows": 0, + "price_scope_review_required_rows": 0, + "source_review_rows": [], + "commercial_evidence_warnings": [], + } + + if "source" in frame.columns: + source_ids = frame["source"].astype("string").fillna("").str.strip() + else: + source_ids = pd.Series("", index=frame.index, dtype="string") + + rights_approved_rows = 0 + price_scope_complete_rows = 0 + rights_status_counts: dict[str, int] = {} + source_review_rows: list[dict[str, Any]] = [] + for source_id in sorted(source_ids.unique().tolist()): + row_count = int(source_ids.eq(source_id).sum()) + rights = commercial_eligibility(rights_registry, source_id) + rights_status_counts[rights.status] = rights_status_counts.get(rights.status, 0) + row_count + rights_approved_rows += row_count if rights.allowed else 0 + rights_record = rights_registry.get(source_id) + price_scope_complete = bool( + rights_record is not None and "prices" in rights_record.supported_fields + ) + price_scope_complete_rows += row_count if price_scope_complete else 0 + blockers: list[str] = [] + if not rights.allowed: + blockers.append(f"commercial_rights:{rights.status}") + if not price_scope_complete: + blockers.append("registered_price_scope_incomplete") + source_review_rows.append( + { + "source_id": source_id or "", + "row_count": row_count, + "rights_status": rights.status, + "commercial_rights_approved": rights.allowed, + "price_scope_complete": price_scope_complete, + "blockers": blockers, + } + ) + + valid_rows = len(frame) + rights_review_required_rows = valid_rows - rights_approved_rows + price_scope_review_required_rows = valid_rows - price_scope_complete_rows + if rights_approved_rows == valid_rows: + commercial_rights_status = "rights_approved" + elif rights_approved_rows == 0: + commercial_rights_status = "rights_review_required" + else: + commercial_rights_status = "mixed_rights" + if price_scope_complete_rows == valid_rows: + price_scope_status = "price_scope_complete" + elif price_scope_complete_rows == 0: + price_scope_status = "price_scope_review_required" + else: + price_scope_status = "mixed_price_scope" + + commercial_evidence_warnings: list[str] = [] + if rights_review_required_rows: + commercial_evidence_warnings.append( + "Commercial rights review required for " + f"{rights_review_required_rows} valid staged price row(s)." + ) + if price_scope_review_required_rows: + commercial_evidence_warnings.append( + "Registered prices scope review required for " + f"{price_scope_review_required_rows} valid staged price row(s)." + ) + return { + "commercial_rights_status": commercial_rights_status, + "rights_approved_rows": rights_approved_rows, + "rights_review_required_rows": rights_review_required_rows, + "rights_status_counts": dict(sorted(rights_status_counts.items())), + "price_scope_status": price_scope_status, + "price_scope_complete_rows": price_scope_complete_rows, + "price_scope_review_required_rows": price_scope_review_required_rows, + "source_review_rows": source_review_rows, + "commercial_evidence_warnings": commercial_evidence_warnings, + } + + +def _normalize_price_import_frame( + frame: pd.DataFrame, + *, + review_cutoff: str | None = None, +) -> tuple[pd.DataFrame, dict[str, Any]]: warnings: list[str] = [] unknown_columns = sorted(set(frame.columns) - set(PRICE_IMPORT_REQUIRED_COLUMNS) - set(PRICE_IMPORT_OPTIONAL_COLUMNS) - {"adj_close"}) if unknown_columns: @@ -1385,12 +1745,21 @@ def _normalize_price_import_frame(frame: pd.DataFrame) -> tuple[pd.DataFrame, di "skipped_rows": len(frame), "duplicate_rows": 0, "affected_tickers": [], + **_price_lineage_summary(pd.DataFrame()), + **_price_temporal_summary( + pd.DataFrame(), review_cutoff=review_cutoff + )[1], }, ) normalized = frame.copy() normalized["ticker"] = _normalize_ticker_series(normalized["ticker"]) normalized["date"] = pd.to_datetime(normalized["date"], errors="coerce", format="mixed") + for text_column in ("source", "source_ref"): + if text_column in normalized.columns: + normalized[text_column] = normalized[text_column].astype("string").str.strip() + if "retrieved_at" in normalized.columns: + normalized["retrieved_at"] = normalized["retrieved_at"].astype("string").fillna("").str.strip() if "as_of_date" in normalized.columns: normalized["as_of_date"] = pd.to_datetime(normalized["as_of_date"], errors="coerce", format="mixed") for numeric_column in ("open", "high", "low", "close", "adj_close", "volume"): @@ -1430,6 +1799,10 @@ def _normalize_price_import_frame(frame: pd.DataFrame) -> tuple[pd.DataFrame, di "skipped_rows": skipped_invalid, "duplicate_rows": 0, "affected_tickers": [], + **_price_lineage_summary(pd.DataFrame()), + **_price_temporal_summary( + pd.DataFrame(), review_cutoff=review_cutoff + )[1], }, ) @@ -1438,15 +1811,28 @@ def _normalize_price_import_frame(frame: pd.DataFrame) -> tuple[pd.DataFrame, di warnings.append(f"Deduplicated {duplicate_rows} duplicate date+ticker staged row(s), keeping the last row.") valid = valid.drop_duplicates(subset=["date", "ticker"], keep="last").copy() + valid, temporal_summary = _price_temporal_summary( + valid, + review_cutoff=review_cutoff, + ) + valid["date"] = valid["date"].apply(_serialize_price_date) if "as_of_date" in valid.columns: valid["as_of_date"] = valid["as_of_date"].apply(_serialize_price_date) output_columns = ["date", "ticker", "open", "high", "low", "close", "adj_close", "volume"] - for optional_column in ("source", "as_of_date", "notes"): + for optional_column in ("source", "source_ref", "retrieved_at", "as_of_date", "notes"): if optional_column in valid.columns: output_columns.append(optional_column) valid = valid.reindex(columns=output_columns) + lineage_summary = _price_lineage_summary(valid) + if lineage_summary["lineage_review_required_rows"]: + warnings.append( + "Price lineage review required for " + f"{lineage_summary['lineage_review_required_rows']} valid row(s); missing or invalid fields: " + + ", ".join(lineage_summary["lineage_missing_fields"]) + + "." + ) status = "valid_with_warnings" if warnings else "valid" return ( valid, @@ -1460,6 +1846,8 @@ def _normalize_price_import_frame(frame: pd.DataFrame) -> tuple[pd.DataFrame, di "skipped_rows": skipped_invalid + duplicate_rows, "duplicate_rows": duplicate_rows, "affected_tickers": sorted(valid["ticker"].dropna().unique().tolist()), + **lineage_summary, + **temporal_summary, }, ) @@ -1469,10 +1857,13 @@ def validate_price_imports( *, data_dir: Path | None = None, import_dir: Path | None = None, + rights_registry: Mapping[str, SourceRights] | None = None, + review_cutoff: str | None = None, ) -> dict[str, Any]: base_dir = resolve_project_root(base_dir) data_dir = resolve_data_dir(data_dir, base_dir) import_dir = _resolve_import_dir(data_dir, import_dir) + rights_registry = rights_registry if rights_registry is not None else load_source_rights_registry() staged_path = import_dir / "prices.csv" if not staged_path.exists(): return { @@ -1487,11 +1878,18 @@ def validate_price_imports( "missing_required_columns": PRICE_IMPORT_REQUIRED_COLUMNS, "unknown_columns": [], "warnings": ["No price import file found at data/imports/prices.csv."], + **_price_lineage_summary(pd.DataFrame()), + **_price_temporal_summary(pd.DataFrame(), review_cutoff=review_cutoff)[1], + **_price_source_evidence_summary(pd.DataFrame(), rights_registry), } staged_frame, read_warnings = _read_price_import(staged_path) - valid_frame, summary = _normalize_price_import_frame(staged_frame) + valid_frame, summary = _normalize_price_import_frame( + staged_frame, + review_cutoff=review_cutoff, + ) return { **summary, + **_price_source_evidence_summary(valid_frame, rights_registry), "staged_path": str(staged_path), "canonical_path": str(data_dir / "prices.csv"), "warnings": read_warnings + summary["warnings"], @@ -1519,15 +1917,41 @@ def preview_price_import_merge( *, data_dir: Path | None = None, import_dir: Path | None = None, + rights_registry: Mapping[str, SourceRights] | None = None, + review_cutoff: str | None = None, ) -> dict[str, Any]: + preview, _ = _prepare_price_import_merge( + base_dir, + data_dir=data_dir, + import_dir=import_dir, + rights_registry=rights_registry, + review_cutoff=review_cutoff, + ) + return preview + + +def _prepare_price_import_merge( + base_dir: Path | None = None, + *, + data_dir: Path | None = None, + import_dir: Path | None = None, + rights_registry: Mapping[str, SourceRights] | None = None, + review_cutoff: str | None = None, +) -> tuple[dict[str, Any], pd.DataFrame]: base_dir = resolve_project_root(base_dir) data_dir = resolve_data_dir(data_dir, base_dir) - validation = validate_price_imports(base_dir, data_dir=data_dir, import_dir=import_dir) + validation = validate_price_imports( + base_dir, + data_dir=data_dir, + import_dir=import_dir, + rights_registry=rights_registry, + review_cutoff=review_cutoff, + ) valid_frame = validation.pop("valid_frame", pd.DataFrame()) if validation["status"] == "no_staged_file": - return {**validation, "new_rows": 0, "updated_rows": 0, "unchanged_rows": 0, "skipped_rows": 0} + return ({**validation, "new_rows": 0, "updated_rows": 0, "unchanged_rows": 0, "skipped_rows": 0}, valid_frame) if validation["status"] == "invalid": - return {**validation, "new_rows": 0, "updated_rows": 0, "unchanged_rows": 0} + return ({**validation, "new_rows": 0, "updated_rows": 0, "unchanged_rows": 0}, valid_frame) canonical = _load_canonical_price_frame(Path(validation["canonical_path"])) canonical_keys = _price_key_series(canonical) if not canonical.empty and {"date", "ticker"}.issubset(canonical.columns) else pd.Series(dtype="object") @@ -1564,14 +1988,17 @@ def preview_price_import_merge( else: unchanged_rows += 1 - return { - **validation, - "new_rows": new_rows, - "updated_rows": updated_rows, - "unchanged_rows": unchanged_rows, - "overwrite_keys": overwrite_keys, - "new_keys": new_keys, - } + return ( + { + **validation, + "new_rows": new_rows, + "updated_rows": updated_rows, + "unchanged_rows": unchanged_rows, + "overwrite_keys": overwrite_keys, + "new_keys": new_keys, + }, + valid_frame, + ) def _backup_price_file(path: Path, data_dir: Path) -> str | None: @@ -1585,21 +2012,82 @@ def _backup_price_file(path: Path, data_dir: Path) -> str | None: return str(backup_path) +def _atomic_write_price_frame(frame: pd.DataFrame, path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{path.name}.", + suffix=".tmp", + dir=path.parent, + ) + temporary_path = Path(temporary_name) + try: + with os.fdopen(descriptor, "w", newline="", encoding="utf-8") as handle: + frame.to_csv(handle, index=False) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary_path, path) + finally: + if temporary_path.exists(): + temporary_path.unlink() + + def apply_price_import_merge( base_dir: Path | None = None, *, data_dir: Path | None = None, import_dir: Path | None = None, backup: bool = True, + commercial_mode: bool | None = None, + rights_registry: Mapping[str, SourceRights] | None = None, + review_cutoff: str | None = None, ) -> dict[str, Any]: base_dir = resolve_project_root(base_dir) data_dir = resolve_data_dir(data_dir, base_dir) - preview = preview_price_import_merge(base_dir, data_dir=data_dir, import_dir=import_dir) + commercial_mode = commercial_mode if commercial_mode is not None else commercial_mode_enabled() + preview, staged = _prepare_price_import_merge( + base_dir, + data_dir=data_dir, + import_dir=import_dir, + rights_registry=rights_registry, + review_cutoff=review_cutoff, + ) if preview["status"] in {"no_staged_file", "invalid"}: - return {**preview, "applied": False, "backup_path": None} + return { + **preview, + "applied": False, + "apply_status": "technical_validation_required", + "apply_blockers": [], + "backup_path": None, + } + + apply_blockers: list[str] = [] + if preview["price_temporal_invalid_rows"]: + apply_blockers.append("price_temporal_review_required") + if commercial_mode: + if preview["lineage_status"] != "lineage_complete": + apply_blockers.append("price_lineage_review_required") + if ( + preview["price_temporal_status"] != "temporal_complete" + and "price_temporal_review_required" not in apply_blockers + ): + apply_blockers.append("price_temporal_review_required") + if preview["commercial_rights_status"] != "rights_approved": + apply_blockers.append("commercial_rights_review_required") + if preview["price_scope_status"] != "price_scope_complete": + apply_blockers.append("registered_price_scope_review_required") + if apply_blockers: + return { + **preview, + "applied": False, + "apply_status": ( + "technical_temporal_validation_required" + if preview["price_temporal_invalid_rows"] + else "commercial_evidence_review_required" + ), + "apply_blockers": apply_blockers, + "backup_path": None, + } - validation = validate_price_imports(base_dir, data_dir=data_dir, import_dir=import_dir) - staged = validation.pop("valid_frame", pd.DataFrame()) canonical_path = Path(preview["canonical_path"]) canonical = _load_canonical_price_frame(canonical_path) output_columns = list(canonical.columns) @@ -1625,11 +2113,12 @@ def apply_price_import_merge( merged = pd.concat([canonical_indexed, new_rows], axis=0).reset_index(drop=True) if {"ticker", "date"}.issubset(merged.columns): merged = merged.sort_values(["ticker", "date"]).reset_index(drop=True) - canonical_path.parent.mkdir(parents=True, exist_ok=True) - merged.to_csv(canonical_path, index=False) + _atomic_write_price_frame(merged, canonical_path) return { **preview, "applied": True, + "apply_status": "applied", + "apply_blockers": [], "backup_path": backup_path, "rows_written": len(merged), } @@ -1702,6 +2191,7 @@ def main() -> None: parser.add_argument("--validate-price-imports", action="store_true", help="Validate data/imports/prices.csv without mutating data/prices.csv.") parser.add_argument("--preview-price-import-merge", action="store_true", help="Preview price import file changes without mutating data/prices.csv.") parser.add_argument("--apply-price-import-merge", action="store_true", help="Apply price import file rows into data/prices.csv with a backup.") + parser.add_argument("--review-cutoff", help="Explicit timezone-aware cutoff for staged price retrieval review.") parser.add_argument("--price-status", action="store_true", help="Display outputs/price_update_status.csv if present.") parser.add_argument("--top-n", type=int, help="Optional cap for human-readable price status rows.") parser.add_argument("--json", action="store_true", help="Print JSON for import/status commands.") @@ -1717,7 +2207,11 @@ def main() -> None: output_dir = resolve_outputs_dir(args.output_dir, project_root) if args.validate_price_imports: - summary = validate_price_imports(project_root, data_dir=data_dir) + summary = validate_price_imports( + project_root, + data_dir=data_dir, + review_cutoff=args.review_cutoff, + ) if args.json: print(json.dumps({key: value for key, value in summary.items() if key != "valid_frame"}, indent=2)) else: @@ -1726,7 +2220,11 @@ def main() -> None: return if args.preview_price_import_merge: - summary = preview_price_import_merge(project_root, data_dir=data_dir) + summary = preview_price_import_merge( + project_root, + data_dir=data_dir, + review_cutoff=args.review_cutoff, + ) if args.json: print(json.dumps(summary, indent=2)) else: @@ -1735,7 +2233,11 @@ def main() -> None: return if args.apply_price_import_merge: - summary = apply_price_import_merge(project_root, data_dir=data_dir) + summary = apply_price_import_merge( + project_root, + data_dir=data_dir, + review_cutoff=args.review_cutoff, + ) if args.json: print(json.dumps(summary, indent=2)) else: diff --git a/src/dcf_price_lineage.py b/src/dcf_price_lineage.py new file mode 100644 index 00000000..642f76f1 --- /dev/null +++ b/src/dcf_price_lineage.py @@ -0,0 +1,304 @@ +"""Fail-closed, read-only price-lineage review for proposed DCF promotions.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Mapping + +import pandas as pd + +from src.commercial_source_rights import SourceRights, commercial_eligibility +from src.loader import normalize_columns +from src.price_lineage_temporal import review_daily_price_retrieval + + +REQUIRED_PRICE_PROVENANCE_FIELDS = ("source", "source_ref", "retrieved_at") +REQUIRED_PRICE_SCOPE_FIELDS = ("prices",) + + +@dataclass(frozen=True) +class DcfPriceLineageEvidence: + ticker: str + observation_date: str + valid_row_count: int + latest_row_count: int + source_id: str + source_reference: str + retrieved_at: str + availability_at: str + review_cutoff: str + temporal_status: str + rights_status: str + missing_provenance_fields: tuple[str, ...] + missing_supported_fields: tuple[str, ...] + blockers: tuple[str, ...] + + +@dataclass(frozen=True) +class DcfPriceLineageReview: + status: str + promotion_count: int + usable_latest_row_count: int + missing_latest_row_count: int + ambiguous_latest_row_count: int + lineage_complete_count: int + lineage_review_required_count: int + temporal_complete_count: int + temporal_review_required_count: int + rights_approved_count: int + rights_review_required_count: int + field_scope_complete_count: int + field_scope_review_required_count: int + source_counts: tuple[tuple[str, int], ...] + rights_status_counts: tuple[tuple[str, int], ...] + evidence_rows: tuple[DcfPriceLineageEvidence, ...] + top_n: int + + +def _truthy(value: object) -> bool: + if isinstance(value, bool): + return value + try: + if pd.isna(value): + return False + except (TypeError, ValueError): + return False + return str(value).strip().lower() in {"true", "1", "yes", "y"} + + +def _text(value: object) -> str: + try: + if pd.isna(value): + return "" + except (TypeError, ValueError): + return "" + return str(value).strip() + + +def _readiness_index(frame: pd.DataFrame) -> dict[str, pd.Series]: + if frame.empty or "ticker" not in frame.columns: + return {} + rows: dict[str, pd.Series] = {} + for _, row in frame.iterrows(): + ticker = _text(row.get("ticker")).upper() + if ticker: + rows[ticker] = row + return rows + + +def _dcf_promotions(saved: pd.DataFrame, proposed: pd.DataFrame) -> tuple[str, ...]: + saved_rows = _readiness_index(saved) + proposed_rows = _readiness_index(proposed) + return tuple( + ticker + for ticker in sorted(proposed_rows) + if not _truthy(saved_rows.get(ticker, pd.Series(dtype=object)).get("dcf_ready")) + and _truthy(proposed_rows[ticker].get("dcf_ready")) + ) + + +def _normalized_price_rows(prices: pd.DataFrame) -> pd.DataFrame: + if prices.empty: + return pd.DataFrame() + frame = prices.copy() + frame.columns = normalize_columns(list(frame.columns)) + if not {"ticker", "date", "close"}.issubset(frame.columns): + return pd.DataFrame() + frame["ticker"] = frame["ticker"].astype("string").str.upper().str.strip() + frame["_lineage_date"] = pd.to_datetime(frame["date"], errors="coerce", format="mixed") + frame["_lineage_close"] = pd.to_numeric(frame["close"], errors="coerce") + return frame.loc[ + frame["ticker"].notna() + & frame["ticker"].ne("") + & frame["_lineage_date"].notna() + & frame["_lineage_close"].notna() + & frame["_lineage_close"].gt(0) + ].copy() + + +def _count_values(values: list[str]) -> tuple[tuple[str, int], ...]: + if not values: + return () + counts = pd.Series(values, dtype="string").value_counts() + return tuple( + sorted( + ((str(name), int(value)) for name, value in counts.items()), + key=lambda item: (-item[1], item[0]), + ) + ) + + +def _selection_failure( + ticker: str, + *, + valid_row_count: int, + latest_row_count: int, + observation_date: str, +) -> DcfPriceLineageEvidence: + ambiguous = latest_row_count > 1 + return DcfPriceLineageEvidence( + ticker=ticker, + observation_date=observation_date, + valid_row_count=valid_row_count, + latest_row_count=latest_row_count, + source_id="" if ambiguous else "", + source_reference="", + retrieved_at="", + availability_at="", + review_cutoff="", + temporal_status="not_evaluated_missing_or_ambiguous_evidence", + rights_status=( + "not_evaluated_ambiguous_evidence" if ambiguous else "not_evaluated_missing_evidence" + ), + missing_provenance_fields=REQUIRED_PRICE_PROVENANCE_FIELDS, + missing_supported_fields=REQUIRED_PRICE_SCOPE_FIELDS, + blockers=("ambiguous_latest_price_row" if ambiguous else "missing_latest_price_row",), + ) + + +def review_dcf_price_lineage( + saved: pd.DataFrame, + proposed: pd.DataFrame, + prices: pd.DataFrame, + *, + rights_registry: Mapping[str, SourceRights], + review_cutoff: str | None = None, + top_n: int = 20, +) -> DcfPriceLineageReview: + """Review the selected latest price row without changing technical readiness.""" + + if top_n < 1: + raise ValueError("top_n must be at least 1") + + promotions = _dcf_promotions(saved, proposed) + if not promotions: + return DcfPriceLineageReview( + status="no_dcf_promotions", + promotion_count=0, + usable_latest_row_count=0, + missing_latest_row_count=0, + ambiguous_latest_row_count=0, + lineage_complete_count=0, + lineage_review_required_count=0, + temporal_complete_count=0, + temporal_review_required_count=0, + rights_approved_count=0, + rights_review_required_count=0, + field_scope_complete_count=0, + field_scope_review_required_count=0, + source_counts=(), + rights_status_counts=(), + evidence_rows=(), + top_n=top_n, + ) + + normalized_prices = _normalized_price_rows(prices) + evidence: list[DcfPriceLineageEvidence] = [] + for ticker in promotions: + ticker_rows = ( + normalized_prices.loc[normalized_prices["ticker"] == ticker].copy() + if not normalized_prices.empty + else pd.DataFrame() + ) + if ticker_rows.empty: + evidence.append( + _selection_failure( + ticker, + valid_row_count=0, + latest_row_count=0, + observation_date="", + ) + ) + continue + + latest_date = ticker_rows["_lineage_date"].max() + latest_rows = ticker_rows.loc[ticker_rows["_lineage_date"] == latest_date] + observation_date = latest_date.date().isoformat() + if len(latest_rows) != 1: + evidence.append( + _selection_failure( + ticker, + valid_row_count=len(ticker_rows), + latest_row_count=len(latest_rows), + observation_date=observation_date, + ) + ) + continue + + row = latest_rows.iloc[0] + source_id = _text(row.get("source")) + source_reference = _text(row.get("source_ref")) + temporal = review_daily_price_retrieval( + observation_date, + row.get("retrieved_at"), + review_cutoff=review_cutoff, + ) + retrieved_at = temporal.retrieved_at + missing_provenance = tuple( + field + for field, value in ( + ("source", source_id), + ("source_ref", source_reference), + ("retrieved_at", retrieved_at), + ) + if not value + ) + rights = commercial_eligibility(rights_registry, source_id) + rights_record = rights_registry.get(source_id) + supported_fields = set(rights_record.supported_fields) if rights_record is not None else set() + missing_supported = tuple( + field for field in REQUIRED_PRICE_SCOPE_FIELDS if field not in supported_fields + ) + blockers: list[str] = [] + blockers.extend(f"missing_provenance:{field}" for field in missing_provenance) + blockers.extend(temporal.blockers) + if not rights.allowed: + blockers.append(f"commercial_rights:{rights.status}") + if missing_supported: + blockers.append("registered_price_scope_incomplete") + evidence.append( + DcfPriceLineageEvidence( + ticker=ticker, + observation_date=observation_date, + valid_row_count=len(ticker_rows), + latest_row_count=1, + source_id=source_id or "", + source_reference=source_reference, + retrieved_at=retrieved_at, + availability_at=temporal.availability_at, + review_cutoff=temporal.review_cutoff, + temporal_status=temporal.status, + rights_status=rights.status, + missing_provenance_fields=missing_provenance, + missing_supported_fields=missing_supported, + blockers=tuple(blockers), + ) + ) + + usable_latest = sum(item.latest_row_count == 1 for item in evidence) + missing_latest = sum(item.latest_row_count == 0 for item in evidence) + ambiguous_latest = sum(item.latest_row_count > 1 for item in evidence) + lineage_complete = sum(not item.missing_provenance_fields for item in evidence) + temporal_complete = sum(item.temporal_status == "temporal_complete" for item in evidence) + rights_approved = sum(item.rights_status == "approved" for item in evidence) + field_scope_complete = sum(not item.missing_supported_fields for item in evidence) + complete = all(not item.blockers for item in evidence) + return DcfPriceLineageReview( + status="price_lineage_review_complete" if complete else "price_lineage_review_required", + promotion_count=len(evidence), + usable_latest_row_count=usable_latest, + missing_latest_row_count=missing_latest, + ambiguous_latest_row_count=ambiguous_latest, + lineage_complete_count=lineage_complete, + lineage_review_required_count=len(evidence) - lineage_complete, + temporal_complete_count=temporal_complete, + temporal_review_required_count=len(evidence) - temporal_complete, + rights_approved_count=rights_approved, + rights_review_required_count=len(evidence) - rights_approved, + field_scope_complete_count=field_scope_complete, + field_scope_review_required_count=len(evidence) - field_scope_complete, + source_counts=_count_values([item.source_id for item in evidence]), + rights_status_counts=_count_values([item.rights_status for item in evidence]), + evidence_rows=tuple(evidence[:top_n]), + top_n=top_n, + ) diff --git a/src/earnings_consensus_collector.py b/src/earnings_consensus_collector.py new file mode 100644 index 00000000..787a5d14 --- /dev/null +++ b/src/earnings_consensus_collector.py @@ -0,0 +1,683 @@ +"""Append-only prospective consensus snapshot collection contract.""" + +from __future__ import annotations + +import argparse +import csv +import hashlib +import json +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Mapping, Sequence + +from src.commercial_source_rights import ( + SourceRights, + commercial_mode_enabled, + load_source_rights_registry, + review_commercial_field_scope, +) +from src.earnings_nowcast_contract import ConsensusSnapshot, parse_utc_timestamp + + +SCHEMA_VERSION = "earnings-consensus-prospective-v1" +FIELDS = ( + "schema_version", "snapshot_id", "ticker", "fiscal_period", "snapshot_at", "retrieved_at", + "source", "source_ref", "revenue_consensus", "eps_consensus", "revenue_currency", + "revenue_unit_scale", "revenue_basis", "eps_currency", "eps_basis", "eps_share_basis", + "eps_operations_basis", "split_adjustment_basis", "expected_report_date", "review_state", + "supersedes_snapshot_id", +) + + +@dataclass(frozen=True) +class ProspectiveConsensusRecord: + schema_version: str + snapshot_id: str + ticker: str + fiscal_period: str + snapshot_at: str + retrieved_at: str + source: str + source_ref: str + revenue_consensus: str + eps_consensus: str + revenue_currency: str + revenue_unit_scale: str + revenue_basis: str + eps_currency: str + eps_basis: str + eps_share_basis: str + eps_operations_basis: str + split_adjustment_basis: str + expected_report_date: str + review_state: str + supersedes_snapshot_id: str + + +@dataclass(frozen=True) +class CollectionPreview: + state: str + reason: str + write_allowed: bool + snapshot_identity: str + rights_status: str + commercial_rights_approved: bool + required_supported_fields: tuple[str, ...] + missing_supported_fields: tuple[str, ...] + commercial_evidence_ready: bool + commercial_write_allowed: bool + commercial_blockers: tuple[str, ...] + + +@dataclass(frozen=True) +class BatchCollectionPreview: + mode: str + write_performed: bool + state: str + review_cutoff: str + commercial_mode: bool + ledger_digest: str + input_digest: str + preview_receipt: str + row_count: int + reviewable_count: int + technical_write_allowed: bool + commercial_evidence_ready: bool + commercial_write_allowed: bool + technical_blockers: tuple[str, ...] + commercial_blockers: tuple[str, ...] + rows: tuple[CollectionPreview, ...] + + +@dataclass(frozen=True) +class CollectionPlan: + mode: str + cadence: str + as_of: str + tickers: tuple[str, ...] + collection_performed: bool + next_action: str + + +def snapshot_identity(record: ProspectiveConsensusRecord) -> str: + payload = { + field: getattr(record, field) + for field in FIELDS + if field not in {"snapshot_id", "supersedes_snapshot_id"} + } + return hashlib.sha256( + json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + + +def _records_digest(records: Sequence[ProspectiveConsensusRecord]) -> str: + payload = [asdict(record) for record in records] + return hashlib.sha256( + json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + + +def _normalized_cutoff(as_of: str) -> str: + return parse_utc_timestamp(as_of, label="collection cutoff").isoformat() + + +def _preview_receipt( + *, + review_cutoff: str, + commercial_mode: bool, + ledger_digest: str, + input_digest: str, +) -> str: + payload = { + "schema_version": SCHEMA_VERSION, + "review_cutoff": review_cutoff, + "commercial_mode": commercial_mode, + "ledger_digest": ledger_digest, + "input_digest": input_digest, + } + return hashlib.sha256( + json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + + +def _read_snapshot_rows(path: Path | str) -> tuple[ProspectiveConsensusRecord, ...]: + source = Path(path) + if not source.is_file(): + return () + with source.open(newline="", encoding="utf-8") as handle: + reader = csv.DictReader(handle) + if tuple(reader.fieldnames or ()) != FIELDS: + raise ValueError("Consensus snapshot ledger header does not match the append-only contract.") + return tuple( + ProspectiveConsensusRecord(**{field: str(row.get(field) or "").strip() for field in FIELDS}) + for row in reader + ) + + +def load_snapshots(path: Path | str) -> tuple[ProspectiveConsensusRecord, ...]: + rows = _read_snapshot_rows(path) + _validate_ledger_integrity(rows) + return rows + + +def load_proposed_snapshots(path: Path | str) -> tuple[ProspectiveConsensusRecord, ...]: + rows = _read_snapshot_rows(path) + for row_number, row in enumerate(rows, start=2): + try: + _validate(row) + except ValueError as exc: + raise ValueError(f"input row {row_number}: {exc}") from exc + return rows + + +def _validate(record: ProspectiveConsensusRecord) -> None: + if record.schema_version != SCHEMA_VERSION: + raise ValueError(f"schema_version must be {SCHEMA_VERSION}") + for field in FIELDS: + if field in {"revenue_consensus", "eps_consensus", "supersedes_snapshot_id"}: + continue + if not str(getattr(record, field) or "").strip(): + raise ValueError(f"{field} is required") + parse_utc_timestamp(record.snapshot_at, label="snapshot_at") + parse_utc_timestamp(record.retrieved_at, label="retrieved_at") + if parse_utc_timestamp(record.snapshot_at) > parse_utc_timestamp(record.retrieved_at): + raise ValueError("snapshot_at cannot be after retrieved_at") + if not record.revenue_consensus and not record.eps_consensus: + raise ValueError("at least one consensus metric is required") + if record.review_state != "reviewed": + raise ValueError("review_state must be reviewed") + ConsensusSnapshot( + ticker=record.ticker, + fiscal_period=record.fiscal_period, + snapshot_at=record.snapshot_at, + revenue_consensus=float(record.revenue_consensus) if record.revenue_consensus else None, + eps_consensus=float(record.eps_consensus) if record.eps_consensus else None, + source=record.source, + retrieved_at=record.retrieved_at, + source_ref=record.source_ref, + revenue_currency=record.revenue_currency, + revenue_unit_scale=float(record.revenue_unit_scale), + revenue_basis=record.revenue_basis, + eps_currency=record.eps_currency, + eps_basis=record.eps_basis, + eps_share_basis=record.eps_share_basis, + eps_operations_basis=record.eps_operations_basis, + split_adjustment_basis=record.split_adjustment_basis, + expected_report_date=record.expected_report_date, + ) + + +def _validate_ledger_integrity(rows: Sequence[ProspectiveConsensusRecord]) -> None: + records = tuple(rows) + for row_number, row in enumerate(records, start=2): + try: + _validate(row) + except ValueError as exc: + raise ValueError(f"ledger row {row_number}: {exc}") from exc + + by_id: dict[str, tuple[int, ProspectiveConsensusRecord]] = {} + identities: dict[str, int] = {} + for index, row in enumerate(records): + if row.snapshot_id in by_id: + prior_index = by_id[row.snapshot_id][0] + raise ValueError( + f"ledger row {index + 2}: duplicate snapshot_id {row.snapshot_id} " + f"already appears at ledger row {prior_index + 2}" + ) + identity = snapshot_identity(row) + if identity in identities: + raise ValueError( + f"ledger row {index + 2}: duplicate snapshot identity already appears " + f"at ledger row {identities[identity] + 2}" + ) + by_id[row.snapshot_id] = (index, row) + identities[identity] = index + + scopes: dict[tuple[str, str], list[tuple[int, ProspectiveConsensusRecord]]] = {} + for index, row in enumerate(records): + scope = (row.ticker.upper(), row.fiscal_period.upper()) + scopes.setdefault(scope, []).append((index, row)) + + for scope, scoped_rows in scopes.items(): + scoped_ids = {row.snapshot_id for _, row in scoped_rows} + children: dict[str, list[str]] = {snapshot_id: [] for snapshot_id in scoped_ids} + roots: list[str] = [] + for index, row in scoped_rows: + parent_id = row.supersedes_snapshot_id + if not parent_id: + roots.append(row.snapshot_id) + continue + parent_entry = by_id.get(parent_id) + if parent_entry is None: + raise ValueError( + f"ledger row {index + 2}: missing parent snapshot {parent_id}" + ) + parent_index, parent = parent_entry + if (parent.ticker.upper(), parent.fiscal_period.upper()) != scope: + raise ValueError( + f"ledger row {index + 2}: revision parent must preserve ticker and fiscal period" + ) + children[parent_id].append(row.snapshot_id) + + if not roots: + raise ValueError( + f"ledger scope {scope[0]} {scope[1]} contains a revision cycle and has no root" + ) + if len(roots) != 1: + raise ValueError( + f"ledger scope {scope[0]} {scope[1]} must contain exactly one root" + ) + for parent_id, child_ids in children.items(): + if len(child_ids) > 1: + raise ValueError( + f"ledger scope {scope[0]} {scope[1]} contains a revision fork at {parent_id}" + ) + + visited: set[str] = set() + current_id = roots[0] + while current_id: + if current_id in visited: + raise ValueError( + f"ledger scope {scope[0]} {scope[1]} contains a revision cycle" + ) + visited.add(current_id) + current_children = children[current_id] + current_id = current_children[0] if current_children else "" + if visited != scoped_ids: + raise ValueError( + f"ledger scope {scope[0]} {scope[1]} contains a disconnected revision cycle" + ) + + for index, row in scoped_rows: + if not row.supersedes_snapshot_id: + continue + parent_index, parent = by_id[row.supersedes_snapshot_id] + if parent_index >= index: + raise ValueError( + f"ledger row {index + 2}: revision parent must appear earlier in append order" + ) + if ( + parse_utc_timestamp(row.snapshot_at) <= parse_utc_timestamp(parent.snapshot_at) + or parse_utc_timestamp(row.retrieved_at) <= parse_utc_timestamp(parent.retrieved_at) + ): + raise ValueError( + f"ledger row {index + 2}: revision timestamps must be later than parent" + ) + + +def _collection_preview( + state: str, + reason: str, + write_allowed: bool, + proposed: ProspectiveConsensusRecord, + rights_registry: Mapping[str, SourceRights], +) -> CollectionPreview: + required_supported_fields = tuple( + field + for field in ("revenue_consensus", "eps_consensus") + if str(getattr(proposed, field) or "").strip() + ) + commercial_review = review_commercial_field_scope( + rights_registry, + proposed.source, + required_supported_fields, + ) + commercial_blockers: list[str] = [] + if not commercial_review.commercial_rights_approved: + commercial_blockers.append( + f"commercial_rights:{commercial_review.rights_status}" + ) + commercial_blockers.extend( + f"registered_consensus_scope_missing:{field}" + for field in commercial_review.missing_supported_fields + ) + return CollectionPreview( + state=state, + reason=reason, + write_allowed=write_allowed, + snapshot_identity=snapshot_identity(proposed), + rights_status=commercial_review.rights_status, + commercial_rights_approved=commercial_review.commercial_rights_approved, + required_supported_fields=commercial_review.required_supported_fields, + missing_supported_fields=commercial_review.missing_supported_fields, + commercial_evidence_ready=commercial_review.commercial_evidence_ready, + commercial_write_allowed=( + write_allowed and commercial_review.commercial_evidence_ready + ), + commercial_blockers=tuple(commercial_blockers), + ) + + +def preview_collection( + existing: Sequence[ProspectiveConsensusRecord], + proposed: ProspectiveConsensusRecord, + *, + as_of: str, + cooldown_hours: int = 0, + rights_registry: Mapping[str, SourceRights] | None = None, +) -> CollectionPreview: + rights_registry = rights_registry or load_source_rights_registry() + try: + _validate_ledger_integrity(existing) + except ValueError as exc: + return _collection_preview( + "rejected", + f"existing ledger invalid: {exc}", + False, + proposed, + rights_registry, + ) + try: + _validate(proposed) + except ValueError as exc: + return _collection_preview("rejected", str(exc), False, proposed, rights_registry) + boundary = parse_utc_timestamp(as_of, label="collection cutoff") + if parse_utc_timestamp(proposed.snapshot_at) > boundary or parse_utc_timestamp(proposed.retrieved_at) > boundary: + return _collection_preview( + "rejected", + "snapshot or retrieval timestamp is after the collection cutoff", + False, + proposed, + rights_registry, + ) + if any(row.snapshot_id == proposed.snapshot_id or snapshot_identity(row) == snapshot_identity(proposed) for row in existing): + return _collection_preview( + "duplicate", "identical immutable snapshot already exists", False, proposed, rights_registry + ) + same_scope = [ + row for row in existing + if row.ticker.upper() == proposed.ticker.upper() and row.fiscal_period.upper() == proposed.fiscal_period.upper() + ] + if same_scope and cooldown_hours: + latest = max(parse_utc_timestamp(row.retrieved_at) for row in same_scope) + elapsed = (parse_utc_timestamp(proposed.retrieved_at) - latest).total_seconds() / 3600 + if elapsed < cooldown_hours: + return _collection_preview( + "cooldown", + f"latest snapshot is only {elapsed:.1f} hours old", + False, + proposed, + rights_registry, + ) + if proposed.supersedes_snapshot_id: + target = next((row for row in existing if row.snapshot_id == proposed.supersedes_snapshot_id), None) + if target is None: + return _collection_preview( + "rejected", "supersedes_snapshot_id does not exist", False, proposed, rights_registry + ) + if (target.ticker.upper(), target.fiscal_period.upper()) != (proposed.ticker.upper(), proposed.fiscal_period.upper()): + return _collection_preview( + "rejected", "revision must preserve ticker and fiscal period", False, proposed, rights_registry + ) + if any( + row.supersedes_snapshot_id == target.snapshot_id for row in existing + ): + return _collection_preview( + "rejected", + "supersedes_snapshot_id must identify the current leaf snapshot", + False, + proposed, + rights_registry, + ) + if ( + parse_utc_timestamp(proposed.snapshot_at) <= parse_utc_timestamp(target.snapshot_at) + or parse_utc_timestamp(proposed.retrieved_at) <= parse_utc_timestamp(target.retrieved_at) + ): + return _collection_preview( + "rejected", + "revision snapshot and retrieval timestamps must be later than parent", + False, + proposed, + rights_registry, + ) + return _collection_preview( + "reviewable_revision", + "append-only revision preserves the prior snapshot", + True, + proposed, + rights_registry, + ) + if same_scope: + return _collection_preview( + "rejected", + "later same-period snapshots must identify supersedes_snapshot_id", + False, + proposed, + rights_registry, + ) + return _collection_preview( + "reviewable_new", "new reviewed point-in-time snapshot", True, proposed, rights_registry + ) + + +def preview_collection_batch( + existing: Sequence[ProspectiveConsensusRecord], + proposed: Sequence[ProspectiveConsensusRecord], + *, + as_of: str, + cooldown_hours: int = 0, + commercial_mode: bool | None = None, + rights_registry: Mapping[str, SourceRights] | None = None, +) -> BatchCollectionPreview: + rights_registry = rights_registry or load_source_rights_registry() + commercial_mode = commercial_mode if commercial_mode is not None else commercial_mode_enabled() + _validate_ledger_integrity(existing) + review_cutoff = _normalized_cutoff(as_of) + proposed_rows = tuple(proposed) + ledger_digest = _records_digest(existing) + input_digest = _records_digest(proposed_rows) + receipt = _preview_receipt( + review_cutoff=review_cutoff, + commercial_mode=commercial_mode, + ledger_digest=ledger_digest, + input_digest=input_digest, + ) + if not proposed_rows: + return BatchCollectionPreview( + mode="preview_only", + write_performed=False, + state="empty_batch", + review_cutoff=review_cutoff, + commercial_mode=commercial_mode, + ledger_digest=ledger_digest, + input_digest=input_digest, + preview_receipt=receipt, + row_count=0, + reviewable_count=0, + technical_write_allowed=False, + commercial_evidence_ready=False, + commercial_write_allowed=False, + technical_blockers=("batch:empty_input",), + commercial_blockers=("batch:empty_input",), + rows=(), + ) + + virtual_ledger = list(existing) + row_previews: list[CollectionPreview] = [] + technical_blockers: list[str] = [] + commercial_blockers: list[str] = [] + for index, row in enumerate(proposed_rows, start=1): + row_preview = preview_collection( + virtual_ledger, + row, + as_of=review_cutoff, + cooldown_hours=cooldown_hours, + rights_registry=rights_registry, + ) + row_previews.append(row_preview) + if row_preview.write_allowed: + virtual_ledger.append(row) + else: + technical_blockers.append( + f"row_{index}:{row_preview.state}:{row_preview.reason}" + ) + commercial_blockers.extend( + f"row_{index}:{blocker}" for blocker in row_preview.commercial_blockers + ) + + technical_write_allowed = not technical_blockers + commercial_evidence_ready = all( + row_preview.commercial_evidence_ready for row_preview in row_previews + ) + return BatchCollectionPreview( + mode="preview_only", + write_performed=False, + state="reviewable_batch" if technical_write_allowed else "rejected_batch", + review_cutoff=review_cutoff, + commercial_mode=commercial_mode, + ledger_digest=ledger_digest, + input_digest=input_digest, + preview_receipt=receipt, + row_count=len(proposed_rows), + reviewable_count=sum(row_preview.write_allowed for row_preview in row_previews), + technical_write_allowed=technical_write_allowed, + commercial_evidence_ready=commercial_evidence_ready, + commercial_write_allowed=technical_write_allowed and commercial_evidence_ready, + technical_blockers=tuple(technical_blockers), + commercial_blockers=tuple(commercial_blockers), + rows=tuple(row_previews), + ) + + +def append_reviewed_batch( + path: Path | str, + records: Sequence[ProspectiveConsensusRecord], + *, + confirm_reviewed: bool, + commercial_mode: bool | None = None, + rights_registry: Mapping[str, SourceRights] | None = None, + review_cutoff: str | None = None, + preview_receipt: str | None = None, +) -> Path: + if not confirm_reviewed: + raise ValueError("confirm_reviewed is required before append") + if not review_cutoff: + raise ValueError("review_cutoff is required and must match the reviewed preview") + if not preview_receipt: + raise ValueError("preview_receipt is required and must match the reviewed preview") + destination = Path(path) + existing = load_snapshots(destination) + proposed = tuple(records) + rights_registry = rights_registry or load_source_rights_registry() + commercial_mode = commercial_mode if commercial_mode is not None else commercial_mode_enabled() + preview = preview_collection_batch( + existing, + proposed, + as_of=review_cutoff, + commercial_mode=commercial_mode, + rights_registry=rights_registry, + ) + if preview.preview_receipt != preview_receipt: + raise ValueError("preview receipt mismatch: input, cutoff, ledger, or commercial mode changed") + if not preview.technical_write_allowed: + raise ValueError( + f"{preview.state}: " + "; ".join(preview.technical_blockers) + ) + if commercial_mode and not preview.commercial_write_allowed: + raise ValueError( + "batch_commercial_evidence_review_required: " + + "; ".join(preview.commercial_blockers) + ) + destination.parent.mkdir(parents=True, exist_ok=True) + exists = destination.exists() and destination.stat().st_size > 0 + with destination.open("a", newline="", encoding="utf-8") as handle: + writer = csv.DictWriter(handle, fieldnames=FIELDS) + if not exists: + writer.writeheader() + writer.writerows(asdict(record) for record in proposed) + return destination + + +def append_reviewed_snapshot( + path: Path | str, + record: ProspectiveConsensusRecord, + *, + confirm_reviewed: bool, + commercial_mode: bool | None = None, + rights_registry: Mapping[str, SourceRights] | None = None, + review_cutoff: str | None = None, + preview_receipt: str | None = None, +) -> Path: + return append_reviewed_batch( + path, + (record,), + confirm_reviewed=confirm_reviewed, + commercial_mode=commercial_mode, + rights_registry=rights_registry, + review_cutoff=review_cutoff, + preview_receipt=preview_receipt, + ) + + +def collection_plan(*, tickers: Sequence[str], as_of: str, cadence: str) -> CollectionPlan: + parse_utc_timestamp(as_of, label="plan cutoff") + if cadence not in {"weekly", "pre_earnings"}: + raise ValueError("cadence must be weekly or pre_earnings") + normalized = tuple(sorted({str(ticker).strip().upper() for ticker in tickers if str(ticker).strip()})) + return CollectionPlan( + mode="plan_only", + cadence=cadence, + as_of=as_of, + tickers=normalized, + collection_performed=False, + next_action="Run a provider probe or reviewed CSV preview at the planned cutoff.", + ) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Plan or inspect append-only prospective consensus collection.") + subparsers = parser.add_subparsers(dest="command", required=True) + plan = subparsers.add_parser("plan") + plan.add_argument("--tickers", default="NVDA,AMD,AVGO,MU,QCOM") + plan.add_argument("--as-of", default=datetime.now(timezone.utc).isoformat()) + plan.add_argument("--cadence", choices=("weekly", "pre_earnings"), default="weekly") + status = subparsers.add_parser("status") + status.add_argument("--ledger", default="data/imports/earnings_nowcast/prospective_consensus.csv") + preview = subparsers.add_parser("preview") + preview.add_argument("--input", required=True) + preview.add_argument("--ledger", default="data/imports/earnings_nowcast/prospective_consensus.csv") + preview.add_argument("--as-of", required=True) + record = subparsers.add_parser("record") + record.add_argument("--input", required=True) + record.add_argument("--ledger", default="data/imports/earnings_nowcast/prospective_consensus.csv") + record.add_argument("--as-of", required=True) + record.add_argument("--preview-receipt", required=True) + record.add_argument("--confirm-reviewed", action="store_true") + args = parser.parse_args(argv) + if args.command == "plan": + result = collection_plan( + tickers=tuple(value for value in args.tickers.split(",") if value.strip()), + as_of=args.as_of, + cadence=args.cadence, + ) + print(json.dumps(asdict(result), indent=2, sort_keys=True)) + elif args.command == "status": + rows = load_snapshots(args.ledger) + print(json.dumps({"mode": "read_only", "snapshot_count": len(rows), "ledger": args.ledger}, indent=2)) + elif args.command == "preview": + existing = load_snapshots(args.ledger) + proposed = load_proposed_snapshots(args.input) + result = preview_collection_batch( + existing, + proposed, + as_of=args.as_of, + commercial_mode=commercial_mode_enabled(), + ) + print(json.dumps(asdict(result), indent=2, sort_keys=True)) + else: + if not args.confirm_reviewed: + raise ValueError("record requires --confirm-reviewed after preview and source review") + append_reviewed_batch( + args.ledger, + load_proposed_snapshots(args.input), + confirm_reviewed=True, + review_cutoff=args.as_of, + preview_receipt=args.preview_receipt, + ) + print(f"Appended reviewed prospective snapshots to {args.ledger}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/earnings_consensus_sources.py b/src/earnings_consensus_sources.py new file mode 100644 index 00000000..e9b9e3a8 --- /dev/null +++ b/src/earnings_consensus_sources.py @@ -0,0 +1,380 @@ +"""Fail-closed source activation contract for Earnings Nowcast consensus evidence.""" + +from __future__ import annotations + +import argparse +import csv +import json +import os +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Mapping, Sequence + +from src.commercial_source_rights import ( + SourceRights, + load_source_rights_registry, + review_commercial_field_scope, +) +from src.earnings_nowcast_contract import ConsensusSnapshot, parse_utc_timestamp + + +PROVIDER_ORDER = ("alpha_vantage", "fmp", "finnhub", "reviewed_csv") +KEYS = { + "alpha_vantage": "ALPHA_VANTAGE_API_KEY", + "fmp": "FMP_API_KEY", + "finnhub": "FINNHUB_API_KEY", +} +HISTORICAL_REQUIRED = ( + "ticker", "fiscal_period", "snapshot_at", "retrieved_at", "source_ref", + "revenue_currency", "revenue_unit_scale", "revenue_basis", "eps_currency", + "eps_basis", "eps_share_basis", "eps_operations_basis", "split_adjustment_basis", +) + + +@dataclass(frozen=True) +class ConsensusSourceStatus: + provider: str + status: str + reason: str + source_usage: str + auto_apply: bool = False + + +@dataclass(frozen=True) +class SourceCommercialReview: + row_number: int + required_supported_fields: tuple[str, ...] + missing_supported_fields: tuple[str, ...] + commercial_evidence_ready: bool + commercial_blockers: tuple[str, ...] + + +@dataclass(frozen=True) +class SourceValidationResult: + provider: str + review_cutoff: str + state: str + accepted_count: int + rejected_count: int + historical_snapshot_count: int + candidate_context_count: int + rejected_rows: tuple[dict[str, object], ...] + rights_status: str + commercial_rights_approved: bool + commercial_ready_count: int + commercial_review_required_count: int + commercial_evidence_ready: bool + commercial_blockers: tuple[str, ...] + commercial_review_rows: tuple[SourceCommercialReview, ...] + auto_apply: bool = False + + +def load_source_review_csv(path: Path | str) -> tuple[dict[str, object], ...]: + """Load supplied consensus source rows without normalizing or writing evidence.""" + + review_path = Path(path) + try: + with review_path.open("r", encoding="utf-8", newline="") as handle: + reader = csv.DictReader(handle, strict=True) + if reader.fieldnames is None: + raise ValueError("consensus source review CSV must contain a header row") + fieldnames = tuple(str(field or "") for field in reader.fieldnames) + if ( + any(not field.strip() for field in fieldnames) + or len(set(fieldnames)) != len(fieldnames) + ): + raise ValueError( + "consensus source review CSV headers must be non-blank unique column names" + ) + rows: list[dict[str, object]] = [] + for row_number, row in enumerate(reader, start=1): + if None in row: + raise ValueError( + f"consensus source review CSV row {row_number} has more values than the header" + ) + rows.append({str(key): value for key, value in row.items()}) + return tuple(rows) + except ValueError: + raise + except (OSError, UnicodeError, csv.Error) as exc: + raise ValueError( + f"cannot read consensus source review CSV: {review_path}" + ) from exc + + +def _joined(values: Sequence[object]) -> str: + return ",".join(str(value) for value in values) if values else "none" + + +def render_source_validation_result(result: SourceValidationResult) -> str: + """Render a complete source review without converting it into activation.""" + + lines = [ + "Consensus Source Review", + "Read-only: this command does not fetch, normalize, record, apply, rebuild readiness, or write artifacts.", + f"provider: {result.provider or '-'}", + f"review_cutoff: {result.review_cutoff}", + f"state: {result.state}", + f"accepted_count: {result.accepted_count}", + f"rejected_count: {result.rejected_count}", + f"historical_snapshot_count: {result.historical_snapshot_count}", + f"candidate_context_count: {result.candidate_context_count}", + f"rights_status: {result.rights_status}", + f"commercial_rights_approved: {str(result.commercial_rights_approved).lower()}", + f"commercial_ready_count: {result.commercial_ready_count}", + f"commercial_review_required_count: {result.commercial_review_required_count}", + f"commercial_evidence_ready: {str(result.commercial_evidence_ready).lower()}", + f"commercial_blockers: {_joined(result.commercial_blockers)}", + "rejected_rows:", + ] + if result.rejected_rows: + lines.extend( + f"- row {row['row_number']}: {row['reason']}" + for row in result.rejected_rows + ) + else: + lines.append("- none") + lines.append("commercial_review_rows:") + if result.commercial_review_rows: + lines.extend( + "- row " + f"{row.row_number}: required={_joined(row.required_supported_fields)}; " + f"missing={_joined(row.missing_supported_fields)}; " + f"ready={str(row.commercial_evidence_ready).lower()}; " + f"blockers={_joined(row.commercial_blockers)}" + for row in result.commercial_review_rows + ) + else: + lines.append("- none") + lines.extend( + [ + f"auto_apply: {str(result.auto_apply).lower()}", + "next_gate: collection preview remains a separate reviewed gate after the payload and evidence are accepted.", + "Boundary: reviewability is not collection, activation, readiness, backtesting, calibration, or investment advice.", + ] + ) + return "\n".join(lines) + + +def consensus_source_statuses( + *, + env: Mapping[str, str] | None = None, + generic_csv: Path | str | None = None, +) -> tuple[ConsensusSourceStatus, ...]: + values = env if env is not None else os.environ + rows: list[ConsensusSourceStatus] = [] + for provider in PROVIDER_ORDER[:-1]: + key = KEYS[provider] + configured = bool(str(values.get(key, "")).strip()) + rows.append( + ConsensusSourceStatus( + provider, + "configured_unverified" if configured else "external_key_required", + "Provider key is configured; payload rights and point-in-time history still require validation." + if configured + else f"{key} is not configured.", + "probe_only", + ) + ) + path = Path(generic_csv) if generic_csv else None + rows.append( + ConsensusSourceStatus( + "reviewed_csv", + "reviewed_csv_available" if path and path.is_file() else "external_data_required", + f"Reviewed local CSV is available at {path}." if path and path.is_file() else "No reviewed consensus CSV was supplied.", + "reviewed_point_in_time_evidence", + ) + ) + return tuple(rows) + + +def validate_source_rows( + provider: str, + rows: Sequence[Mapping[str, object]], + *, + as_of: object, + rights_registry: Mapping[str, SourceRights] | None = None, +) -> SourceValidationResult: + cutoff = parse_utc_timestamp(as_of, label="review cutoff") + review_cutoff = cutoff.isoformat().replace("+00:00", "Z") + source_id = str(provider).strip().lower() + registry = load_source_rights_registry() if rights_registry is None else rights_registry + source_review = review_commercial_field_scope(registry, source_id, ()) + accepted = 0 + historical = 0 + candidate = 0 + rejected: list[dict[str, object]] = [] + commercial_reviews: list[SourceCommercialReview] = [] + for index, row in enumerate(rows, start=1): + scope = str(row.get("history_scope") or "").strip().lower() + required = ("ticker", "fiscal_period", "snapshot_at", "retrieved_at", "source_ref") + if scope == "point_in_time": + required = HISTORICAL_REQUIRED + missing = [field for field in required if not str(row.get(field) or "").strip()] + reasons: list[str] = [] + if scope not in {"current_only", "point_in_time"}: + reasons.append("history_scope must be current_only or point_in_time") + if missing: + reasons.append("missing required fields: " + ", ".join(missing)) + parsed_timestamps = {} + for timestamp in ("snapshot_at", "retrieved_at"): + if str(row.get(timestamp) or "").strip(): + try: + parsed_timestamps[timestamp] = parse_utc_timestamp( + row[timestamp], label=timestamp + ) + except ValueError as exc: + reasons.append(str(exc)) + snapshot_at = parsed_timestamps.get("snapshot_at") + retrieved_at = parsed_timestamps.get("retrieved_at") + if ( + snapshot_at is not None + and retrieved_at is not None + and snapshot_at > retrieved_at + ): + reasons.append("snapshot_at cannot be after retrieved_at") + for field, timestamp in parsed_timestamps.items(): + if timestamp > cutoff: + reasons.append(f"{field} is after review cutoff") + if not reasons: + try: + ConsensusSnapshot( + ticker=row.get("ticker"), + fiscal_period=row.get("fiscal_period"), + snapshot_at=row.get("snapshot_at"), + revenue_consensus=float(row["revenue_consensus"]) if str(row.get("revenue_consensus") or "").strip() else None, + eps_consensus=float(row["eps_consensus"]) if str(row.get("eps_consensus") or "").strip() else None, + source=source_id, + retrieved_at=row.get("retrieved_at"), + source_ref=row.get("source_ref"), + revenue_currency=row.get("revenue_currency") or "USD", + revenue_unit_scale=float(row.get("revenue_unit_scale") or 1), + revenue_basis=row.get("revenue_basis") or "reported", + eps_currency=row.get("eps_currency") or "USD", + eps_basis=row.get("eps_basis") or "gaap", + eps_share_basis=row.get("eps_share_basis") or "diluted", + eps_operations_basis=row.get("eps_operations_basis") or "reported", + split_adjustment_basis=row.get("split_adjustment_basis") or "as_reported", + expected_report_date=row.get("expected_report_date") or None, + ) + except (TypeError, ValueError) as exc: + reasons.append(str(exc)) + if reasons: + rejected.append({"row_number": index, "reason": " | ".join(reasons)}) + continue + accepted += 1 + required_supported_fields = tuple( + field + for field in ("revenue_consensus", "eps_consensus") + if str(row.get(field) or "").strip() + ) + commercial_review = review_commercial_field_scope( + registry, + source_id, + required_supported_fields, + ) + commercial_blockers: list[str] = [] + if not commercial_review.commercial_rights_approved: + commercial_blockers.append( + f"commercial_rights:{commercial_review.rights_status}" + ) + commercial_blockers.extend( + f"registered_consensus_scope_missing:{field}" + for field in commercial_review.missing_supported_fields + ) + commercial_reviews.append( + SourceCommercialReview( + row_number=index, + required_supported_fields=commercial_review.required_supported_fields, + missing_supported_fields=commercial_review.missing_supported_fields, + commercial_evidence_ready=commercial_review.commercial_evidence_ready, + commercial_blockers=tuple(commercial_blockers), + ) + ) + if scope == "point_in_time": + historical += 1 + else: + candidate += 1 + state = ( + "historical_evidence_reviewable" + if historical + else "candidate_context_only" + if candidate + else "still_blocked" + ) + commercial_ready_count = sum( + review.commercial_evidence_ready for review in commercial_reviews + ) + aggregate_blockers: list[str] = [] + if not source_review.commercial_rights_approved: + aggregate_blockers.append( + f"commercial_rights:{source_review.rights_status}" + ) + for review in commercial_reviews: + for blocker in review.commercial_blockers: + if blocker not in aggregate_blockers: + aggregate_blockers.append(blocker) + return SourceValidationResult( + provider=source_id, + review_cutoff=review_cutoff, + state=state, + accepted_count=accepted, + rejected_count=len(rejected), + historical_snapshot_count=historical, + candidate_context_count=candidate, + rejected_rows=tuple(rejected), + rights_status=source_review.rights_status, + commercial_rights_approved=source_review.commercial_rights_approved, + commercial_ready_count=commercial_ready_count, + commercial_review_required_count=accepted - commercial_ready_count, + commercial_evidence_ready=bool(commercial_reviews) + and commercial_ready_count == accepted, + commercial_blockers=tuple(aggregate_blockers), + commercial_review_rows=tuple(commercial_reviews), + ) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Report fail-closed Earnings consensus source activation state.") + parser.add_argument("--reviewed-csv") + parser.add_argument("--review-csv", type=Path) + parser.add_argument("--provider") + parser.add_argument("--as-of") + parser.add_argument("--json", action="store_true") + args = parser.parse_args(argv) + if args.review_csv is not None: + if args.reviewed_csv is not None: + parser.error("--review-csv cannot be combined with --reviewed-csv") + if not str(args.provider or "").strip(): + parser.error("--provider is required with --review-csv") + if not str(args.as_of or "").strip(): + parser.error("--as-of is required with --review-csv") + try: + result = validate_source_rows( + args.provider, + load_source_review_csv(args.review_csv), + as_of=args.as_of, + ) + except ValueError as exc: + parser.error(str(exc)) + if args.json: + print(json.dumps(asdict(result), indent=2, sort_keys=True)) + else: + print(render_source_validation_result(result)) + return 0 + if args.provider is not None or args.as_of is not None: + parser.error("--provider and --as-of require --review-csv") + statuses = consensus_source_statuses(generic_csv=args.reviewed_csv) + if args.json: + print(json.dumps([asdict(row) for row in statuses], indent=2, sort_keys=True)) + else: + print("Provider | Status | Usage | Reason") + print("--- | --- | --- | ---") + for row in statuses: + print(f"{row.provider} | {row.status} | {row.source_usage} | {row.reason}") + print("Boundary: configured access is not historical evidence; every payload still requires rights, provenance, and comparability review.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/earnings_nowcast_backtest.py b/src/earnings_nowcast_backtest.py index 78e75d2c..3b040d0a 100644 --- a/src/earnings_nowcast_backtest.py +++ b/src/earnings_nowcast_backtest.py @@ -5,8 +5,15 @@ from dataclasses import dataclass from typing import Mapping, Sequence -from src.earnings_nowcast_contract import ConsensusSnapshot, NowcastState, QuarterlyActual, parse_utc_timestamp +from src.earnings_nowcast_contract import ( + ConsensusSnapshot, + NowcastState, + QuarterlyActual, + eps_split_basis_verified, + parse_utc_timestamp, +) from src.earnings_nowcast_model import NowcastConfig, build_baseline_nowcast +from src.earnings_nowcast_readiness import canonicalize_actuals @dataclass(frozen=True) @@ -51,11 +58,21 @@ class BacktestReport: eps_interval_coverage: float | None joint_interval_coverage: float | None benchmark_metrics: Mapping[str, float] + benchmark_failures: tuple[str, ...] leakage_failures: tuple[str, ...] failures: tuple[str, ...] events: tuple[BacktestEvent, ...] +@dataclass(frozen=True) +class _CanonicalBacktestTarget: + ticker: str + fiscal_period: str + reported_at: str + revenue_actual: float | None + eps_actual: float | None + + @dataclass(frozen=True) class ProbabilityObservation: probability: float @@ -128,15 +145,66 @@ def _direction(actual: float, consensus: float, tolerance_pct: float) -> str: return "aligned" +def _canonical_backtest_targets( + actuals: Sequence[QuarterlyActual], +) -> tuple[_CanonicalBacktestTarget, ...]: + rows_by_ticker: dict[str, list[QuarterlyActual]] = {} + rows_by_period: dict[tuple[str, str], list[QuarterlyActual]] = {} + for row in actuals: + rows_by_ticker.setdefault(row.ticker, []).append(row) + rows_by_period.setdefault((row.ticker, row.fiscal_period), []).append(row) + reported_at_lookup = { + key: min((row.reported_at for row in rows), key=parse_utc_timestamp) + for key, rows in rows_by_period.items() + } + + revenue_lookup: dict[tuple[str, str], QuarterlyActual] = {} + eps_lookup: dict[tuple[str, str], QuarterlyActual] = {} + for ticker, ticker_rows in rows_by_ticker.items(): + canonical = canonicalize_actuals(ticker_rows, None) + revenue_lookup.update( + ((ticker, row.fiscal_period), row) for row in canonical.revenue_rows + ) + eps_lookup.update( + ((ticker, row.fiscal_period), row) for row in canonical.eps_rows + ) + + targets = tuple( + _CanonicalBacktestTarget( + ticker=ticker, + fiscal_period=period, + reported_at=reported_at_lookup[(ticker, period)], + revenue_actual=( + revenue_lookup[(ticker, period)].revenue_actual + if (ticker, period) in revenue_lookup + else None + ), + eps_actual=( + eps_lookup[(ticker, period)].eps_actual + if (ticker, period) in eps_lookup + else None + ), + ) + for ticker, period in sorted( + rows_by_period, + key=lambda key: (parse_utc_timestamp(reported_at_lookup[key]), key), + ) + ) + return targets + + def walk_forward_backtest( actuals: Sequence[QuarterlyActual], consensus_snapshots: Sequence[ConsensusSnapshot], config: NowcastConfig | None = None, *, minimum_backtest_events: int = 20, + maximum_snapshot_age_days: int = 90, ) -> BacktestReport: config = config or NowcastConfig() - actual_lookup = {(row.ticker, row.fiscal_period): row for row in actuals} + if maximum_snapshot_age_days < 1: + raise ValueError("maximum_snapshot_age_days must be positive") + targets = _canonical_backtest_targets(actuals) events: list[BacktestEvent] = [] failures: list[str] = [] leakage_failures: list[str] = [] @@ -150,22 +218,68 @@ def exclude(reason: str, detail: str) -> None: exclusion_reasons[reason] = exclusion_reasons.get(reason, 0) + 1 excluded_events.append(detail) - targets = sorted(actuals, key=lambda row: (row.reported_at, row.ticker, row.fiscal_period)) for target in targets: - eligible_snapshots = [ + target_eps_actual = target.eps_actual + if target.revenue_actual is None and target_eps_actual is None: + exclude( + "no_comparable_target_actual", + f"{target.ticker} {target.fiscal_period}: no target metric has verified comparable evidence", + ) + continue + matching_snapshots = [ row for row in consensus_snapshots if row.ticker == target.ticker and row.fiscal_period == target.fiscal_period and parse_utc_timestamp(row.snapshot_at) < parse_utc_timestamp(target.reported_at) ] + eligible_snapshots: list[ConsensusSnapshot] = [] + for row in matching_snapshots: + if parse_utc_timestamp(row.retrieved_at) >= parse_utc_timestamp(target.reported_at): + leakage_failures.append( + f"{target.ticker} {target.fiscal_period}: consensus retrieved after target report" + ) + continue + eligible_snapshots.append(row) if not eligible_snapshots: exclude( "no_pre_report_consensus_snapshot", f"{target.ticker} {target.fiscal_period}: no consensus snapshot before reported_at", ) continue - snapshot = max(eligible_snapshots, key=lambda row: parse_utc_timestamp(row.snapshot_at)) + latest_timestamp = max(parse_utc_timestamp(row.snapshot_at) for row in eligible_snapshots) + latest_snapshots = [ + row for row in eligible_snapshots if parse_utc_timestamp(row.snapshot_at) == latest_timestamp + ] + definitions = { + ( + row.revenue_consensus, + row.eps_consensus, + row.revenue_currency, + row.revenue_unit_scale, + row.revenue_basis, + row.eps_currency, + row.eps_basis, + row.eps_share_basis, + row.eps_operations_basis, + row.split_adjustment_basis, + ) + for row in latest_snapshots + } + if len(definitions) > 1: + exclude( + "ambiguous_consensus_revision", + f"{target.ticker} {target.fiscal_period}: conflicting consensus rows share the latest timestamp", + ) + continue + snapshot = sorted(latest_snapshots, key=lambda row: row.source_ref or row.source)[0] + snapshot_age = parse_utc_timestamp(target.reported_at) - parse_utc_timestamp(snapshot.snapshot_at) + if snapshot_age.days > maximum_snapshot_age_days: + exclude( + "stale_consensus_snapshot", + f"{target.ticker} {target.fiscal_period}: latest consensus is {snapshot_age.days} days before reported_at", + ) + continue cutoff = snapshot.snapshot_at history = [ row @@ -188,7 +302,18 @@ def exclude(reason: str, detail: str) -> None: latest_input = max(input_timestamps, key=parse_utc_timestamp) if parse_utc_timestamp(latest_input) > parse_utc_timestamp(cutoff): leakage_failures.append(f"{target.ticker} {target.fiscal_period}: input after cutoff") - prior = actual_lookup.get((target.ticker, _prior_year(target.fiscal_period))) + prior_period = _prior_year(target.fiscal_period) + canonical_prior = canonicalize_actuals( + [row for row in history if row.fiscal_period == prior_period], + None, + ) + prior_revenue = canonical_prior.revenue_rows[0] if canonical_prior.revenue_rows else None + prior_eps = canonical_prior.eps_rows[0] if canonical_prior.eps_rows else None + consensus_eps = ( + snapshot.eps_consensus + if eps_split_basis_verified(snapshot.split_adjustment_basis) + else None + ) events.append( BacktestEvent( ticker=target.ticker, @@ -204,11 +329,11 @@ def exclude(reason: str, detail: str) -> None: eps_forecast=forecast.eps_midpoint, eps_low=forecast.eps_low, eps_high=forecast.eps_high, - eps_actual=target.eps_actual, + eps_actual=target_eps_actual, consensus_revenue=snapshot.revenue_consensus, - consensus_eps=snapshot.eps_consensus, - prior_year_revenue=prior.revenue_actual if prior else None, - prior_year_eps=prior.eps_actual if prior else None, + consensus_eps=consensus_eps, + prior_year_revenue=prior_revenue.revenue_actual if prior_revenue else None, + prior_year_eps=prior_eps.eps_actual if prior_eps else None, relative_classification=forecast.relative_classification, ) ) @@ -259,6 +384,16 @@ def exclude(reason: str, detail: str) -> None: if value is not None: benchmarks[name] = value + revenue_mae = _mean(revenue_errors) + eps_mae = _mean(eps_errors) + benchmark_failures: list[str] = [] + if revenue_mae is not None and "consensus_revenue_mae" in benchmarks: + if revenue_mae >= benchmarks["consensus_revenue_mae"]: + benchmark_failures.append("revenue_model_did_not_improve_consensus") + if eps_mae is not None and "consensus_eps_mae" in benchmarks: + if eps_mae >= benchmarks["consensus_eps_mae"]: + benchmark_failures.append("eps_model_did_not_improve_consensus") + if not events: failures.insert(0, "No valid out-of-sample events") elif len(events) < minimum_backtest_events: @@ -267,7 +402,9 @@ def exclude(reason: str, detail: str) -> None: ) if leakage_failures: failures.append("Point-in-time leakage detected") - if not events or leakage_failures: + if len(events) >= minimum_backtest_events and benchmark_failures: + failures.extend(benchmark_failures) + if not events or leakage_failures or (len(events) >= minimum_backtest_events and benchmark_failures): verdict = "failed" elif len(events) < minimum_backtest_events: verdict = "insufficient" @@ -280,10 +417,10 @@ def exclude(reason: str, detail: str) -> None: excluded_count=excluded_count, exclusion_reasons=exclusion_reasons, excluded_events=tuple(excluded_events), - revenue_mae=_mean(revenue_errors), + revenue_mae=revenue_mae, revenue_median_absolute_error=_median(revenue_errors), revenue_wape=(sum(revenue_errors) / revenue_actual_total if revenue_errors and revenue_actual_total else None), - eps_mae=_mean(eps_errors), + eps_mae=eps_mae, eps_median_absolute_error=_median(eps_errors), directional_accuracy=_mean(directions), interval_coverage=_mean(interval_hits), @@ -291,6 +428,7 @@ def exclude(reason: str, detail: str) -> None: eps_interval_coverage=_mean(eps_interval_hits), joint_interval_coverage=_mean(joint_interval_hits), benchmark_metrics=benchmarks, + benchmark_failures=tuple(benchmark_failures), leakage_failures=tuple(leakage_failures), failures=tuple(failures), events=tuple(events), diff --git a/src/earnings_nowcast_cohort.py b/src/earnings_nowcast_cohort.py new file mode 100644 index 00000000..f9f69223 --- /dev/null +++ b/src/earnings_nowcast_cohort.py @@ -0,0 +1,199 @@ +"""Read-only Earnings Nowcast cohort readiness summary.""" + +from __future__ import annotations + +import argparse +import json +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Mapping, Sequence + +from src.earnings_nowcast_contract import ( + ConsensusSnapshot, + QuarterlyActual, + eps_split_basis_verified, +) +from src.earnings_nowcast_onboarding import validate_onboarding +from src.earnings_nowcast_readiness import assess_nowcast_readiness + + +@dataclass(frozen=True) +class CohortReadinessRow: + ticker: str + latest_actual_period: str + forecast_period: str + revenue_history_count: int + eps_history_count: int + q4_revenue_count: int + q4_eps_count: int + revenue_ready: bool + eps_ready: bool + q4_ready: bool + split_basis_ready: bool + consensus_snapshot_count: int + backtest_event_count: int + calibration_event_count: int + probability_state: str + state: str + blocker: str + next_action: str + + +def _period_key(period: str) -> tuple[int, int]: + year, quarter = period.split("-Q", 1) + return int(year), int(quarter) + + +def _next_period(period: str) -> str: + year, quarter = _period_key(period) + return f"{year + 1}-Q1" if quarter == 4 else f"{year}-Q{quarter + 1}" + + +def _accepted_values(input_dir: Path, as_of: str) -> tuple[tuple[QuarterlyActual, ...], tuple[ConsensusSnapshot, ...]]: + validation = validate_onboarding(input_dir, cutoff=as_of) + values = tuple(item["value"] for item in validation["accepted_rows"]) + return ( + tuple(value for value in values if isinstance(value, QuarterlyActual)), + tuple(value for value in values if isinstance(value, ConsensusSnapshot)), + ) + + +def build_cohort_readiness( + input_dir: Path | str, + *, + tickers: Sequence[str], + as_of: str, + backtest_counts: Mapping[str, int] | None = None, + calibration_counts: Mapping[str, int] | None = None, +) -> tuple[CohortReadinessRow, ...]: + actuals, consensus = _accepted_values(Path(input_dir), as_of) + backtests = {str(key).upper(): int(value) for key, value in (backtest_counts or {}).items()} + calibrations = {str(key).upper(): int(value) for key, value in (calibration_counts or {}).items()} + output: list[CohortReadinessRow] = [] + for requested in tickers: + ticker = str(requested or "").strip().upper() + ticker_actuals = tuple(row for row in actuals if row.ticker == ticker) + if not ticker_actuals: + output.append( + CohortReadinessRow( + ticker=ticker, + latest_actual_period="", + forecast_period="", + revenue_history_count=0, + eps_history_count=0, + q4_revenue_count=0, + q4_eps_count=0, + revenue_ready=False, + eps_ready=False, + q4_ready=False, + split_basis_ready=False, + consensus_snapshot_count=0, + backtest_event_count=backtests.get(ticker, 0), + calibration_event_count=calibrations.get(ticker, 0), + probability_state="awaiting_calibration_evidence", + state="blocked", + blocker="quarterly_actuals_missing", + next_action="Add source-backed quarterly actuals through validate and preview.", + ) + ) + continue + periods = sorted({row.fiscal_period for row in ticker_actuals}, key=_period_key) + latest = periods[-1] + forecast_period = _next_period(latest) + ticker_consensus = tuple( + row for row in consensus if row.ticker == ticker and row.fiscal_period == forecast_period + ) + readiness = assess_nowcast_readiness( + ticker=ticker, + fiscal_period=forecast_period, + as_of_timestamp=as_of, + actuals=ticker_actuals, + consensus=ticker_consensus, + ) + revenue_count = len({row.fiscal_period for row in ticker_actuals if row.revenue_actual is not None}) + eps_count = len({row.fiscal_period for row in ticker_actuals if row.eps_actual is not None}) + q4_revenue = len({row.fiscal_period for row in ticker_actuals if row.fiscal_period.endswith("Q4") and row.revenue_actual is not None}) + q4_eps = len({row.fiscal_period for row in ticker_actuals if row.fiscal_period.endswith("Q4") and row.eps_actual is not None}) + split_ready = bool( + any(row.eps_actual is not None for row in ticker_actuals) + and all( + eps_split_basis_verified(row.split_adjustment_basis) + for row in ticker_actuals + if row.eps_actual is not None + ) + ) + blockers = tuple(readiness.missing_evidence) + output.append( + CohortReadinessRow( + ticker=ticker, + latest_actual_period=latest, + forecast_period=forecast_period, + revenue_history_count=revenue_count, + eps_history_count=eps_count, + q4_revenue_count=q4_revenue, + q4_eps_count=q4_eps, + revenue_ready=readiness.revenue_ready, + eps_ready=readiness.eps_ready, + q4_ready=q4_revenue > 0 and q4_eps > 0, + split_basis_ready=split_ready, + consensus_snapshot_count=len(ticker_consensus), + backtest_event_count=backtests.get(ticker, 0), + calibration_event_count=calibrations.get(ticker, 0), + probability_state=( + "calibrated" if calibrations.get(ticker, 0) >= 100 else "awaiting_calibration_evidence" + ), + state=readiness.state.value, + blocker=", ".join(blockers), + next_action=readiness.next_action, + ) + ) + return tuple(output) + + +def cohort_readiness_cards(rows: Sequence[CohortReadinessRow]) -> list[dict[str, object]]: + ready_revenue = sum(row.revenue_ready for row in rows) + ready_eps = sum(row.eps_ready for row in rows) + consensus = sum(row.consensus_snapshot_count > 0 for row in rows) + return [ + { + "kicker": "EARNINGS EVIDENCE", + "title": f"{ready_revenue}/{len(rows)} Revenue baselines ready; {ready_eps}/{len(rows)} EPS baselines ready", + "body": ( + f"{consensus}/{len(rows)} companies have an exact-period point-in-time consensus snapshot. " + "Revenue and EPS gates are independent; numerical probability remains separate." + ), + "badges": ["read-only", "five-company cohort", "research-only"], + "command": "", + } + ] + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Print a read-only Earnings Nowcast cohort readiness board.") + parser.add_argument("--input-dir", default="data/imports/earnings_nowcast") + parser.add_argument("--tickers", default="NVDA,AMD,AVGO,MU,QCOM") + parser.add_argument("--as-of", required=True) + parser.add_argument("--json", action="store_true") + args = parser.parse_args(argv) + rows = build_cohort_readiness( + args.input_dir, + tickers=tuple(value.strip() for value in args.tickers.split(",") if value.strip()), + as_of=args.as_of, + ) + if args.json: + print(json.dumps([asdict(row) for row in rows], indent=2, sort_keys=True)) + return 0 + print("Ticker | State | Latest actual | Forecast period | Revenue | EPS | Consensus | Probability | Next action") + print("--- | --- | --- | --- | --- | --- | --- | --- | ---") + for row in rows: + print( + f"{row.ticker} | {row.state} | {row.latest_actual_period or '-'} | {row.forecast_period or '-'} | " + f"{'ready' if row.revenue_ready else 'withheld'} | {'ready' if row.eps_ready else 'withheld'} | " + f"{row.consensus_snapshot_count} snapshot(s) | {row.probability_state} | {row.next_action}" + ) + print("Boundary: this board reports evidence readiness only; it creates no forecast, probability, or recommendation.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/earnings_nowcast_contract.py b/src/earnings_nowcast_contract.py index d8ac9e4c..da3a44f7 100644 --- a/src/earnings_nowcast_contract.py +++ b/src/earnings_nowcast_contract.py @@ -12,6 +12,27 @@ _FISCAL_PERIOD_PATTERN = re.compile(r"^\d{4}-Q[1-4]$") _HEX_64_PATTERN = re.compile(r"^[0-9a-f]{64}$") +COMPANYFACTS_SPLIT_BASIS_UNVERIFIED = "companyfacts_split_basis_unverified" +PRIMARY_SPLIT_BASIS_UNVERIFIED = "primary_split_basis_unverified" + + +def eps_split_basis_verified(value: object) -> bool: + """Accept only declared basis states; explicit unverified sentinels fail closed.""" + + normalized = str(value or "").strip().lower() + if normalized in { + COMPANYFACTS_SPLIT_BASIS_UNVERIFIED, + PRIMARY_SPLIT_BASIS_UNVERIFIED, + }: + return False + if normalized in {"as_reported", "pre_split"}: + return True + return bool( + re.fullmatch( + r"(?:post_split|split_adjusted)_20\d{2}(?:_|-)\d{2}(?:_|-)\d{2}", + normalized, + ) + ) class NowcastState(StrEnum): diff --git a/src/earnings_nowcast_model.py b/src/earnings_nowcast_model.py index 8b3c9517..f91bb93e 100644 --- a/src/earnings_nowcast_model.py +++ b/src/earnings_nowcast_model.py @@ -7,7 +7,7 @@ from typing import Sequence from src.earnings_nowcast_contract import ConsensusSnapshot, ForecastSnapshot, QuarterlyActual, input_snapshot_hash, parse_utc_timestamp -from src.earnings_nowcast_readiness import assess_nowcast_readiness, canonicalize_actuals +from src.earnings_nowcast_readiness import assess_nowcast_readiness, canonicalize_actuals, contiguous_metric_window @dataclass(frozen=True) @@ -181,15 +181,35 @@ def build_baseline_nowcast( missing = ", ".join(readiness.missing_evidence) or readiness.state.value raise ValueError(f"Nowcast is blocked: {missing}") - canonical = canonicalize_actuals(rows, consensus) + canonical = canonicalize_actuals( + [ + row + for row in rows + if parse_utc_timestamp(row.reported_at) <= parse_utc_timestamp(cutoff) + and row.fiscal_period != consensus.fiscal_period + ], + consensus, + ) + revenue_window = contiguous_metric_window( + canonical.revenue_rows, + consensus.fiscal_period, + "revenue", + config.minimum_history_quarters, + ) + eps_window = contiguous_metric_window( + canonical.eps_rows, + consensus.fiscal_period, + "eps", + config.minimum_history_quarters, + ) revenue_midpoint = revenue_low = revenue_high = None if readiness.revenue_ready: - revenue_midpoint, revenue_low, revenue_high = _revenue_forecast(canonical.revenue_rows, consensus.fiscal_period, config) + revenue_midpoint, revenue_low, revenue_high = _revenue_forecast(revenue_window, consensus.fiscal_period, config) eps_midpoint = eps_low = eps_high = None if readiness.eps_ready: - eps_midpoint, eps_low, eps_high = _eps_forecast(canonical.eps_rows, consensus.fiscal_period, config) + eps_midpoint, eps_low, eps_high = _eps_forecast(eps_window, consensus.fiscal_period, config) revenue_classification = ( classify_consensus_gap( @@ -213,7 +233,7 @@ def build_baseline_nowcast( ) primary_classification = revenue_classification or eps_classification or "withheld" - canonical_rows = {row.source_ref: row for row in (*canonical.revenue_rows, *canonical.eps_rows)} + canonical_rows = {row.source_ref: row for row in (*revenue_window, *eps_window)} digest = input_snapshot_hash([*canonical_rows.values(), consensus, asdict(config)]) forecast_horizon_days = ( (date.fromisoformat(consensus.expected_report_date) - parse_utc_timestamp(cutoff).date()).days diff --git a/src/earnings_nowcast_readiness.py b/src/earnings_nowcast_readiness.py index 4726ddfa..3656ca3b 100644 --- a/src/earnings_nowcast_readiness.py +++ b/src/earnings_nowcast_readiness.py @@ -8,6 +8,7 @@ FreshnessState, NowcastState, QuarterlyActual, + eps_split_basis_verified, parse_utc_timestamp, ) @@ -87,23 +88,69 @@ def _metric_definition(row: QuarterlyActual | ConsensusSnapshot, metric: str) -> ) -def _canonical_period_row(rows: Sequence[QuarterlyActual], metric: str) -> tuple[QuarterlyActual | None, tuple[str, ...]]: +def _previous_period(period: str) -> str: + year, quarter = period.split("-Q", 1) + return f"{int(year) - 1}-Q4" if quarter == "1" else f"{year}-Q{int(quarter) - 1}" + + +def contiguous_metric_window( + rows: Sequence[QuarterlyActual], + target_period: str, + metric: str, + minimum_quarters: int, +) -> tuple[QuarterlyActual, ...]: + value_field = f"{metric}_actual" + rows_by_period = { + row.fiscal_period: row + for row in rows + if getattr(row, value_field) is not None + } + expected_period = _previous_period(target_period) + window: list[QuarterlyActual] = [] + for _ in range(minimum_quarters): + row = rows_by_period.get(expected_period) + if row is None: + return () + window.append(row) + expected_period = _previous_period(expected_period) + return tuple(reversed(window)) + + +def _canonical_period_row( + rows: Sequence[QuarterlyActual], + metric: str, + *, + lineage_rows: Sequence[QuarterlyActual] | None = None, +) -> tuple[QuarterlyActual | None, tuple[str, ...]]: value_field = f"{metric}_actual" ordered = sorted(rows, key=lambda row: (parse_utc_timestamp(row.reported_at), parse_utc_timestamp(row.retrieved_at), row.source_ref)) values = {float(getattr(row, value_field)) for row in ordered} if len(values) == 1: return ordered[-1], () latest = ordered[-1] - earlier_by_ref = {row.source_ref: row for row in ordered[:-1]} - superseded = earlier_by_ref.get(latest.supersedes_source_ref or "") - if superseded is not None and float(getattr(superseded, value_field)) != float(getattr(latest, value_field)): - unresolved = [ - row - for row in ordered[:-1] - if row.source_ref != superseded.source_ref - and float(getattr(row, value_field)) != float(getattr(latest, value_field)) - ] - if not unresolved: + ordered_lineage = sorted( + lineage_rows if lineage_rows is not None else rows, + key=lambda row: ( + parse_utc_timestamp(row.reported_at), + parse_utc_timestamp(row.retrieved_at), + row.source_ref, + ), + ) + by_ref = {row.source_ref: row for row in ordered_lineage} + if len(by_ref) == len(ordered_lineage): + position_by_ref = {row.source_ref: index for index, row in enumerate(ordered_lineage)} + visited: set[str] = set() + current = latest + while current.source_ref not in visited: + visited.add(current.source_ref) + supersedes = current.supersedes_source_ref + if not supersedes: + break + prior = by_ref.get(supersedes) + if prior is None or position_by_ref[prior.source_ref] >= position_by_ref[current.source_ref]: + break + current = prior + if {row.source_ref for row in ordered}.issubset(visited): return latest, () return None, tuple(sorted(row.source_ref for row in ordered)) @@ -118,19 +165,44 @@ def canonicalize_actuals( for metric in ("revenue", "eps"): by_period: dict[str, list[QuarterlyActual]] = {} for row in rows: - if getattr(row, f"{metric}_actual") is not None: - by_period.setdefault(row.fiscal_period, []).append(row) + by_period.setdefault(row.fiscal_period, []).append(row) expected_definition = _metric_definition(consensus, metric) if consensus is not None else None for period, period_rows in sorted(by_period.items()): - definitions = {_metric_definition(row, metric) for row in period_rows} + metric_rows = [ + row + for row in period_rows + if getattr(row, f"{metric}_actual") is not None + ] + if not metric_rows: + continue + definitions = {_metric_definition(row, metric) for row in metric_rows} if expected_definition is None: - compatible = period_rows if len(definitions) == 1 else [] + compatible = metric_rows if len(definitions) == 1 else [] else: - compatible = [row for row in period_rows if _metric_definition(row, metric) == expected_definition] + compatible = [ + row + for row in metric_rows + if _metric_definition(row, metric) == expected_definition + ] + if metric == "eps": + consensus_unverified = ( + consensus is not None + and not eps_split_basis_verified(consensus.split_adjustment_basis) + ) + compatible = [ + row + for row in compatible + if not consensus_unverified + and eps_split_basis_verified(row.split_adjustment_basis) + ] if not compatible: incompatible[metric].append(period) continue - chosen, period_conflicts = _canonical_period_row(compatible, metric) + chosen, period_conflicts = _canonical_period_row( + compatible, + metric, + lineage_rows=period_rows, + ) if chosen is None: conflicts[metric].extend(period_conflicts) else: @@ -209,10 +281,24 @@ def assess_nowcast_readiness( selected_consensus = _latest_consensus(available_consensus) canonical = canonicalize_actuals(available_actuals, selected_consensus) - revenue_history = [row.revenue_actual for row in canonical.revenue_rows] - eps_history = [row.eps_actual for row in canonical.eps_rows] - enough_revenue_history = len(revenue_history) >= minimum_history_quarters - enough_eps_history = len(eps_history) >= minimum_history_quarters + revenue_window = contiguous_metric_window( + canonical.revenue_rows, + normalized_period, + "revenue", + minimum_history_quarters, + ) + eps_window = contiguous_metric_window( + canonical.eps_rows, + normalized_period, + "eps", + minimum_history_quarters, + ) + revenue_history = [row.revenue_actual for row in revenue_window] + eps_history = [row.eps_actual for row in eps_window] + enough_revenue_history = len(revenue_window) == minimum_history_quarters + enough_eps_history = len(eps_window) == minimum_history_quarters + revenue_history_gap = bool(canonical.revenue_rows) and not enough_revenue_history + eps_history_gap = bool(canonical.eps_rows) and not enough_eps_history stable_eps_history = enough_eps_history and _sign_changes(float(value) for value in eps_history) <= 1 freshness_state = ( @@ -252,6 +338,8 @@ def assess_nowcast_readiness( missing.append("post_cutoff_evidence") if not enough_revenue_history: missing.append("quarterly_actual_history") + if revenue_history_gap or eps_history_gap: + missing.append("quarter_history_gap") if canonical.revenue_conflict_source_ids: missing.append("conflicting_quarterly_revenue") if canonical.eps_conflict_source_ids: @@ -272,7 +360,7 @@ def assess_nowcast_readiness( missing.append("current_consensus") state = NowcastState.BASELINE_READY if revenue_ready or eps_ready else NowcastState.BLOCKED - source_ids = tuple(row.source_ref for row in (*canonical.revenue_rows, *canonical.eps_rows)) + source_ids = tuple(row.source_ref for row in (*revenue_window, *eps_window)) if selected_consensus is not None: source_ids += ( selected_consensus.source_ref diff --git a/src/earnings_nowcast_report.py b/src/earnings_nowcast_report.py index a77eaef5..f886b77a 100644 --- a/src/earnings_nowcast_report.py +++ b/src/earnings_nowcast_report.py @@ -108,15 +108,23 @@ def _jsonable(value: Any) -> Any: return value -def _latest_consensus(rows: Iterable[ConsensusSnapshot], ticker: str, cutoff: str) -> ConsensusSnapshot: +def _latest_consensus( + rows: Iterable[ConsensusSnapshot], + ticker: str, + cutoff: str, + *, + fiscal_period: str | None = None, +) -> ConsensusSnapshot: matching = [ row for row in rows if row.ticker == ticker + and (fiscal_period is None or row.fiscal_period == fiscal_period) and parse_utc_timestamp(row.snapshot_at) <= parse_utc_timestamp(cutoff) ] if not matching: - raise ValueError(f"No point-in-time consensus snapshot exists for {ticker} at or before {cutoff}") + period_note = f" for {fiscal_period}" if fiscal_period else "" + raise ValueError(f"No point-in-time consensus snapshot exists for {ticker}{period_note} at or before {cutoff}") return max(matching, key=lambda row: parse_utc_timestamp(row.snapshot_at)) @@ -125,11 +133,24 @@ def build_nowcast_packet( *, ticker: str, as_of_timestamp: str, + fiscal_period: str | None = None, ) -> dict[str, object]: root = Path(input_root) if not root.is_dir(): raise FileNotFoundError(f"Nowcast input directory is unavailable: {root}") normalized_ticker = str(ticker or "").strip().upper() + normalized_period = str(fiscal_period or "").strip().upper() or None + raw_consensus = [ + row + for row in _read_csv(root / "consensus_snapshots.csv") + if str(row.get("ticker") or "").strip().upper() == normalized_ticker + ] + synthetic_only = bool(raw_consensus) and all( + str(row.get("source") or "").strip() == "synthetic_test_fixture" + for row in raw_consensus + ) + if normalized_period is None and not synthetic_only: + raise ValueError("fiscal_period is required for real-company nowcast evidence") actuals = _load_actuals(root / "quarterly_actuals.csv") consensus_rows = _load_consensus(root / "consensus_snapshots.csv") signals = _load_signals(root / "signals.csv") @@ -137,6 +158,7 @@ def build_nowcast_packet( (row for row in consensus_rows if row.ticker == normalized_ticker), normalized_ticker, as_of_timestamp, + fiscal_period=normalized_period, ) selected_actuals = [row for row in actuals if row.ticker == normalized_ticker] selected_signals = [row for row in signals if row.target_ticker == normalized_ticker] @@ -331,6 +353,7 @@ def _parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Build a read-only earnings nowcast pilot packet.") parser.add_argument("--root", type=Path, default=Path(".")) parser.add_argument("--ticker") + parser.add_argument("--fiscal-period") parser.add_argument("--as-of", required=True, dest="as_of_timestamp") parser.add_argument("--fixture", action="store_true") parser.add_argument("--walkthrough", action="store_true") @@ -364,7 +387,12 @@ def main() -> int: packet = ( build_fixture_walkthrough(input_root, as_of_timestamp=args.as_of_timestamp) if args.walkthrough - else build_nowcast_packet(input_root, ticker=args.ticker, as_of_timestamp=args.as_of_timestamp) + else build_nowcast_packet( + input_root, + ticker=args.ticker, + fiscal_period=args.fiscal_period, + as_of_timestamp=args.as_of_timestamp, + ) ) except FileNotFoundError as exc: print(str(exc), file=sys.stderr) diff --git a/src/earnings_nowcast_sec_actuals.py b/src/earnings_nowcast_sec_actuals.py new file mode 100644 index 00000000..05f706d7 --- /dev/null +++ b/src/earnings_nowcast_sec_actuals.py @@ -0,0 +1,1754 @@ +from __future__ import annotations + +import argparse +import csv +import json +import os +import re +import signal +import tempfile +from dataclasses import asdict, dataclass, replace +from datetime import date, datetime, time, timezone +from html.parser import HTMLParser +from math import isfinite +from pathlib import Path +from typing import Any, Callable, Mapping, Sequence + +from src.earnings_nowcast_contract import ( + PRIMARY_SPLIT_BASIS_UNVERIFIED, + COMPANYFACTS_SPLIT_BASIS_UNVERIFIED, + QuarterlyActual, +) +from src.earnings_nowcast_onboarding import EVIDENCE_SCHEMA_VERSION, SCHEMAS +from src.providers.sec_companyfacts import ( + fetch_companyfacts, + load_sec_ticker_map, + resolve_ticker_to_cik, +) +from src.providers.sec_submissions import ( + FiledExhibit, + extract_filing_exhibits, + fetch_sec_filing_document, + fetch_sec_filing_index, + fetch_sec_submission, +) + + +REVENUE_CONCEPTS = ( + "RevenueFromContractWithCustomerExcludingAssessedTax", + "Revenues", + "SalesRevenueNet", +) +EPS_CONCEPT = "EarningsPerShareDiluted" +SEC_QUARTERLY_FORMS = frozenset(("10-Q", "10-Q/A")) +_FISCAL_PERIOD_PATTERN = re.compile(r"^(?P\d{4})-Q(?P[1-4])$") +REJECTED_AUDIT_STATES = frozenset( + ( + "ambiguous_concept", + "ambiguous_fiscal_identity", + "comparative_period_relabelled", + "companyfacts_fetch_failed", + "cumulative_fact_rejected", + "fiscal_period_conflict", + "post_cutoff_rejected", + "period_end_ambiguous", + "period_end_missing", + "q4_source_unavailable", + "quarter_header_missing", + "ambiguous_period_header", + "guidance_or_outlook_rejected", + "gaap_eps_missing", + "derived_q4_rejected", + "revenue_scale_missing", + "split_basis_unverified", + "ticker_unresolved", + ) +) +_STAGE_OUTPUT_NAMES = frozenset( + ( + ".sec-cache", + "consensus_snapshots.csv", + "quarterly_actuals.csv", + "sec_actuals_audit.json", + "sec_actuals_rejected.csv", + "signals.csv", + ) +) + + +@dataclass(frozen=True) +class SecDurationFact: + taxonomy: str + concept: str + unit: str + value: float + start: str + end: str + filed: str + form: str + accession: str + fiscal_year: int + fiscal_period: str + frame: str + + @property + def duration_days(self) -> int: + return (date.fromisoformat(self.end) - date.fromisoformat(self.start)).days + + +@dataclass(frozen=True) +class ExtractionAuditRow: + ticker: str + state: str + metric: str + fiscal_period: str + source_ref: str + detail: str + concept: str = "" + start: str = "" + end: str = "" + frame: str = "" + accession: str = "" + + +@dataclass(frozen=True) +class ExtractionResult: + rows: tuple[QuarterlyActual, ...] + audit_rows: tuple[ExtractionAuditRow, ...] + + +@dataclass(frozen=True) +class StageResult: + requested_tickers: tuple[str, ...] + accepted_tickers: tuple[str, ...] + withheld_tickers: tuple[str, ...] + accepted_row_count: int + rejected_row_count: int + quarterly_actuals_path: str + audit_path: str + rejected_path: str + automatic_apply: bool = False + + +class _StructuredTableParser(HTMLParser): + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.tables: list[tuple[tuple[tuple[str, ...], ...], str]] = [] + self._page_text: list[str] = [] + self._table_rows: list[list[str]] | None = None + self._row: list[str] | None = None + self._cell: list[str] | None = None + self._caption: list[str] | None = None + self._table_depth = 0 + + @property + def page_text(self) -> str: + return " ".join(self._page_text) + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + lowered = tag.lower() + if lowered == "table": + self._table_depth += 1 + if self._table_depth == 1: + self._table_rows = [] + elif self._table_depth and lowered == "caption": + self._caption = [] + elif self._table_depth and lowered == "tr": + self._row = [] + elif self._table_depth and lowered in {"td", "th"} and self._row is not None: + self._cell = [] + + def handle_data(self, data: str) -> None: + self._page_text.append(data) + if self._caption is not None: + self._caption.append(data) + if self._cell is not None: + self._cell.append(data) + + def handle_endtag(self, tag: str) -> None: + lowered = tag.lower() + if lowered == "caption" and self._caption is not None and self._table_rows is not None: + caption = " ".join(self._caption).strip() + if caption: + self._table_rows.append([caption]) + self._caption = None + elif lowered in {"td", "th"} and self._cell is not None and self._row is not None: + self._row.append(" ".join(self._cell).strip()) + self._cell = None + elif lowered == "tr" and self._row is not None and self._table_rows is not None: + if self._row: + self._table_rows.append(self._row) + self._row = None + elif lowered == "table" and self._table_depth: + self._table_depth -= 1 + if self._table_depth == 0 and self._table_rows is not None: + rows = tuple(tuple(cell for cell in row) for row in self._table_rows) + self.tables.append((rows, " ".join(cell for row in rows for cell in row))) + self._table_rows = None + + +def _normalized_table_text(value: str) -> str: + return " ".join(str(value or "").replace("\xa0", " ").lower().split()) + + +def _q4_audit( + ticker: str, + state: str, + metric: str, + fiscal_period: str, + exhibit: FiledExhibit, + detail: str, +) -> ExtractionAuditRow: + return ExtractionAuditRow( + ticker=str(ticker or "").strip().upper(), + state=state, + metric=metric, + fiscal_period=fiscal_period, + source_ref=exhibit.source_ref, + detail=detail, + concept=exhibit.document_type, + accession=exhibit.accession, + ) + + +def _explicit_q4_fiscal_period(document_text: str) -> str | None: + text = _normalized_table_text(document_text) + years = { + match.group(1) + for pattern in ( + r"(?:fourth quarter|q4)\s+(?:of\s+)?fiscal\s+(20\d{2})", + r"fiscal\s+(20\d{2}).{0,80}?(?:fourth quarter|q4)", + r"q4\s+fy\s*(20\d{2})", + ) + for match in re.finditer(pattern, text) + } + if len(years) != 1: + return None + return f"{next(iter(years))}-Q4" + + +def _q4_value_column(rows: tuple[tuple[str, ...], ...], fiscal_period: str) -> int | None: + fiscal_year = fiscal_period.split("-", 1)[0] + fiscal_year_short = fiscal_year[-2:] + columns = { + index + for row in rows[:2] + for index, cell in enumerate(row) + if re.search(rf"\bq4\s+(?:fy\s*)?(?:{fiscal_year}|{fiscal_year_short})\b", _normalized_table_text(cell)) + or re.search(rf"\bfiscal\s+{fiscal_year}\s+q4\b", _normalized_table_text(cell)) + } + return next(iter(columns)) if len(columns) == 1 else None + + +def _has_ambiguous_q4_header(rows: tuple[tuple[str, ...], ...]) -> bool: + headers = _normalized_table_text(" ".join(cell for row in rows[:2] for cell in row)) + return "q4" in headers + + +def _dates_in_text(value: str) -> set[str]: + dates: set[str] = set() + for raw_date in re.findall( + r"\b(?:january|february|march|april|may|june|july|august|september|october|november|december)\s+\d{1,2},\s+20\d{2}\b", + str(value or ""), + flags=re.IGNORECASE, + ): + try: + dates.add(datetime.strptime(raw_date.title(), "%B %d, %Y").date().isoformat()) + except ValueError: + continue + for raw_date in re.findall(r"\b20\d{2}-\d{2}-\d{2}\b", str(value or "")): + try: + dates.add(date.fromisoformat(raw_date).isoformat()) + except ValueError: + continue + return dates + + +def _q4_period_end_dates( + matching_tables: Sequence[tuple[tuple[tuple[str, ...], ...], int]], +) -> set[str]: + period_ends: set[str] = set() + for rows, value_column in matching_tables: + for row in rows: + if len(row) <= value_column: + continue + label = _normalized_table_text(row[0]) + value = row[value_column] + value_text = _normalized_table_text(value) + if not any( + marker in label or marker in value_text + for marker in ("period ended", "quarter ended", "three months ended") + ): + continue + period_ends.update(_dates_in_text(value)) + return period_ends + + +def _table_number(value: str, *, scale_required: bool) -> tuple[float, float] | None: + text = _normalized_table_text(value) + if any(word in text for word in ("expected", "approximately", "outlook", "guidance")): + return None + match = re.search(r"(?P\()?\s*\$?\s*(?P\d[\d,]*(?:\.\d+)?)", text) + if not match: + return None + try: + value_number = float(match.group("number").replace(",", "")) + except ValueError: + return None + if match.group("negative"): + value_number = -value_number + if "billion" in text: + return value_number, 1_000_000_000.0 + if "million" in text: + return value_number, 1_000_000.0 + return (value_number, 1.0) if not scale_required else None + + +def _table_level_scale(rows: tuple[tuple[str, ...], ...]) -> tuple[float | None, bool]: + header_text = _normalized_table_text(" ".join(cell for row in rows[:2] for cell in row)) + scales = { + {"thousand": 1_000.0, "million": 1_000_000.0, "billion": 1_000_000_000.0}[match.group(1).rstrip("s")] + for pattern in ( + r"\b(?:dollars?|amounts?|figures?)\b.{0,30}?\b(thousands?|millions?|billions?)\b", + r"\(\s*in\s+(thousands?|millions?|billions?)\s*\)", + ) + for match in re.finditer(pattern, header_text) + } + if len(scales) != 1: + return None, bool(scales) + return next(iter(scales)), False + + +def _derived_q4_context(text: str) -> bool: + normalized = _normalized_table_text(text) + annual_pattern = r"\b(?:annual|full[- ]year)\b" + subtraction_pattern = r"\b(?:less|minus|subtract(?:ing|ed)?|deduct(?:ing|ed)?)\b" + short_period_pattern = r"\b(?:nine|9)[- ]months?\b|\b(?:the\s+)?first\s+(?:three|3)\s+quarters?\b" + direct_arithmetic = any( + re.search(pattern, normalized) + for pattern in ( + rf"{annual_pattern}.{{0,80}}{subtraction_pattern}.{{0,80}}(?:{short_period_pattern})", + rf"(?:{short_period_pattern}).{{0,80}}{subtraction_pattern}.{{0,80}}{annual_pattern}", + ) + ) + if direct_arithmetic: + return True + + for match in re.finditer( + r"\b(?:comput(?:e|ed|ing)|calculat(?:e|ed|ing)|deriv(?:e|ed|ing)|subtract(?:ing|ed)|deduct(?:ing|ed))\b", + normalized, + ): + context = normalized[max(0, match.start() - 120) : match.end() + 120] + if ( + re.search(annual_pattern, context) + and re.search(subtraction_pattern, context) + and re.search(short_period_pattern, context) + ): + return True + return False + + +def _q4_metric_values( + rows: tuple[tuple[str, ...], ...], + value_column: int = 1, +) -> tuple[float | None, float | None, bool, bool, bool]: + revenue: float | None = None + eps: float | None = None + non_gaap_eps_present = False + derived = False + revenue_scale_missing = False + table_scale, table_scale_ambiguous = _table_level_scale(rows) + for row in rows: + if len(row) <= value_column: + continue + label = _normalized_table_text(row[0]) + value = row[value_column] + if any(word in label for word in ("less", "minus", "subtract", "derived", "calculated")): + derived = True + continue + if label in {"revenue", "total revenue", "net revenue"}: + parsed = _table_number(value, scale_required=False) + if parsed is not None: + raw_value, value_scale = parsed + if table_scale_ambiguous: + revenue_scale_missing = True + elif value_scale != 1.0 and table_scale is not None and value_scale != table_scale: + revenue_scale_missing = True + elif value_scale != 1.0: + revenue = raw_value * value_scale + elif table_scale is not None: + revenue = raw_value * table_scale + else: + revenue_scale_missing = True + elif "non-gaap" in label and "earnings per share" in label: + non_gaap_eps_present = True + elif label in { + "gaap diluted earnings per share", + "diluted gaap earnings per share", + "gaap diluted eps", + }: + parsed = _table_number(value, scale_required=False) + if parsed is not None: + eps = parsed[0] + return revenue, eps, non_gaap_eps_present, derived, revenue_scale_missing + + +def _merge_q4_metric_values( + candidates: Sequence[tuple[float | None, float | None]], +) -> tuple[float | None, float | None] | None: + revenue_values = {revenue for revenue, _eps in candidates if revenue is not None} + eps_values = {eps for _revenue, eps in candidates if eps is not None} + if len(revenue_values) > 1 or len(eps_values) > 1: + return None + return ( + next(iter(revenue_values)) if revenue_values else None, + next(iter(eps_values)) if eps_values else None, + ) + + +def _split_adjustment_basis(document_text: str) -> str: + text = _normalized_table_text(document_text) + match = re.search( + r"(?:retrospectively|retroactively) adjusted[^.]{0,160}?split[^.]{0,160}?effective\s+([a-z]+\s+\d{1,2},\s+20\d{2})", + text, + ) + if not match: + return PRIMARY_SPLIT_BASIS_UNVERIFIED + try: + effective_date = datetime.strptime(match.group(1), "%B %d, %Y").date() + except ValueError: + return PRIMARY_SPLIT_BASIS_UNVERIFIED + return f"split_adjusted_{effective_date.isoformat().replace('-', '_')}" + + +def extract_explicit_q4_actual( + ticker: str, + exhibit: FiledExhibit, + document_text: str, + *, + fiscal_period: str, + filed_at: str, + retrieved_at: str, + cutoff: str | None = None, +) -> ExtractionResult: + normalized_ticker = str(ticker or "").strip().upper() + parser = _StructuredTableParser() + parser.feed(document_text or "") + audit_rows: list[ExtractionAuditRow] = [] + if not re.fullmatch(r"20\d{2}-Q4", fiscal_period or ""): + return ExtractionResult( + rows=(), + audit_rows=( + _q4_audit(normalized_ticker, "ambiguous_period_header", "quarterly_actual", "", exhibit, "fiscal period is not an explicit Q4 identity"), + ), + ) + explicit_period = _explicit_q4_fiscal_period(parser.page_text) + if explicit_period is not None and explicit_period != fiscal_period: + return ExtractionResult( + rows=(), + audit_rows=( + _q4_audit(normalized_ticker, "ambiguous_period_header", "quarterly_actual", fiscal_period, exhibit, "document does not state one matching fiscal Q4 identity"), + ), + ) + matching_tables = [ + (rows, value_column) + for rows, _table_text in parser.tables + if (value_column := _q4_value_column(rows, fiscal_period)) is not None + ] + if not matching_tables: + state = "ambiguous_period_header" if any(_has_ambiguous_q4_header(rows) for rows, _text in parser.tables) else "quarter_header_missing" + audit_rows.append( + _q4_audit(normalized_ticker, state, "quarterly_actual", fiscal_period, exhibit, "no structured table has an unambiguous matching Q4 header") + ) + if _derived_q4_context(parser.page_text) or any(_q4_metric_values(rows)[3] for rows, _text in parser.tables): + audit_rows.append( + _q4_audit(normalized_ticker, "derived_q4_rejected", "quarterly_actual", fiscal_period, exhibit, "Q4 table labels a metric as derived or arithmetic") + ) + return ExtractionResult( + rows=(), + audit_rows=tuple(audit_rows), + ) + if _derived_q4_context(parser.page_text): + return ExtractionResult( + rows=(), + audit_rows=( + _q4_audit(normalized_ticker, "derived_q4_rejected", "quarterly_actual", fiscal_period, exhibit, "Q4 exhibit context states the selected result was derived from annual and nine-month values"), + ), + ) + candidates: list[tuple[float | None, float | None]] = [] + accepted_period_end_dates: set[str] = set() + for rows, value_column in matching_tables: + table_text = _normalized_table_text(" ".join(cell for row in rows for cell in row)) + revenue, eps, non_gaap_eps_present, derived, revenue_scale_missing = _q4_metric_values(rows, value_column) + table_period_end_dates = _q4_period_end_dates(((rows, value_column),)) + if any(word in table_text for word in ("outlook", "expected", "approximately", "guidance")): + audit_rows.append( + _q4_audit(normalized_ticker, "guidance_or_outlook_rejected", "quarterly_actual", fiscal_period, exhibit, "Q4 table contains guidance or outlook language") + ) + if non_gaap_eps_present and eps is None: + audit_rows.append( + _q4_audit(normalized_ticker, "gaap_eps_missing", "eps", fiscal_period, exhibit, "non-GAAP EPS is not accepted without an explicit GAAP diluted EPS label") + ) + continue + if derived: + audit_rows.append( + _q4_audit(normalized_ticker, "derived_q4_rejected", "quarterly_actual", fiscal_period, exhibit, "Q4 table labels a metric as derived or arithmetic") + ) + continue + if revenue_scale_missing: + audit_rows.append( + _q4_audit(normalized_ticker, "revenue_scale_missing", "revenue", fiscal_period, exhibit, "Revenue lacks one unambiguous dollar scale in the selected Q4 table context") + ) + if non_gaap_eps_present and eps is None: + audit_rows.append( + _q4_audit(normalized_ticker, "gaap_eps_missing", "eps", fiscal_period, exhibit, "non-GAAP EPS is not accepted without an explicit GAAP diluted EPS label") + ) + if revenue is not None and len(table_period_end_dates) != 1: + audit_rows.append( + _q4_audit( + normalized_ticker, + "period_end_missing" if not table_period_end_dates else "period_end_ambiguous", + "revenue", + fiscal_period, + exhibit, + "Revenue table does not state one explicit period-end date for the Q4 value column", + ) + ) + revenue = None + if eps is not None and len(table_period_end_dates) != 1: + audit_rows.append( + _q4_audit( + normalized_ticker, + "period_end_missing" if not table_period_end_dates else "period_end_ambiguous", + "eps", + fiscal_period, + exhibit, + "EPS table does not state one explicit period-end date for the Q4 value column", + ) + ) + eps = None + if revenue is not None or eps is not None: + candidates.append((revenue, eps)) + accepted_period_end_dates.update(table_period_end_dates) + if not candidates: + if not audit_rows: + audit_rows.append( + _q4_audit(normalized_ticker, "ambiguous_concept", "quarterly_actual", fiscal_period, exhibit, "explicit Revenue and GAAP diluted EPS labels were not found in a Q4 table") + ) + return ExtractionResult(rows=(), audit_rows=tuple(audit_rows)) + resolved_metrics = _merge_q4_metric_values(candidates) + if resolved_metrics is None: + audit_rows.append( + _q4_audit(normalized_ticker, "ambiguous_concept", "quarterly_actual", fiscal_period, exhibit, "multiple Q4 result tables disagree") + ) + return ExtractionResult(rows=(), audit_rows=tuple(audit_rows)) + period_end_dates = accepted_period_end_dates + if len(period_end_dates) != 1: + state = "period_end_missing" if not period_end_dates else "period_end_ambiguous" + detail = ( + "selected Q4 result table does not state an explicit period-end date" + if not period_end_dates + else "selected Q4 result table states multiple period-end dates for the Q4 value column" + ) + audit_rows.append( + _q4_audit( + normalized_ticker, + state, + "quarterly_actual", + fiscal_period, + exhibit, + detail, + ) + ) + return ExtractionResult(rows=(), audit_rows=tuple(audit_rows)) + reported_at = datetime.fromisoformat(filed_at.replace("Z", "+00:00")).astimezone(timezone.utc).isoformat() + if cutoff is not None: + cutoff_timestamp = datetime.fromisoformat(cutoff.replace("Z", "+00:00")) + if cutoff_timestamp.tzinfo is None: + raise ValueError("cutoff must be timezone-aware") + if datetime.fromisoformat(reported_at) > cutoff_timestamp.astimezone(timezone.utc): + audit_rows.append( + _q4_audit(normalized_ticker, "post_cutoff_rejected", "quarterly_actual", fiscal_period, exhibit, f"reported_at={reported_at} is after cutoff={cutoff_timestamp.astimezone(timezone.utc).isoformat()}") + ) + return ExtractionResult(rows=(), audit_rows=tuple(audit_rows)) + revenue, eps = resolved_metrics + resolved_period_end = next(iter(period_end_dates)) + row = QuarterlyActual( + ticker=normalized_ticker, + fiscal_period=fiscal_period, + period_end_date=resolved_period_end, + reported_at=reported_at, + revenue_actual=revenue, + eps_actual=eps, + source="sec_filed_exhibit", + source_ref=exhibit.source_ref, + retrieved_at=retrieved_at, + split_adjustment_basis=_split_adjustment_basis(parser.page_text), + ) + for metric, value in (("revenue", revenue), ("eps", eps)): + if value is not None: + audit_rows.append( + _q4_audit(normalized_ticker, "accepted_explicit_q4", metric, fiscal_period, exhibit, f"explicit Q4 {metric} in SEC-filed {exhibit.document_type} table") + ) + if revenue is None or eps is None: + audit_rows.append( + _q4_audit(normalized_ticker, "metric_partial", "quarterly_actual", fiscal_period, exhibit, "only one source-backed Q4 metric is available") + ) + return ExtractionResult(rows=(row,), audit_rows=tuple(audit_rows)) + + +def _required_text(value: object) -> str | None: + text = str(value or "").strip() + return text or None + + +def _finite_json_number(value: object) -> float | None: + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + try: + normalized = float(value) + except OverflowError: + return None + return normalized if isfinite(normalized) else None + + +def _integral_fiscal_year(value: object) -> int | None: + normalized = _finite_json_number(value) + if normalized is None or not normalized.is_integer(): + return None + return int(normalized) + + +def _normalized_fact( + taxonomy: str, + concept: str, + unit: str, + item: object, +) -> SecDurationFact | None: + if not isinstance(item, Mapping): + return None + form = _required_text(item.get("form")) + start = _required_text(item.get("start")) + end = _required_text(item.get("end")) + filed = _required_text(item.get("filed")) + accession = _required_text(item.get("accn")) + fiscal_period = _required_text(item.get("fp")) + try: + value = _finite_json_number(item.get("val")) + fiscal_year = _integral_fiscal_year(item.get("fy")) + if form not in SEC_QUARTERLY_FORMS or not all((start, end, filed, accession, fiscal_period)): + return None + if value is None or fiscal_year is None: + return None + date.fromisoformat(start) + date.fromisoformat(end) + date.fromisoformat(filed) + except (TypeError, ValueError): + return None + return SecDurationFact( + taxonomy=taxonomy, + concept=concept, + unit=unit, + value=value, + start=start, + end=end, + filed=filed, + form=form, + accession=accession, + fiscal_year=fiscal_year, + fiscal_period=fiscal_period, + frame=str(item.get("frame") or "").strip(), + ) + + +def normalize_sec_duration_facts(payload: Mapping[str, object]) -> tuple[SecDurationFact, ...]: + facts_root = payload.get("facts") if isinstance(payload, Mapping) else None + if not isinstance(facts_root, Mapping): + return () + facts: list[SecDurationFact] = [] + for taxonomy, taxonomy_facts in facts_root.items(): + if not isinstance(taxonomy_facts, Mapping): + continue + for concept, concept_data in taxonomy_facts.items(): + if not isinstance(concept_data, Mapping): + continue + units = concept_data.get("units") + if not isinstance(units, Mapping): + continue + for unit, items in units.items(): + if not isinstance(items, list): + continue + for item in items: + fact = _normalized_fact(str(taxonomy), str(concept), str(unit), item) + if fact is not None: + facts.append(fact) + return tuple( + sorted( + facts, + key=lambda fact: ( + fact.taxonomy, + fact.concept, + fact.unit, + fact.accession, + fact.fiscal_year, + fact.fiscal_period, + fact.end, + fact.start, + fact.filed, + fact.value, + fact.frame, + ), + ) + ) + + +def _metric_for(fact: SecDurationFact) -> str | None: + if fact.taxonomy != "us-gaap": + return None + if fact.concept in REVENUE_CONCEPTS: + return "revenue" + if fact.concept == EPS_CONCEPT: + return "eps" + return None + + +def _source_ref(cik: object, accession: str) -> str: + cik_digits = "".join(character for character in str(cik or "") if character.isdigit()).lstrip("0") or "0" + accession_path = accession.replace("-", "") + return f"https://www.sec.gov/Archives/edgar/data/{cik_digits}/{accession_path}/" + + +def _reported_at(filed: str) -> str: + return datetime.combine(date.fromisoformat(filed), time(23, 59, 59), tzinfo=timezone.utc).isoformat() + + +def _audit( + ticker: str, + state: str, + metric: str, + fiscal_period: str, + source_ref: str, + detail: str, + fact: SecDurationFact | None = None, +) -> ExtractionAuditRow: + return ExtractionAuditRow( + ticker=ticker, + state=state, + metric=metric, + fiscal_period=fiscal_period, + source_ref=source_ref, + detail=detail, + concept=fact.concept if fact else "", + start=fact.start if fact else "", + end=fact.end if fact else "", + frame=fact.frame if fact else "", + accession=fact.accession if fact else "", + ) + + +def _choose_metric( + facts: list[SecDurationFact], + metric: str, + ticker: str, + fiscal_period: str, + source_ref: str, + audit_rows: list[ExtractionAuditRow], +) -> float | None: + if not facts: + return None + frames = {fact.frame for fact in facts if fact.frame} + values = {fact.value for fact in facts} + if len(frames) > 1 or len(values) > 1: + audit_rows.append( + _audit( + ticker, + "ambiguous_concept", + metric, + fiscal_period, + source_ref, + "conflicting concepts, values, or SEC frames for one quarter", + facts[0], + ) + ) + return None + if metric == "revenue": + concept_rank = {concept: index for index, concept in enumerate(REVENUE_CONCEPTS)} + facts.sort(key=lambda fact: (concept_rank[fact.concept], fact.unit, fact.frame)) + return facts[0].value + + +def extract_q1_q3_lineage( + ticker: str, + payload: Mapping[str, object], + *, + cutoff: str, + retrieved_at: str, +) -> ExtractionResult: + normalized_ticker = str(ticker or "").strip().upper() + cutoff_timestamp = datetime.fromisoformat(cutoff.replace("Z", "+00:00")) + if cutoff_timestamp.tzinfo is None: + raise ValueError("cutoff must be timezone-aware") + cutoff_timestamp = cutoff_timestamp.astimezone(timezone.utc) + facts = normalize_sec_duration_facts(payload) + audit_rows: list[ExtractionAuditRow] = [] + quarter_facts: list[SecDurationFact] = [] + for fact in facts: + metric = _metric_for(fact) + if metric is None: + continue + source_ref = _source_ref(payload.get("cik"), fact.accession) + fiscal_period = f"{fact.fiscal_year}-{fact.fiscal_period}" + if not 60 <= fact.duration_days <= 120: + audit_rows.append( + _audit( + normalized_ticker, + "cumulative_fact_rejected", + metric, + fiscal_period, + source_ref, + f"duration_days={fact.duration_days} is outside the 60-120 day quarter range", + fact, + ) + ) + continue + if fact.fiscal_period not in {"Q1", "Q2", "Q3"}: + continue + quarter_facts.append(fact) + + latest_end_by_filing: dict[tuple[str, int, str], str] = {} + for fact in quarter_facts: + key = (fact.accession, fact.fiscal_year, fact.fiscal_period) + latest_end_by_filing[key] = max(latest_end_by_filing.get(key, fact.end), fact.end) + + current_facts: list[SecDurationFact] = [] + comparative_facts: list[SecDurationFact] = [] + for fact in quarter_facts: + key = (fact.accession, fact.fiscal_year, fact.fiscal_period) + metric = _metric_for(fact) + assert metric is not None + source_ref = _source_ref(payload.get("cik"), fact.accession) + fiscal_period = f"{fact.fiscal_year}-{fact.fiscal_period}" + if fact.end != latest_end_by_filing[key]: + comparative_facts.append(fact) + continue + current_facts.append(fact) + + identities_by_end: dict[str, set[str]] = {} + ends_by_identity: dict[str, set[str]] = {} + for fact in current_facts: + identity = f"{fact.fiscal_year}-{fact.fiscal_period}" + identities_by_end.setdefault(fact.end, set()).add(identity) + ends_by_identity.setdefault(identity, set()).add(fact.end) + conflicting_ends = {end for end, identities in identities_by_end.items() if len(identities) > 1} + conflicting_identities = { + identity for identity, period_ends in ends_by_identity.items() if len(period_ends) > 1 + } + for fact in current_facts: + identity = f"{fact.fiscal_year}-{fact.fiscal_period}" + if fact.end not in conflicting_ends and identity not in conflicting_identities: + continue + metric = _metric_for(fact) + assert metric is not None + detail = ( + "multiple current-quarter filings assign different fiscal identities to one period end" + if fact.end in conflicting_ends + else "one fiscal identity maps to multiple current-quarter period ends" + ) + audit_rows.append( + _audit( + normalized_ticker, + "fiscal_period_conflict", + metric, + identity, + _source_ref(payload.get("cik"), fact.accession), + detail, + fact, + ) + ) + + facts_by_identity: list[tuple[SecDurationFact, str]] = [] + comparative_signatures: set[tuple[str, str, str, str, int, str]] = set() + for fact in current_facts: + identity = f"{fact.fiscal_year}-{fact.fiscal_period}" + if fact.end not in conflicting_ends and identity not in conflicting_identities: + facts_by_identity.append((fact, identity)) + for fact in comparative_facts: + metric = _metric_for(fact) + assert metric is not None + source_ref = _source_ref(payload.get("cik"), fact.accession) + identity = identities_by_end.get(fact.end, set()) + canonical_identity = next(iter(identity)) if len(identity) == 1 else None + if ( + canonical_identity is not None + and fact.end not in conflicting_ends + and canonical_identity not in conflicting_identities + ): + facts_by_identity.append((fact, canonical_identity)) + comparative_signatures.add( + (fact.accession, fact.start, fact.end, fact.filed, fact.fiscal_year, fact.fiscal_period) + ) + continue + audit_rows.append( + _audit( + normalized_ticker, + "comparative_period_relabelled", + metric, + f"{fact.fiscal_year}-{fact.fiscal_period}", + source_ref, + "comparative period has no uniquely established original fiscal identity", + fact, + ) + ) + + by_signature: dict[tuple[str, str, str, str, int, str], list[SecDurationFact]] = {} + identity_by_signature: dict[tuple[str, str, str, str, int, str], str] = {} + for fact, fiscal_period in facts_by_identity: + signature = (fact.accession, fact.start, fact.end, fact.filed, fact.fiscal_year, fact.fiscal_period) + by_signature.setdefault(signature, []).append(fact) + identity_by_signature[signature] = fiscal_period + + rows: list[QuarterlyActual] = [] + for signature, signature_facts in sorted(by_signature.items(), key=lambda item: (item[0][2], item[0][3], item[0][0])): + accession, start, end, filed, fiscal_year, fiscal_quarter = signature + fiscal_period = identity_by_signature[signature] + source_ref = _source_ref(payload.get("cik"), accession) + revenue = _choose_metric( + [fact for fact in signature_facts if _metric_for(fact) == "revenue"], + "revenue", + normalized_ticker, + fiscal_period, + source_ref, + audit_rows, + ) + eps = _choose_metric( + [fact for fact in signature_facts if _metric_for(fact) == "eps"], + "eps", + normalized_ticker, + fiscal_period, + source_ref, + audit_rows, + ) + if revenue is None and eps is None: + continue + reported_at = _reported_at(filed) + if datetime.fromisoformat(reported_at).astimezone(timezone.utc) > cutoff_timestamp: + audit_rows.append( + _audit( + normalized_ticker, + "post_cutoff_rejected", + "quarterly_actual", + fiscal_period, + source_ref, + f"reported_at={reported_at} is after cutoff={cutoff_timestamp.isoformat()}", + signature_facts[0], + ) + ) + continue + rows.append( + QuarterlyActual( + ticker=normalized_ticker, + fiscal_period=fiscal_period, + period_end_date=end, + reported_at=reported_at, + revenue_actual=revenue, + eps_actual=eps, + source="sec_companyfacts", + source_ref=source_ref, + retrieved_at=retrieved_at, + split_adjustment_basis=( + COMPANYFACTS_SPLIT_BASIS_UNVERIFIED if eps is not None else "as_reported" + ), + ) + ) + accepted_state = "accepted_revision" if signature in comparative_signatures else "accepted_explicit_quarter" + accepted_detail = ( + "comparative presentation inherits a uniquely established fiscal identity" + if accepted_state == "accepted_revision" + else "is a uniquely aligned current-quarter SEC duration fact" + ) + for metric, value in (("revenue", revenue), ("eps", eps)): + if value is not None: + audit_rows.append( + _audit( + normalized_ticker, + accepted_state, + metric, + fiscal_period, + source_ref, + f"{metric} {accepted_detail}", + signature_facts[0], + ) + ) + if eps is not None: + audit_rows.append( + _audit( + normalized_ticker, + "split_basis_unverified", + "eps", + fiscal_period, + source_ref, + "SEC Companyfacts does not prove whether comparative diluted EPS is on a split-comparable basis", + signature_facts[0], + ) + ) + if revenue is None or eps is None: + audit_rows.append( + _audit( + normalized_ticker, + "metric_partial", + "quarterly_actual", + fiscal_period, + source_ref, + "only one source-backed metric is available for the quarter", + signature_facts[0], + ) + ) + return ExtractionResult(rows=tuple(rows), audit_rows=tuple(audit_rows)) + + +def _same_actual_presentation(left: QuarterlyActual, right: QuarterlyActual) -> bool: + return ( + left.period_end_date, + left.revenue_actual, + left.eps_actual, + left.revenue_currency, + left.revenue_unit_scale, + left.revenue_basis, + left.eps_currency, + left.eps_basis, + left.eps_share_basis, + left.eps_operations_basis, + left.split_adjustment_basis, + ) == ( + right.period_end_date, + right.revenue_actual, + right.eps_actual, + right.revenue_currency, + right.revenue_unit_scale, + right.revenue_basis, + right.eps_currency, + right.eps_basis, + right.eps_share_basis, + right.eps_operations_basis, + right.split_adjustment_basis, + ) + + +def link_quarter_revisions(rows: Sequence[QuarterlyActual]) -> tuple[QuarterlyActual, ...]: + linked: list[QuarterlyActual] = [] + for row in sorted(rows, key=lambda item: (item.ticker, item.fiscal_period, item.reported_at, item.source, item.source_ref)): + family_rows = [ + prior + for prior in linked + if prior.ticker == row.ticker + and prior.fiscal_period == row.fiscal_period + and prior.period_end_date == row.period_end_date + and prior.source == row.source + and prior.reported_at < row.reported_at + ] + if family_rows and _same_actual_presentation(family_rows[-1], row): + continue + if family_rows: + linked.append(replace(row, supersedes_source_ref=family_rows[-1].source_ref)) + else: + linked.append(row) + return tuple(linked) + + +def _actual_csv_row(row: QuarterlyActual) -> dict[str, object]: + return {"schema_version": EVIDENCE_SCHEMA_VERSION, **asdict(row)} + + +def _cached_sec_ticker_map(cache_dir: Path) -> dict[str, dict[str, Any]]: + """Read the standard SEC ticker cache without falling back to the network.""" + cache_path = cache_dir / "company_tickers.json" + if not cache_path.exists(): + raise RuntimeError(f"SEC network access is disabled and ticker-map cache is missing: {cache_path}") + try: + payload = json.loads(cache_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise RuntimeError(f"SEC network access is disabled and ticker-map cache is unreadable: {cache_path}") from exc + rows = payload.values() if isinstance(payload, dict) else payload if isinstance(payload, list) else () + ticker_map: dict[str, dict[str, Any]] = {} + for row in rows: + if not isinstance(row, Mapping): + continue + ticker = str(row.get("ticker") or "").strip().upper() + cik_value = row.get("cik_str") or row.get("cik") or row.get("cikStr") + if not ticker or cik_value in (None, ""): + continue + cik_text = str(cik_value).strip() + cik = str(int(cik_text)).zfill(10) if cik_text.isdigit() else cik_text.zfill(10) + ticker_map[ticker] = { + "ticker": ticker, + "cik": cik, + "title": row.get("title") or row.get("name"), + "exchange": row.get("exchange"), + } + if not ticker_map: + raise RuntimeError(f"SEC network access is disabled and ticker-map cache has no usable rows: {cache_path}") + return ticker_map + + +def _network_disabled_fetcher(*_args: object, **_kwargs: object) -> Any: + raise RuntimeError("SEC network access is disabled; provide cached evidence or rerun without --no-network") + + +def _rejected_audit_rows(results: Mapping[str, ExtractionResult]) -> list[ExtractionAuditRow]: + return [ + audit_row + for ticker in sorted(results) + for audit_row in results[ticker].audit_rows + if audit_row.state in REJECTED_AUDIT_STATES + ] + + +def _is_within(path: Path, parent: Path) -> bool: + return path == parent or parent in path.parents + + +def _has_generated_stage_marker(root: Path) -> bool: + audit_path = root / "sec_actuals_audit.json" + if not audit_path.is_file(): + return False + try: + payload = json.loads(audit_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return False + return ( + isinstance(payload, Mapping) + and payload.get("mode") == "preview_only" + and payload.get("automatic_apply") is False + ) + + +def _validated_stage_output_dir(output_dir: Path | str) -> Path: + root = Path(output_dir).expanduser().resolve(strict=False) + project_root = Path(__file__).resolve().parents[1] + canonical_data = (project_root / "data").resolve(strict=False) + if _is_within(root, canonical_data): + raise ValueError( + "SEC actuals staging requires a generated temporary/review directory outside canonical data and imports" + ) + + temporary_root = Path(tempfile.gettempdir()).resolve(strict=False) + review_named = any( + marker in part.lower() + for part in root.parts + for marker in ("generated", "review", "sec-actuals", "sec_actuals", "stage", "staging") + ) + allowed_location = _is_within(root, temporary_root) or review_named + if not allowed_location: + raise ValueError( + "SEC actuals staging requires a generated temporary/review directory" + ) + + if root.exists(): + if not root.is_dir(): + raise ValueError("SEC actuals staging output must be a directory") + entries = {path.name for path in root.iterdir()} + generated_stage = ( + not entries + or entries <= {".sec-cache"} + or (_has_generated_stage_marker(root) and entries <= _STAGE_OUTPUT_NAMES) + ) + if not generated_stage: + raise ValueError( + "SEC actuals staging refuses an existing non-generated evidence directory; use a new generated temporary/review directory" + ) + return root + + +def _stage_identity_conflicts( + rows: Sequence[QuarterlyActual], +) -> tuple[set[tuple[str, str, str]], list[ExtractionAuditRow]]: + period_ends_by_identity: dict[tuple[str, str], set[str]] = {} + identities_by_period_end: dict[tuple[str, str], set[str]] = {} + for row in rows: + period_ends_by_identity.setdefault((row.ticker, row.fiscal_period), set()).add( + row.period_end_date + ) + identities_by_period_end.setdefault((row.ticker, row.period_end_date), set()).add( + row.fiscal_period + ) + conflicting_identities = { + identity + for identity, period_ends in period_ends_by_identity.items() + if len(period_ends) > 1 + } + conflicting_period_ends = { + period_end + for period_end, identities in identities_by_period_end.items() + if len(identities) > 1 + } + conflicts = { + (row.ticker, row.fiscal_period, row.period_end_date) + for row in rows + if (row.ticker, row.fiscal_period) in conflicting_identities + or (row.ticker, row.period_end_date) in conflicting_period_ends + } + audit_rows = [ + ExtractionAuditRow( + ticker=row.ticker, + state="fiscal_period_conflict", + metric="quarterly_actual", + fiscal_period=row.fiscal_period, + source_ref=row.source_ref, + detail=( + "one period end maps to multiple fiscal identities at the staging boundary" + if (row.ticker, row.period_end_date) in conflicting_period_ends + else "one fiscal identity maps to multiple period ends at the staging boundary" + ), + end=row.period_end_date, + ) + for row in rows + if (row.ticker, row.fiscal_period, row.period_end_date) in conflicts + ] + return conflicts, audit_rows + + +def write_sec_actuals_stage(output_dir: Path, results: Mapping[str, ExtractionResult]) -> StageResult: + root = _validated_stage_output_dir(output_dir) + root.mkdir(parents=True, exist_ok=True) + requested_tickers = tuple(sorted({str(ticker).strip().upper() for ticker in results if str(ticker).strip()})) + extracted_rows = [row for ticker in requested_tickers for row in results[ticker].rows] + conflicting_rows, conflict_audit_rows = _stage_identity_conflicts(extracted_rows) + linked_rows = link_quarter_revisions( + [ + row + for row in extracted_rows + if (row.ticker, row.fiscal_period, row.period_end_date) not in conflicting_rows + ] + ) + accepted_tickers = tuple(sorted({row.ticker for row in linked_rows})) + withheld_tickers = tuple(ticker for ticker in requested_tickers if ticker not in accepted_tickers) + rejected_rows = _rejected_audit_rows(results) + conflict_audit_rows + all_audit_rows = [ + audit_row + for ticker in requested_tickers + for audit_row in results[ticker].audit_rows + ] + conflict_audit_rows + + quarterly_actuals_path = root / "quarterly_actuals.csv" + with quarterly_actuals_path.open("w", newline="", encoding="utf-8") as handle: + writer = csv.DictWriter(handle, fieldnames=SCHEMAS["quarterly_actuals.csv"]) + writer.writeheader() + writer.writerows(_actual_csv_row(row) for row in linked_rows) + for filename in ("consensus_snapshots.csv", "signals.csv"): + with (root / filename).open("w", newline="", encoding="utf-8") as handle: + csv.writer(handle).writerow(SCHEMAS[filename]) + + audit_path = root / "sec_actuals_audit.json" + audit_payload = { + "schema_version": EVIDENCE_SCHEMA_VERSION, + "mode": "preview_only", + "automatic_apply": False, + "requested_tickers": requested_tickers, + "accepted_tickers": accepted_tickers, + "withheld_tickers": withheld_tickers, + "accepted_row_count": len(linked_rows), + "rejected_row_count": len(rejected_rows), + "audit_rows": [asdict(row) for row in all_audit_rows], + } + audit_path.write_text(json.dumps(audit_payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + rejected_path = root / "sec_actuals_rejected.csv" + rejected_fields = ( + "ticker", + "state", + "reason_code", + "metric", + "fiscal_period", + "source_ref", + "detail", + "concept", + "start", + "end", + "frame", + "accession", + ) + with rejected_path.open("w", newline="", encoding="utf-8") as handle: + writer = csv.DictWriter(handle, fieldnames=rejected_fields) + writer.writeheader() + writer.writerows({"reason_code": row.state, **asdict(row)} for row in rejected_rows) + + return StageResult( + requested_tickers=requested_tickers, + accepted_tickers=accepted_tickers, + withheld_tickers=withheld_tickers, + accepted_row_count=len(linked_rows), + rejected_row_count=len(rejected_rows), + quarterly_actuals_path=str(quarterly_actuals_path), + audit_path=str(audit_path), + rejected_path=str(rejected_path), + ) + + +def _recent_q4_candidate_filings(payload: Mapping[str, Any]) -> tuple[tuple[str, str, str], ...]: + recent = payload.get("filings", {}).get("recent", {}) if isinstance(payload.get("filings"), Mapping) else {} + if not isinstance(recent, Mapping): + return () + forms = recent.get("form") if isinstance(recent.get("form"), list) else [] + filing_dates = recent.get("filingDate") if isinstance(recent.get("filingDate"), list) else [] + report_dates = recent.get("reportDate") if isinstance(recent.get("reportDate"), list) else [] + accessions = recent.get("accessionNumber") if isinstance(recent.get("accessionNumber"), list) else [] + candidates: list[tuple[str, str, str]] = [] + for index, form in enumerate(forms): + if str(form or "").strip().upper() not in {"8-K", "8-K/A"}: + continue + accession = str(accessions[index] if index < len(accessions) else "").strip() + filed_date = str(filing_dates[index] if index < len(filing_dates) else "").strip() + report_date = str(report_dates[index] if index < len(report_dates) else "").strip() + if accession and filed_date: + candidates.append((accession, filed_date, report_date)) + return tuple(candidates) + + +def _q4_source_unavailable( + ticker: str, + source_ref: str, + detail: str, + accession: str = "", +) -> ExtractionAuditRow: + return ExtractionAuditRow( + ticker=ticker, + state="q4_source_unavailable", + metric="quarterly_actual", + fiscal_period="", + source_ref=source_ref, + detail=detail, + accession=accession, + ) + + +def _combine_q4_results( + ticker: str, + q4_results: Sequence[ExtractionResult], +) -> ExtractionResult: + audit_rows = [audit_row for result in q4_results for audit_row in result.audit_rows] + rows = [row for result in q4_results for row in result.rows] + by_period: dict[str, list[QuarterlyActual]] = {} + for row in rows: + by_period.setdefault(row.fiscal_period, []).append(row) + accepted_rows: list[QuarterlyActual] = [] + for fiscal_period, period_rows in sorted(by_period.items()): + source_refs = {row.source_ref for row in period_rows} + if len(source_refs) > 1: + audit_rows.append( + ExtractionAuditRow( + ticker=ticker, + state="ambiguous_concept", + metric="quarterly_actual", + fiscal_period=fiscal_period, + source_ref="", + detail="Q4 metrics span multiple SEC-filed exhibits; per-metric provenance cannot be represented", + ) + ) + continue + resolved_metrics = _merge_q4_metric_values( + [(row.revenue_actual, row.eps_actual) for row in period_rows] + ) + if resolved_metrics is None: + audit_rows.append( + ExtractionAuditRow( + ticker=ticker, + state="ambiguous_concept", + metric="quarterly_actual", + fiscal_period=fiscal_period, + source_ref=period_rows[0].source_ref, + detail="multiple SEC-filed Q4 exhibits disagree", + ) + ) + continue + canonical_row = sorted(period_rows, key=lambda row: row.source_ref)[0] + accepted_rows.append( + replace( + canonical_row, + revenue_actual=resolved_metrics[0], + eps_actual=resolved_metrics[1], + ) + ) + return ExtractionResult(rows=tuple(accepted_rows), audit_rows=tuple(audit_rows)) + + +def _date_only_filing_availability(filed_date: str, cutoff: str) -> tuple[str | None, str | None]: + """Treat date-only SEC metadata as available at 23:59:59 UTC and fail closed before then.""" + filing_day = date.fromisoformat(filed_date) + cutoff_timestamp = datetime.fromisoformat(cutoff.replace("Z", "+00:00")) + if cutoff_timestamp.tzinfo is None: + raise ValueError("cutoff must be timezone-aware") + conservative_available_at = datetime.fromisoformat(f"{filing_day.isoformat()}T23:59:59+00:00") + normalized_cutoff = cutoff_timestamp.astimezone(timezone.utc) + if conservative_available_at > normalized_cutoff: + return None, ( + f"date-only filed metadata={filing_day.isoformat()} is treated as available at " + f"{conservative_available_at.isoformat()}, after cutoff={normalized_cutoff.isoformat()}" + ) + return conservative_available_at.isoformat(), None + + +def stage_sec_quarterly_actuals( + tickers: Sequence[str], + *, + output_dir: Path, + cutoff: str, + user_agent: str | None, + retrieved_at: str | None = None, + cache_dir: Path | str | None = None, + refresh: bool = False, + allow_network: bool = True, + sleep_seconds: float = 0.2, + ticker_map: Mapping[str, Mapping[str, Any]] | None = None, + ticker_map_loader: Callable[..., Mapping[str, Mapping[str, Any]]] = load_sec_ticker_map, + ticker_map_fetcher: Callable[[str, str, float], Any] | None = None, + companyfacts_loader: Callable[..., Mapping[str, Any]] = fetch_companyfacts, + companyfacts_fetcher: Callable[[str, str, float], Any] | None = None, + submissions_loader: Callable[..., Mapping[str, Any]] = fetch_sec_submission, + submissions_fetcher: Callable[[str, str, float], Any] | None = None, + filing_index_loader: Callable[..., str] = fetch_sec_filing_index, + filing_index_fetcher: Callable[[str, str, float], str] | None = None, + exhibit_document_loader: Callable[..., str] = fetch_sec_filing_document, + exhibit_document_fetcher: Callable[[str, str, float], str] | None = None, +) -> StageResult: + resolved_output_dir = _validated_stage_output_dir(output_dir) + requested_tickers = tuple(sorted({str(ticker).strip().upper() for ticker in tickers if str(ticker).strip()})) + resolved_cache_dir = Path(cache_dir) if cache_dir is not None else resolved_output_dir / ".sec-cache" + provider_refresh = refresh if allow_network else False + provider_cache = not allow_network + if ticker_map is None: + if allow_network: + resolved_ticker_map = ticker_map_loader( + cache_dir=resolved_cache_dir, + user_agent=user_agent, + refresh=provider_refresh, + sleep_seconds=sleep_seconds, + fetcher=ticker_map_fetcher, + ) + else: + resolved_ticker_map = _cached_sec_ticker_map(resolved_cache_dir) + else: + resolved_ticker_map = ticker_map + resolved_retrieved_at = retrieved_at or datetime.now(timezone.utc).isoformat() + results: dict[str, ExtractionResult] = {} + for ticker in requested_tickers: + cik = resolve_ticker_to_cik(ticker, dict(resolved_ticker_map)) + if cik is None: + results[ticker] = ExtractionResult( + rows=(), + audit_rows=( + _audit(ticker, "ticker_unresolved", "quarterly_actual", "", "", "no SEC CIK mapping was found"), + ), + ) + continue + try: + payload = companyfacts_loader( + cik, + user_agent, + cache=provider_cache, + refresh=provider_refresh, + cache_dir=resolved_cache_dir, + sleep_seconds=sleep_seconds, + fetcher=companyfacts_fetcher if allow_network else _network_disabled_fetcher, + ) + except RuntimeError as exc: + results[ticker] = ExtractionResult( + rows=(), + audit_rows=( + _audit(ticker, "companyfacts_fetch_failed", "quarterly_actual", "", "", str(exc)), + ), + ) + continue + q1_q3_result = extract_q1_q3_lineage( + ticker, + payload, + cutoff=cutoff, + retrieved_at=resolved_retrieved_at, + ) + try: + submissions_payload = submissions_loader( + cik, + user_agent, + cache=provider_cache, + refresh=provider_refresh, + cache_dir=resolved_cache_dir, + sleep_seconds=sleep_seconds, + fetcher=submissions_fetcher if allow_network else _network_disabled_fetcher, + ) + except RuntimeError as exc: + results[ticker] = ExtractionResult( + rows=q1_q3_result.rows, + audit_rows=q1_q3_result.audit_rows + + (_q4_source_unavailable(ticker, "", f"SEC submissions lookup failed: {exc}"),), + ) + continue + q4_candidates = _recent_q4_candidate_filings(submissions_payload) + q4_results: list[ExtractionResult] = [] + if not q4_candidates: + q4_results.append( + ExtractionResult( + rows=(), + audit_rows=( + _q4_source_unavailable( + ticker, + "", + "no eligible 8-K or 8-K/A Q4 candidate filing was found", + ), + ), + ) + ) + for accession, filed_date, _report_date in q4_candidates: + filed_at, cutoff_detail = _date_only_filing_availability(filed_date, cutoff) + if filed_at is None: + q4_results.append( + ExtractionResult( + rows=(), + audit_rows=( + ExtractionAuditRow( + ticker=ticker, + state="post_cutoff_rejected", + metric="quarterly_actual", + fiscal_period="", + source_ref="", + detail=cutoff_detail or "date-only filing metadata is unavailable at the requested cutoff", + accession=accession, + ), + ), + ) + ) + continue + try: + index_html = filing_index_loader( + cik, + accession, + user_agent, + cache=provider_cache, + refresh=provider_refresh, + cache_dir=resolved_cache_dir, + sleep_seconds=sleep_seconds, + fetcher=filing_index_fetcher if allow_network else _network_disabled_fetcher, + ) + except RuntimeError as exc: + q4_results.append( + ExtractionResult( + rows=(), + audit_rows=(_q4_source_unavailable(ticker, "", f"filing index lookup failed: {exc}", accession),), + ) + ) + continue + exhibits = extract_filing_exhibits(index_html, cik=cik, accession=accession) + if not exhibits: + q4_results.append( + ExtractionResult( + rows=(), + audit_rows=(_q4_source_unavailable(ticker, "", "no EX-99 result exhibit was found", accession),), + ) + ) + continue + for exhibit in exhibits: + try: + document_text = exhibit_document_loader( + cik, + accession, + exhibit.document_name, + user_agent, + cache=provider_cache, + refresh=provider_refresh, + cache_dir=resolved_cache_dir, + sleep_seconds=sleep_seconds, + fetcher=exhibit_document_fetcher if allow_network else _network_disabled_fetcher, + ) + except RuntimeError as exc: + q4_results.append( + ExtractionResult( + rows=(), + audit_rows=(_q4_source_unavailable(ticker, exhibit.source_ref, f"exhibit lookup failed: {exc}", accession),), + ) + ) + continue + fiscal_period = _explicit_q4_fiscal_period(document_text) + if fiscal_period is None: + q4_results.append( + ExtractionResult( + rows=(), + audit_rows=( + _q4_audit( + ticker, + "ambiguous_period_header", + "quarterly_actual", + "", + exhibit, + "document does not state one explicit fiscal Q4 identity", + ), + ), + ) + ) + continue + q4_results.append( + extract_explicit_q4_actual( + ticker, + exhibit, + document_text, + fiscal_period=fiscal_period, + filed_at=filed_at, + retrieved_at=resolved_retrieved_at, + cutoff=cutoff, + ) + ) + q4_result = _combine_q4_results(ticker, q4_results) + results[ticker] = ExtractionResult( + rows=q1_q3_result.rows + q4_result.rows, + audit_rows=q1_q3_result.audit_rows + q4_result.audit_rows, + ) + return write_sec_actuals_stage(resolved_output_dir, results) + + +def _continuity_gaps(rows: Sequence[Mapping[str, str]]) -> list[dict[str, object]]: + periods = sorted( + { + (int(match.group("year")), int(match.group("quarter"))) + for row in rows + if (match := _FISCAL_PERIOD_PATTERN.match(str(row.get("fiscal_period") or ""))) + } + ) + gaps: list[dict[str, object]] = [] + for prior, current in zip(periods, periods[1:]): + prior_index = prior[0] * 4 + prior[1] - 1 + current_index = current[0] * 4 + current[1] - 1 + if current_index <= prior_index + 1: + continue + missing = [] + for index in range(prior_index + 1, current_index): + year, quarter_offset = divmod(index, 4) + missing.append(f"{year}-Q{quarter_offset + 1}") + gaps.append( + { + "after_fiscal_period": f"{prior[0]}-Q{prior[1]}", + "before_fiscal_period": f"{current[0]}-Q{current[1]}", + "missing_fiscal_periods": missing, + } + ) + return gaps + + +def build_sec_actuals_stage_summary(result: StageResult) -> dict[str, object]: + with Path(result.quarterly_actuals_path).open(newline="", encoding="utf-8") as handle: + accepted_rows = list(csv.DictReader(handle)) + with Path(result.rejected_path).open(newline="", encoding="utf-8") as handle: + rejected_rows = list(csv.DictReader(handle)) + ticker_summaries: dict[str, dict[str, object]] = {} + for ticker in result.requested_tickers: + ticker_accepted = [row for row in accepted_rows if row.get("ticker") == ticker] + ticker_rejected = [row for row in rejected_rows if row.get("ticker") == ticker] + source_refs = sorted( + { + str(row.get("source_ref") or "") + for row in ticker_accepted + ticker_rejected + if str(row.get("source_ref") or "") + } + ) + metrics = {} + for metric, value_field in (("revenue", "revenue_actual"), ("eps", "eps_actual")): + metric_accepted = [ + row for row in ticker_accepted if str(row.get(value_field) or "").strip() + ] + metrics[metric] = { + "accepted_rows": metric_accepted, + "missing_q4": not any( + str(row.get("fiscal_period") or "").endswith("-Q4") + for row in metric_accepted + ), + "continuity_gaps": _continuity_gaps(metric_accepted), + } + ticker_summaries[ticker] = { + "accepted_rows": ticker_accepted, + "rejected_rows": ticker_rejected, + "metrics": metrics, + "source_refs": source_refs, + } + return { + "automatic_apply": False, + "requested_tickers": list(result.requested_tickers), + "accepted_tickers": list(result.accepted_tickers), + "withheld_tickers": list(result.withheld_tickers), + "accepted_row_count": result.accepted_row_count, + "rejected_row_count": result.rejected_row_count, + "paths": { + "quarterly_actuals": result.quarterly_actuals_path, + "audit": result.audit_path, + "rejected_rows": result.rejected_path, + }, + "tickers": ticker_summaries, + } + + +def main( + argv: Sequence[str] | None = None, + *, + stage_runner: Callable[..., StageResult] = stage_sec_quarterly_actuals, +) -> None: + parser = argparse.ArgumentParser(description="Stage SEC quarterly actual evidence to an explicit output directory.") + parser.add_argument("--tickers", required=True, help="Comma-separated ticker list.") + parser.add_argument("--output-dir", required=True, help="Generated staging directory; no canonical data is changed.") + parser.add_argument("--cutoff", required=True, help="Timezone-aware evidence cutoff timestamp.") + parser.add_argument("--sec-user-agent", default=os.environ.get("SEC_USER_AGENT"), help="Identifying SEC User-Agent.") + parser.add_argument("--no-network", action="store_true", help="Use cached SEC evidence only.") + parser.add_argument("--sec-refresh", action="store_true", help="Refresh SEC caches before staging.") + parser.add_argument("--json", action="store_true", help="Print machine-readable staging summary.") + parser.add_argument( + "--max-runtime-seconds", + type=float, + default=None, + help="Fail closed if staging exceeds this many seconds.", + ) + args = parser.parse_args(argv) + if args.no_network and args.sec_refresh: + parser.error("--sec-refresh cannot be combined with --no-network") + try: + output_dir = _validated_stage_output_dir(args.output_dir) + except ValueError as exc: + parser.error(str(exc)) + tickers = [ticker.strip() for ticker in args.tickers.split(",") if ticker.strip()] + previous_handler = None + if args.max_runtime_seconds is not None: + if args.max_runtime_seconds <= 0: + parser.error("--max-runtime-seconds must be greater than zero") + + def _stage_timeout(_signum, _frame): + raise TimeoutError(f"SEC actuals staging exceeded max runtime of {args.max_runtime_seconds:g} seconds") + + previous_handler = signal.getsignal(signal.SIGALRM) + signal.signal(signal.SIGALRM, _stage_timeout) + signal.setitimer(signal.ITIMER_REAL, args.max_runtime_seconds) + try: + result = stage_runner( + tickers, + output_dir=output_dir, + cutoff=args.cutoff, + user_agent=args.sec_user_agent, + refresh=args.sec_refresh, + allow_network=not args.no_network, + ) + except TimeoutError as exc: + parser.error(f"environment_limited: {exc}") + finally: + if args.max_runtime_seconds is not None: + signal.setitimer(signal.ITIMER_REAL, 0.0) + signal.signal(signal.SIGALRM, previous_handler) + summary = build_sec_actuals_stage_summary(result) + if args.json: + print(json.dumps(summary, indent=2, sort_keys=True)) + return + print(f"automatic_apply: {str(summary['automatic_apply']).lower()}") + print(f"accepted_row_count: {summary['accepted_row_count']}") + print(f"rejected_row_count: {summary['rejected_row_count']}") + for ticker, ticker_summary in summary["tickers"].items(): + print( + f"{ticker}: accepted_rows={len(ticker_summary['accepted_rows'])} " + f"rejected_rows={len(ticker_summary['rejected_rows'])} " + f"revenue_missing_q4={str(ticker_summary['metrics']['revenue']['missing_q4']).lower()} " + f"eps_missing_q4={str(ticker_summary['metrics']['eps']['missing_q4']).lower()}" + ) + + +if __name__ == "__main__": + main() diff --git a/src/earnings_nowcast_ui.py b/src/earnings_nowcast_ui.py index 801fdb77..003b7203 100644 --- a/src/earnings_nowcast_ui.py +++ b/src/earnings_nowcast_ui.py @@ -43,14 +43,14 @@ def _unit_label(value: object) -> str: return labels.get(scale, f"scale {scale:g}") -def _metric_definition_text(packet: Mapping[str, Any]) -> str: +def _metric_definition_parts(packet: Mapping[str, Any]) -> tuple[str, str]: definitions = packet.get("metric_definitions", {}) if not isinstance(definitions, Mapping): - return "" + return "", "" revenue = definitions.get("revenue", {}) eps = definitions.get("eps", {}) if not isinstance(revenue, Mapping) or not isinstance(eps, Mapping): - return "" + return "", "" revenue_text = ( f"Revenue definition: {revenue.get('currency', 'currency unspecified')} " f"{_unit_label(revenue.get('unit_scale'))}, {str(revenue.get('basis', 'basis unspecified')).replace('_', ' ')} basis." @@ -62,7 +62,7 @@ def _metric_definition_text(packet: Mapping[str, Any]) -> str: f"{str(eps.get('operations_basis', 'operations basis unspecified')).replace('_', ' ')}, " f"{str(eps.get('split_adjustment_basis', 'split basis unspecified')).replace('_', ' ')}." ) - return f" {revenue_text} {eps_text}" + return " " + revenue_text, " " + eps_text def _forecast_horizon_text(forecast: Mapping[str, object]) -> str: @@ -91,13 +91,22 @@ def nowcast_public_answers( baseline_ready = bool(packet and isinstance(forecast, Mapping) and state not in {"blocked", "excluded"}) evidence_scope = str(packet.get("evidence_scope", "unverified_evidence")) if packet else "unverified_evidence" synthetic_test_only = evidence_scope == "synthetic_test_evidence_only" + revenue_ready = bool(readiness.get("revenue_ready")) and baseline_ready + eps_ready = bool(readiness.get("eps_ready")) and baseline_ready + consensus_ready = bool(readiness.get("consensus_ready")) and baseline_ready + revenue_definition, eps_definition = _metric_definition_parts(packet or {}) if baseline_ready: - ranges = ( - f"Revenue range: {_range_text(forecast, 'revenue')}. " - f"EPS range: {_range_text(forecast, 'eps')}." - f"{_metric_definition_text(packet)}" - f"{_forecast_horizon_text(forecast)}" + revenue_range = ( + f"Revenue range: {_range_text(forecast, 'revenue')}." + f"{revenue_definition}{_forecast_horizon_text(forecast)}" + if revenue_ready + else "Revenue range is withheld because its independent evidence gate did not pass." + ) + eps_range = ( + f"EPS range: {_range_text(forecast, 'eps')}.{eps_definition}" + if eps_ready + else "EPS range is withheld because its independent evidence gate did not pass." ) revenue_classification = str(forecast.get("revenue_classification", "withheld")).replace("_", " ") eps_classification = str(forecast.get("eps_classification", "withheld")).replace("_", " ") @@ -116,7 +125,8 @@ def nowcast_public_answers( ) next_action = "Review the range, then open Advanced evidence only if needed" else: - ranges = "No numerical Revenue or EPS forecast is shown until the baseline evidence gate passes." + revenue_range = "No numerical Revenue forecast is shown until its evidence gate passes." + eps_range = "No numerical EPS forecast is shown until its evidence gate passes." consensus = "Consensus comparison is unavailable because no source-backed baseline is ready." context = "Candidate peer or news context cannot unlock or modify a numerical baseline." withheld = "Revenue, EPS, and numerical Beat/Miss probability remain analytically withheld." @@ -135,11 +145,13 @@ def nowcast_public_answers( eligibility_status = "eligibility_unverified" eligibility_answer = f"{normalized_ticker} eligibility has not been verified." - baseline_status = "synthetic_test_only" if synthetic_test_only and baseline_ready else "ready" if baseline_ready else "blocked" - baseline_answer = ( - "No real-company baseline; a test-only synthetic range is available." + actuals_status = "synthetic_test_only" if synthetic_test_only and baseline_ready else "ready" if baseline_ready else "blocked" + actuals_answer = ( + "Synthetic test-only quarterly actuals satisfy the software fixture contract; they are not real-company evidence." if synthetic_test_only and baseline_ready - else nowcast_state_label(state) + else "Source-backed quarterly actual history is available for at least one metric." + if baseline_ready + else "Source-backed, comparable quarterly actual history is not yet sufficient." ) return { @@ -148,21 +160,26 @@ def nowcast_public_answers( "status": eligibility_status, "answer": eligibility_answer, }, - "baseline": { - "question": "Is a real source-backed baseline available?", - "status": baseline_status, - "answer": baseline_answer, - }, - "ranges": { - "question": "What Revenue/EPS range can be reviewed?", - "status": "ready" if baseline_ready else "withheld", - "answer": ranges, + "actuals": { + "question": "Are source-backed quarterly actuals available?", + "status": actuals_status, + "answer": actuals_answer, }, "consensus": { - "question": "How does the range compare with consensus?", - "status": "ready" if baseline_ready else "withheld", + "question": "Is exact-period point-in-time consensus available?", + "status": "ready" if consensus_ready else "withheld", "answer": consensus, }, + "revenue": { + "question": "Is the Revenue range ready?", + "status": "ready" if revenue_ready else "withheld", + "answer": revenue_range, + }, + "eps": { + "question": "Is the EPS range ready?", + "status": "ready" if eps_ready else "withheld", + "answer": eps_range, + }, "evidence_context": { "question": "What evidence explains the context?", "status": "context_only" if baseline_ready else "blocked", @@ -184,9 +201,10 @@ def nowcast_public_answers( def _public_answers_body(answers: Mapping[str, Mapping[str, str]]) -> str: labels = ( ("eligibility", "Eligibility"), - ("baseline", "Baseline"), - ("ranges", "Range"), + ("actuals", "Actuals"), ("consensus", "Consensus"), + ("revenue", "Revenue"), + ("eps", "EPS"), ("evidence_context", "Context"), ("withheld", "Withheld"), ("next_action", "Next"), diff --git a/src/focused_cohort_coverage.py b/src/focused_cohort_coverage.py new file mode 100644 index 00000000..70ade5c4 --- /dev/null +++ b/src/focused_cohort_coverage.py @@ -0,0 +1,805 @@ +"""Truthful lane coverage for the focused commercial-beta cohort.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Iterable, Mapping + +import pandas as pd + +from src.commercial_source_rights import ( + SourceRights, + load_source_rights_registry, + review_commercial_field_scope, +) +from src.focused_research_cohort import FocusedCohort +from src.earnings_nowcast_contract import QuarterlyActual +from src.quarterly_business_trend import QuarterlyTrendPacket + + +COHORT_COVERAGE_LANES = ( + "adjusted_daily_price_history", + "quarterly_revenue", + "quarterly_eps", + "margins", + "free_cash_flow", + "cash_and_debt", + "shares_outstanding", + "trusted_peers", + "filing_dates", + "earnings_dates", + "point_in_time_consensus", +) + +ALLOWED_COVERAGE_STATES = { + "usable_now", + "partial", + "candidate_context_only", + "blocked", + "excluded", +} + + +@dataclass(frozen=True) +class FocusedCohortCoverageRow: + ticker: str + company_name: str + lane: str + state: str + evidence: str + boundary: str + + +@dataclass(frozen=True) +class FocusedCohortCoverage: + status: str + company_count: int + rows: tuple[FocusedCohortCoverageRow, ...] + message: str + + +@dataclass(frozen=True) +class CohortSavedFieldReview: + technical_available: bool + provenance_complete: bool + commercial_rights_approved: bool + required_supported_fields: tuple[str, ...] + missing_supported_fields: tuple[str, ...] + usable: bool + + +def _text(value: object) -> str: + if value is None: + return "" + try: + if pd.isna(value): + return "" + except (TypeError, ValueError): + pass + return str(value).strip() + + +def _truthy(value: object) -> bool: + return _text(value).lower() in {"true", "1", "yes", "y"} + + +def _normalized_readiness(frame: pd.DataFrame | None) -> dict[str, pd.Series]: + if frame is None or frame.empty or "ticker" not in frame.columns: + return {} + normalized = frame.copy() + normalized.columns = [str(column).strip().lower() for column in normalized.columns] + normalized["ticker"] = normalized["ticker"].map(lambda value: _text(value).upper()) + normalized = normalized[normalized["ticker"].ne("")].drop_duplicates("ticker", keep="last") + return {str(row["ticker"]): row for _, row in normalized.iterrows()} + + +def _row_map(frame: pd.DataFrame | None) -> dict[str, pd.Series]: + return _normalized_readiness(frame) + + +def _has_value(row: pd.Series | None, *fields: str) -> bool: + if row is None: + return False + for field in fields: + if field not in row.index: + continue + value = row.get(field) + if _text(value): + return True + return False + + +def _commercial_field_review( + row: pd.Series | None, + *, + technical_available: bool, + required_fields: tuple[str, ...], + commercial_mode: bool = False, + rights_registry: Mapping[str, SourceRights] | None = None, +) -> CohortSavedFieldReview: + research_source_backed = bool( + row is not None and _has_value(row, "source", "source_ref", "sec_accession") + ) + provenance_complete = bool( + row is not None + and _has_value(row, "source") + and _has_value(row, "source_ref", "sec_accession") + ) + if not commercial_mode: + return CohortSavedFieldReview( + technical_available=technical_available, + provenance_complete=research_source_backed, + commercial_rights_approved=True, + required_supported_fields=required_fields, + missing_supported_fields=(), + usable=technical_available and research_source_backed, + ) + source_id = _text(row.get("source")) if row is not None else "" + registry = rights_registry if rights_registry is not None else load_source_rights_registry() + review = review_commercial_field_scope(registry, source_id, required_fields) + return CohortSavedFieldReview( + technical_available=technical_available, + provenance_complete=provenance_complete, + commercial_rights_approved=review.commercial_rights_approved, + required_supported_fields=review.required_supported_fields, + missing_supported_fields=review.missing_supported_fields, + usable=( + technical_available + and provenance_complete + and review.commercial_evidence_ready + ), + ) + + +def _populated_fields( + row: pd.Series | None, + field_aliases: tuple[tuple[str, tuple[str, ...]], ...], +) -> tuple[str, ...]: + return tuple( + field + for field, aliases in field_aliases + if _has_value(row, *aliases) + ) + + +def _alternative_field_review( + row: pd.Series | None, + field_aliases: tuple[tuple[str, tuple[str, ...]], ...], + *, + commercial_mode: bool, + rights_registry: Mapping[str, SourceRights] | None, +) -> CohortSavedFieldReview: + populated = _populated_fields(row, field_aliases) + if not populated: + return _commercial_field_review( + row, + technical_available=False, + required_fields=(), + commercial_mode=commercial_mode, + rights_registry=rights_registry, + ) + reviews = tuple( + _commercial_field_review( + row, + technical_available=True, + required_fields=(field,), + commercial_mode=commercial_mode, + rights_registry=rights_registry, + ) + for field in populated + ) + if any(review.usable for review in reviews): + selected = next(review for review in reviews if review.usable) + return CohortSavedFieldReview( + technical_available=True, + provenance_complete=selected.provenance_complete, + commercial_rights_approved=selected.commercial_rights_approved, + required_supported_fields=populated, + missing_supported_fields=(), + usable=True, + ) + return CohortSavedFieldReview( + technical_available=True, + provenance_complete=all(review.provenance_complete for review in reviews), + commercial_rights_approved=all( + review.commercial_rights_approved for review in reviews + ), + required_supported_fields=populated, + missing_supported_fields=tuple( + field + for review in reviews + for field in review.missing_supported_fields + ), + usable=False, + ) + + +def _commercial_blocked_evidence( + review: CohortSavedFieldReview, + *, + available: str, + missing: str, +) -> str: + if review.usable: + return available + if not review.technical_available: + return missing + if not review.provenance_complete: + return "A saved technical value exists, but source provenance is incomplete." + if not review.commercial_rights_approved: + return "A saved technical value and provenance exist, but exact-source commercial rights are not approved." + fields = ", ".join(review.missing_supported_fields) or "required lane field" + return ( + "A saved technical value and provenance exist, but registered field scope " + f"is incomplete: {fields}." + ) + + +def _price_history_review( + rows: tuple[pd.Series, ...], + *, + technical_available_hint: bool, + lineage_columns_missing: tuple[str, ...], + commercial_mode: bool, + rights_registry: Mapping[str, SourceRights] | None, +) -> CohortSavedFieldReview: + valid_rows: list[pd.Series] = [] + for row in rows: + date = pd.to_datetime(row.get("date"), errors="coerce") + close = pd.to_numeric( + row.get("adj_close") if _has_value(row, "adj_close") else row.get("close"), + errors="coerce", + ) + if pd.notna(date) and pd.notna(close) and float(close) > 0: + valid_rows.append(row) + if not valid_rows and not (technical_available_hint and lineage_columns_missing): + return CohortSavedFieldReview(False, False, False, ("prices",), (), False) + if not commercial_mode: + return CohortSavedFieldReview(True, True, True, ("prices",), (), True) + + if not valid_rows and lineage_columns_missing: + return CohortSavedFieldReview(True, False, False, ("prices",), (), False) + + registry = rights_registry if rights_registry is not None else load_source_rights_registry() + provenance_complete = all( + _has_value(row, "source") + and _has_value(row, "source_ref") + and _has_value(row, "retrieved_at") + for row in valid_rows + ) + source_reviews = tuple( + review_commercial_field_scope( + registry, _text(row.get("source")), ("prices",) + ) + for row in valid_rows + ) + rights_approved = all(review.commercial_rights_approved for review in source_reviews) + missing_scope = tuple( + dict.fromkeys( + field + for review in source_reviews + for field in review.missing_supported_fields + ) + ) + return CohortSavedFieldReview( + technical_available=True, + provenance_complete=provenance_complete, + commercial_rights_approved=rights_approved, + required_supported_fields=("prices",), + missing_supported_fields=missing_scope, + usable=provenance_complete and rights_approved and not missing_scope, + ) + + +def _quarterly_metric_review( + rows: Iterable[QuarterlyActual], + *, + metric: str, + commercial_mode: bool, + rights_registry: Mapping[str, SourceRights] | None, +) -> CohortSavedFieldReview: + value_field = "revenue_actual" if metric == "revenue" else "eps_actual" + populated_rows = tuple(row for row in rows if getattr(row, value_field) is not None) + if not populated_rows: + return CohortSavedFieldReview(False, False, False, (metric,), (), False) + if not commercial_mode: + return CohortSavedFieldReview(True, True, True, (metric,), (), True) + + registry = rights_registry if rights_registry is not None else load_source_rights_registry() + provenance_complete = all( + bool(_text(row.source) and _text(row.source_ref) and _text(row.retrieved_at)) + for row in populated_rows + ) + source_reviews = tuple( + review_commercial_field_scope(registry, row.source, (metric,)) + for row in populated_rows + ) + rights_approved = all(review.commercial_rights_approved for review in source_reviews) + missing_scope = tuple( + dict.fromkeys( + field + for review in source_reviews + for field in review.missing_supported_fields + ) + ) + return CohortSavedFieldReview( + technical_available=True, + provenance_complete=provenance_complete, + commercial_rights_approved=rights_approved, + required_supported_fields=(metric,), + missing_supported_fields=missing_scope, + usable=provenance_complete and rights_approved and not missing_scope, + ) + + +def _latest_consensus_by_cutoff(frame: pd.DataFrame | None, *, as_of: str | None) -> dict[str, pd.Series]: + if frame is None or frame.empty or not as_of or "ticker" not in frame.columns: + return {} + normalized = frame.copy() + normalized.columns = [str(column).strip().lower() for column in normalized.columns] + timestamp_column = next( + (column for column in ("snapshot_at", "available_at", "retrieved_at", "published_at") if column in normalized.columns), + "", + ) + if not timestamp_column: + return {} + normalized["ticker"] = normalized["ticker"].map(lambda value: _text(value).upper()) + normalized["_snapshot_at"] = pd.to_datetime(normalized[timestamp_column], utc=True, errors="coerce") + cutoff = pd.to_datetime(as_of, utc=True, errors="coerce") + if pd.isna(cutoff): + raise ValueError("as_of must be an ISO-8601 timestamp") + normalized = normalized[ + normalized["ticker"].ne("") + & normalized["_snapshot_at"].notna() + & normalized["_snapshot_at"].le(cutoff) + ].sort_values("_snapshot_at") + normalized = normalized.drop_duplicates("ticker", keep="last") + return {str(row["ticker"]): row for _, row in normalized.iterrows()} + + +def _grouped_rows(frame: pd.DataFrame | None) -> dict[str, tuple[pd.Series, ...]]: + if frame is None or frame.empty or "ticker" not in frame.columns: + return {} + normalized = frame.copy() + normalized.columns = [str(column).strip().lower() for column in normalized.columns] + normalized["ticker"] = normalized["ticker"].map(lambda value: _text(value).upper()) + return { + ticker: tuple(row for _, row in group.iterrows()) + for ticker, group in normalized[normalized["ticker"].ne("")].groupby("ticker", sort=True) + } + + +def derive_cohort_evidence( + tickers: tuple[str, ...], + *, + fundamentals: pd.DataFrame | None = None, + prices: pd.DataFrame | None = None, + price_lineage_missing_fields: tuple[str, ...] = (), + readiness: pd.DataFrame | None = None, + universe: pd.DataFrame | None = None, + consensus: pd.DataFrame | None = None, + earnings: pd.DataFrame | None = None, + peers: pd.DataFrame | None = None, + peer_candidates: pd.DataFrame | None = None, + quarterly_actuals: Iterable[QuarterlyActual] | None = None, + as_of: str | None = None, + commercial_mode: bool = False, + rights_registry: Mapping[str, SourceRights] | None = None, +) -> dict[str, dict[str, object]]: + """Derive display states only from saved rows that retain provenance.""" + + fundamentals_rows = _row_map(fundamentals) + price_rows = _grouped_rows(prices) + readiness_rows = _row_map(readiness) + universe_rows = _row_map(universe) + consensus_rows = _latest_consensus_by_cutoff(consensus, as_of=as_of) + earnings_rows = _row_map(earnings) + peer_rows = _grouped_rows(peers) + candidate_rows = _grouped_rows(peer_candidates) + actual_rows_by_ticker: dict[str, tuple[QuarterlyActual, ...]] = {} + if quarterly_actuals is not None: + grouped_actuals: dict[str, list[QuarterlyActual]] = {} + for actual in quarterly_actuals: + grouped_actuals.setdefault(actual.ticker, []).append(actual) + actual_rows_by_ticker = { + ticker: tuple(rows) for ticker, rows in grouped_actuals.items() + } + registry = ( + rights_registry + if rights_registry is not None + else load_source_rights_registry() + if commercial_mode + else None + ) + result: dict[str, dict[str, object]] = {} + for raw_ticker in tickers: + ticker = _text(raw_ticker).upper() + fundamental = fundamentals_rows.get(ticker) + ready = readiness_rows.get(ticker) + universe_row = universe_rows.get(ticker) + consensus_row = consensus_rows.get(ticker) + earnings_row = earnings_rows.get(ticker) + saved_price_ready = bool(ready is not None and _truthy(ready.get("price_ready"))) + price_review = _price_history_review( + price_rows.get(ticker, ()), + technical_available_hint=saved_price_ready, + lineage_columns_missing=price_lineage_missing_fields, + commercial_mode=commercial_mode, + rights_registry=registry, + ) + margin_review = _alternative_field_review( + fundamental, + ( + ("operating_margin", ("operating_margin",)), + ("fcf_margin", ("fcf_margin",)), + ("profit_margin", ("profit_margin",)), + ), + commercial_mode=commercial_mode, + rights_registry=registry, + ) + fcf_review = _alternative_field_review( + fundamental, + (("free_cash_flow", ("free_cash_flow", "fcf")),), + commercial_mode=commercial_mode, + rights_registry=registry, + ) + cash_review = _commercial_field_review( + fundamental, + technical_available=_has_value(fundamental, "cash"), + required_fields=("cash",), + commercial_mode=commercial_mode, + rights_registry=registry, + ) + debt_review = _commercial_field_review( + fundamental, + technical_available=_has_value(fundamental, "debt"), + required_fields=("debt",), + commercial_mode=commercial_mode, + rights_registry=registry, + ) + shares_review = _commercial_field_review( + fundamental, + technical_available=_has_value(fundamental, "shares_outstanding"), + required_fields=("shares_outstanding",), + commercial_mode=commercial_mode, + rights_registry=registry, + ) + filing_review = _commercial_field_review( + fundamental, + technical_available=_has_value(fundamental, "sec_filed_date", "filed_date"), + required_fields=("filing_dates",), + commercial_mode=commercial_mode, + rights_registry=registry, + ) + + margin_ready = margin_review.usable + fcf_ready = fcf_review.usable + cash_ready = cash_review.usable + debt_ready = debt_review.usable + shares_ready = shares_review.usable + filing_ready = filing_review.usable + saved_peer_ready = bool(ready is not None and _truthy(ready.get("peer_ready"))) + trusted_rows = peer_rows.get(ticker, ()) + trusted_reviews = tuple( + _commercial_field_review( + row, + technical_available=True, + required_fields=("trusted_peers",), + commercial_mode=commercial_mode, + rights_registry=registry, + ) + for row in trusted_rows + ) + trusted_source_ready = bool(trusted_reviews) and all( + review.usable for review in trusted_reviews + ) + peers_ready = saved_peer_ready and (trusted_source_ready if commercial_mode else True) + candidate_count = len(candidate_rows.get(ticker, ())) + consensus_metric_fields = _populated_fields( + consensus_row, + ( + ("revenue_consensus", ("revenue_consensus", "revenue_estimate")), + ("eps_consensus", ("eps_consensus", "eps_estimate")), + ), + ) + consensus_base_ready = bool( + consensus_row is not None + and _has_value(consensus_row, "fiscal_period") + and _has_value(consensus_row, "snapshot_at", "available_at", "retrieved_at", "published_at") + and consensus_metric_fields + ) + consensus_review = _commercial_field_review( + consensus_row, + technical_available=consensus_base_ready, + required_fields=consensus_metric_fields, + commercial_mode=commercial_mode, + rights_registry=registry, + ) + consensus_ready = consensus_review.usable + earnings_technical = bool( + earnings_row is not None + and _has_value( + earnings_row, + "earnings_date", + "expected_report_date", + "event_date", + "next_earnings_date", + "last_earnings_date", + "report_date", + ) + ) + earnings_review = _commercial_field_review( + earnings_row, + technical_available=earnings_technical, + required_fields=("earnings_dates",), + commercial_mode=commercial_mode, + rights_registry=registry, + ) + earnings_ready = earnings_review.usable + ticker_evidence: dict[str, object] = { + "asset_type": _text(universe_row.get("asset_type")) if universe_row is not None else "company", + "price_history_state": "usable_now" if price_review.usable else "blocked", + "price_history_evidence": _commercial_blocked_evidence( + price_review, + available="Saved adjusted daily price history has complete commercial evidence.", + missing="No technically usable adjusted daily price rows are available.", + ), + "margin_state": "usable_now" if margin_ready else "blocked", + "margin_evidence": _commercial_blocked_evidence( + margin_review, + available="Saved source-backed margin input is available.", + missing="No source-backed margin input is available.", + ), + "free_cash_flow_state": "usable_now" if fcf_ready else "blocked", + "free_cash_flow_evidence": _commercial_blocked_evidence( + fcf_review, + available="Saved source-backed free cash flow is available.", + missing="No source-backed free cash flow is available.", + ), + "cash_debt_state": "usable_now" if cash_ready and debt_ready else "partial" if cash_ready or debt_ready else "blocked", + "cash_debt_evidence": ( + "Saved source-backed cash and debt are available." + if cash_ready and debt_ready + else "Cash and debt evidence is incomplete; " + + "; ".join( + ( + f"cash: {_commercial_blocked_evidence(cash_review, available='available', missing='missing')}", + f"debt: {_commercial_blocked_evidence(debt_review, available='available', missing='missing')}", + ) + ) + ), + "shares_state": "usable_now" if shares_ready else "blocked", + "shares_evidence": _commercial_blocked_evidence( + shares_review, + available="Saved source-backed shares outstanding are available.", + missing="No source-backed shares-outstanding input is available.", + ), + "trusted_peers_state": "usable_now" if peers_ready else "candidate_context_only" if candidate_count else "blocked", + "trusted_peers_evidence": ( + "Saved readiness and source rows confirm trusted peer inputs." + if peers_ready + else _commercial_blocked_evidence( + next(review for review in trusted_reviews if not review.usable), + available="Saved trusted peer evidence is available.", + missing="Trusted peer inputs are unavailable.", + ) + if saved_peer_ready and trusted_reviews and not trusted_source_ready + else f"{candidate_count} candidate peer relationship(s) are visible but not trusted." + if candidate_count + else "Trusted peer inputs are unavailable." + ), + "filing_dates_state": "usable_now" if filing_ready else "blocked", + "filing_dates_evidence": _commercial_blocked_evidence( + filing_review, + available="A source-backed filing date is available.", + missing="No source-backed filing date is available.", + ), + "earnings_dates_state": "usable_now" if earnings_ready else "blocked", + "earnings_dates_evidence": _commercial_blocked_evidence( + earnings_review, + available="A source-backed earnings date is available.", + missing="No permitted source-backed earnings date is available.", + ), + "point_in_time_consensus_state": "usable_now" if consensus_ready else "blocked", + "point_in_time_consensus_evidence": _commercial_blocked_evidence( + consensus_review, + available="An exact-period point-in-time consensus snapshot is available.", + missing=( + "No populated exact-period point-in-time consensus value is available." + if consensus_row is not None and not consensus_metric_fields + else "No exact-period point-in-time consensus snapshot is available." + ), + ), + } + if quarterly_actuals is not None: + ticker_actuals = actual_rows_by_ticker.get(ticker, ()) + for metric in ("revenue", "eps"): + review = _quarterly_metric_review( + ticker_actuals, + metric=metric, + commercial_mode=commercial_mode, + rights_registry=registry, + ) + ticker_evidence[f"quarterly_{metric}_commercial_state"] = ( + "usable_now" if review.usable else "blocked" + ) + ticker_evidence[f"quarterly_{metric}_commercial_evidence"] = ( + _commercial_blocked_evidence( + review, + available=( + f"Every populated quarterly {metric} row has complete permitted source evidence." + ), + missing=f"No populated quarterly {metric} row is available.", + ) + ) + result[ticker] = ticker_evidence + return result + + +def _validated_state(value: object, *, default: str = "blocked") -> str: + state = _text(value).lower() or default + if state not in ALLOWED_COVERAGE_STATES: + raise ValueError(f"unsupported coverage state: {state}") + return state + + +def _quarterly_state( + packet: QuarterlyTrendPacket | None, + metric: str, + evidence: Mapping[str, object], +) -> tuple[str, str, str]: + commercial_state_key = f"quarterly_{metric}_commercial_state" + if commercial_state_key in evidence: + commercial_state = _validated_state(evidence.get(commercial_state_key)) + if commercial_state != "usable_now": + return ( + "blocked", + _text(evidence.get(f"quarterly_{metric}_commercial_evidence")) + or f"Quarterly {metric} commercial evidence is unavailable.", + "Technical quarterly observations cannot override exact-source rights or field scope.", + ) + if packet is None: + return "blocked", "No canonical quarterly actuals are available.", "Source-backed quarterly rows are required." + trend = packet.revenue if metric == "revenue" else packet.eps + state = {"ready": "usable_now", "partial": "partial", "blocked": "blocked"}.get(trend.status, "blocked") + evidence = ( + f"{trend.latest_fiscal_period}: explicit quarterly {metric} from {trend.latest_source_ref}." + if trend.latest_fiscal_period and trend.latest_source_ref + else f"Quarterly {metric} evidence is unavailable." + ) + boundary_parts = list(trend.missing_comparisons) + if trend.withheld_reason: + boundary_parts.append(trend.withheld_reason) + boundary = "; ".join(boundary_parts) or "Comparable sequential and prior-year periods are source-backed." + return state, evidence, boundary + + +def _explicit_lane( + evidence: Mapping[str, object], + state_key: str, + *, + missing: str, + boundary: str, +) -> tuple[str, str, str]: + state = _validated_state(evidence.get(state_key)) + evidence_text = _text(evidence.get(state_key.replace("_state", "_evidence"))) or missing + if state == "candidate_context_only": + boundary = f"{boundary} Candidate context is not trusted source proof." + return state, evidence_text, boundary + + +def build_focused_cohort_coverage( + cohort: FocusedCohort, + ticker_readiness: pd.DataFrame | None, + *, + quarterly_packets: Mapping[str, QuarterlyTrendPacket] | None = None, + evidence_by_ticker: Mapping[str, Mapping[str, object]] | None = None, +) -> FocusedCohortCoverage: + """Compose saved evidence into lane states without inferring missing values.""" + + if len(cohort.members) > 50: + raise ValueError("commercial beta cohort cannot exceed 50 companies") + readiness = _normalized_readiness(ticker_readiness) + packets = {str(key).strip().upper(): value for key, value in (quarterly_packets or {}).items()} + evidence_map = {str(key).strip().upper(): value for key, value in (evidence_by_ticker or {}).items()} + rows: list[FocusedCohortCoverageRow] = [] + + for member in cohort.members: + ticker = member.ticker + saved = readiness.get(ticker) + evidence = evidence_map.get(ticker, {}) + asset_type = _text(evidence.get("asset_type")).lower() + if asset_type and asset_type not in {"company", "adr"}: + for lane in COHORT_COVERAGE_LANES: + rows.append( + FocusedCohortCoverageRow( + ticker, + member.company_name, + lane, + "excluded", + f"{asset_type} is outside operating-company beta analysis.", + "Excluded means not applicable; no company valuation or forecast is produced.", + ) + ) + continue + + saved_price_ready = bool(saved is not None and _truthy(saved.get("price_ready"))) + reviewed_price_state = _text(evidence.get("price_history_state")) + price_ready = saved_price_ready and ( + reviewed_price_state == "usable_now" if reviewed_price_state else True + ) + price_state = "usable_now" if price_ready else "blocked" + rows.append( + FocusedCohortCoverageRow( + ticker, + member.company_name, + "adjusted_daily_price_history", + price_state, + ( + _text(evidence.get("price_history_evidence")) + if reviewed_price_state + else "Saved adjusted daily price history is readiness-backed." + ) + if price_ready + else _text(evidence.get("price_history_evidence")) + or "Adjusted daily price history is unavailable.", + "Price history supports trend context only; it does not create fundamentals or a recommendation.", + ) + ) + + for lane, metric in (("quarterly_revenue", "revenue"), ("quarterly_eps", "eps")): + state, evidence_text, boundary = _quarterly_state( + packets.get(ticker), metric, evidence + ) + rows.append(FocusedCohortCoverageRow(ticker, member.company_name, lane, state, evidence_text, boundary)) + + explicit_specs = ( + ("margins", "margin_state", "No versioned quarterly margin evidence is available.", "Margins require an explicit compatible quarterly source contract."), + ("free_cash_flow", "free_cash_flow_state", "No versioned quarterly free-cash-flow evidence is available.", "Free cash flow is not derived from incomplete cash-flow rows."), + ("cash_and_debt", "cash_debt_state", "Cash and debt proof is unavailable.", "Both values need source provenance and compatible effective dates."), + ("shares_outstanding", "shares_state", "Shares-outstanding proof is unavailable.", "Shares are never inferred from price or market capitalization."), + ("trusted_peers", "trusted_peers_state", "Trusted peer proof is unavailable.", "Candidate peers cannot unlock trusted peer analysis."), + ("filing_dates", "filing_dates_state", "Source-backed filing dates are unavailable.", "Filing metadata is context and cannot supply missing financial facts."), + ("earnings_dates", "earnings_dates_state", "Source-backed earnings dates are unavailable.", "Earnings dates require a permitted source and publication timestamp."), + ("point_in_time_consensus", "point_in_time_consensus_state", "Point-in-time consensus is unavailable.", "Consensus must match the fiscal period and be available by the review cutoff."), + ) + for lane, state_key, missing, boundary in explicit_specs: + state, evidence_text, lane_boundary = _explicit_lane( + evidence, + state_key, + missing=missing, + boundary=boundary, + ) + rows.append(FocusedCohortCoverageRow(ticker, member.company_name, lane, state, evidence_text, lane_boundary)) + + states = {row.state for row in rows} + status = "unavailable" if not rows else "excluded" if states == {"excluded"} else "ready" if states <= {"usable_now", "excluded"} else "partial" + return FocusedCohortCoverage( + status=status, + company_count=len(cohort.members), + rows=tuple(rows), + message=( + "Focused cohort lane evidence is fully usable for the supported scope." + if status == "ready" + else "Focused cohort coverage is mixed; blocked and context-only lanes remain explicit." + if rows + else "Focused cohort coverage is unavailable." + ), + ) + + +def focused_cohort_coverage_frame(coverage: FocusedCohortCoverage) -> pd.DataFrame: + return pd.DataFrame( + [ + { + "Ticker": row.ticker, + "Company": row.company_name, + "Lane": row.lane.replace("_", " "), + "State": row.state.replace("_", " "), + "Evidence": row.evidence, + "Boundary": row.boundary, + } + for row in coverage.rows + ], + columns=["Ticker", "Company", "Lane", "State", "Evidence", "Boundary"], + ) diff --git a/src/focused_research_cohort.py b/src/focused_research_cohort.py new file mode 100644 index 00000000..ecddf592 --- /dev/null +++ b/src/focused_research_cohort.py @@ -0,0 +1,228 @@ +"""Deterministic, read-only focused cohort selection for personal research.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Mapping + +import pandas as pd + + +ELIGIBLE_ASSET_TYPES = {"company", "adr"} +LANE_COLUMNS = ( + ("price", "price_ready"), + ("momentum", "momentum_ready"), + ("fundamentals", "fundamentals_ready"), + ("dcf", "dcf_ready"), + ("peers", "peer_ready"), +) + + +@dataclass(frozen=True) +class FocusedCohortMember: + ticker: str + company_name: str + sector: str + industry: str + cohort_rationale: str + usable_lanes: tuple[str, ...] + blocked_lanes: tuple[str, ...] + freshness_state: str + last_review_date: str + next_review_reason: str + + +@dataclass(frozen=True) +class FocusedCohort: + status: str + requested_size: int + minimum_size: int + eligible_count: int + members: tuple[FocusedCohortMember, ...] + message: str + + +def _text(value: object) -> str: + if value is None: + return "" + try: + if pd.isna(value): + return "" + except (TypeError, ValueError): + pass + return str(value).strip() + + +def _truthy(value: object) -> bool: + return _text(value).lower() in {"true", "1", "yes", "y"} + + +def _explicit_false(value: object) -> bool: + return _text(value).lower() in {"false", "0", "no", "n"} + + +def _ticker(value: object) -> str: + return _text(value).upper() + + +def _normalized_frame(frame: pd.DataFrame | None) -> pd.DataFrame: + if frame is None or frame.empty or "ticker" not in frame.columns: + return pd.DataFrame() + result = frame.copy() + result.columns = [str(column).strip().lower() for column in result.columns] + result["ticker"] = result["ticker"].map(_ticker) + result = result[result["ticker"].ne("")].copy() + return result + + +def _blocked_lanes(row: pd.Series) -> tuple[str, ...]: + explicit = _text(row.get("blocked_features")) + if explicit: + values = [part.strip().lower() for part in explicit.split(",") if part.strip()] + values = ["peers" if value == "peer" else value for value in values] + return tuple(dict.fromkeys(values)) + return tuple(lane for lane, column in LANE_COLUMNS if not _truthy(row.get(column))) + + +def _usable_lanes(row: pd.Series) -> tuple[str, ...]: + return tuple(lane for lane, column in LANE_COLUMNS if _truthy(row.get(column))) + + +def _rationale(row: pd.Series) -> str: + if _truthy(row.get("in_active_universe")) and _truthy(row.get("dcf_ready")): + return "Active research company with source-backed valuation inputs." + if _truthy(row.get("dcf_ready")) and _truthy(row.get("peer_ready")): + return "Company with source-backed valuation and trusted peer context." + if _truthy(row.get("dcf_ready")): + return "Company with source-backed valuation inputs." + if _truthy(row.get("fundamentals_ready")): + return "Company with source-backed fundamentals and price evidence." + return "Operating company with reviewable price evidence; deeper lanes remain gated." + + +def _priority(row: pd.Series) -> tuple[int, int, int, int, int, str]: + return ( + 0 if _truthy(row.get("in_active_universe")) else 1, + 0 if _truthy(row.get("dcf_ready")) else 1, + 0 if _truthy(row.get("fundamentals_ready")) else 1, + 0 if _truthy(row.get("peer_ready")) else 1, + 0 if _truthy(row.get("momentum_ready")) else 1, + _ticker(row.get("ticker")), + ) + + +def build_focused_cohort( + ticker_readiness: pd.DataFrame | None, + universe_master: pd.DataFrame | None, + *, + target_size: int = 25, + minimum_size: int = 25, + profile_freshness: str = "stale_or_unknown", + last_review_dates: Mapping[str, str] | None = None, +) -> FocusedCohort: + """Select a stable company cohort from existing readiness evidence only.""" + + if target_size <= 0 or target_size > 50: + raise ValueError("target_size must be between 1 and 50") + if minimum_size <= 0 or minimum_size > target_size: + raise ValueError("minimum_size must be between 1 and target_size") + + readiness = _normalized_frame(ticker_readiness) + universe = _normalized_frame(universe_master) + if readiness.empty or universe.empty: + return FocusedCohort( + status="unavailable", + requested_size=target_size, + minimum_size=minimum_size, + eligible_count=0, + members=(), + message="Focused cohort inputs are unavailable.", + ) + + readiness = readiness.copy() + readiness["_priority"] = readiness.apply(_priority, axis=1) + readiness["_updated"] = readiness.get("updated_at", pd.Series("", index=readiness.index)).map(_text) + readiness = readiness.sort_values(["_priority", "_updated"], ascending=[True, False]).drop_duplicates("ticker", keep="first") + universe = universe.drop_duplicates("ticker", keep="last") + universe_columns = [column for column in ("ticker", "asset_type", "is_active_listing", "sector", "industry", "name") if column in universe.columns] + merged = readiness.merge(universe[universe_columns], on="ticker", how="inner", suffixes=("", "_universe")) + eligible = merged[ + merged["asset_type"].map(lambda value: _text(value).lower() in ELIGIBLE_ASSET_TYPES) + & ~merged["is_active_listing"].map(_explicit_false) + & merged["price_ready"].map(_truthy) + ].copy() + eligible["_priority"] = eligible.apply(_priority, axis=1) + eligible = eligible.sort_values("_priority").reset_index(drop=True) + + reviews = {_ticker(key): _text(value) for key, value in (last_review_dates or {}).items()} + members: list[FocusedCohortMember] = [] + for _, row in eligible.head(target_size).iterrows(): + ticker = _ticker(row.get("ticker")) + blocked = _blocked_lanes(row) + company_name = _text(row.get("name")) or _text(row.get("name_universe")) or ticker + members.append( + FocusedCohortMember( + ticker=ticker, + company_name=company_name, + sector=_text(row.get("sector")) or _text(row.get("sector_universe")), + industry=_text(row.get("industry")) or _text(row.get("industry_universe")), + cohort_rationale=_rationale(row), + usable_lanes=_usable_lanes(row), + blocked_lanes=blocked, + freshness_state=_text(profile_freshness) or "stale_or_unknown", + last_review_date=reviews.get(ticker, ""), + next_review_reason=( + f"Review source evidence for {blocked[0]}." + if blocked + else "Review the latest comparable company evidence." + ), + ) + ) + + status = "ready" if len(members) >= minimum_size else "awaiting_reviewed_source" + message = ( + f"Focused cohort contains {len(members)} of {target_size} requested operating companies." + if status == "ready" + else f"Only {len(members)} eligible operating companies are currently reviewable; do not pad the cohort." + ) + return FocusedCohort( + status=status, + requested_size=target_size, + minimum_size=minimum_size, + eligible_count=len(eligible), + members=tuple(members), + message=message, + ) + + +def focused_cohort_frame(cohort: FocusedCohort) -> pd.DataFrame: + rows = [ + { + "Ticker": member.ticker, + "Company": member.company_name, + "Sector": member.sector, + "Industry": member.industry, + "Usable now": ", ".join(member.usable_lanes) or "none", + "Still gated": ", ".join(member.blocked_lanes) or "none", + "Freshness": member.freshness_state.replace("_", " "), + "Last review": member.last_review_date or "not recorded", + "Next review reason": member.next_review_reason, + "Cohort rationale": member.cohort_rationale, + } + for member in cohort.members + ] + return pd.DataFrame( + rows, + columns=[ + "Ticker", + "Company", + "Sector", + "Industry", + "Usable now", + "Still gated", + "Freshness", + "Last review", + "Next review reason", + "Cohort rationale", + ], + ) diff --git a/src/forward_view.py b/src/forward_view.py new file mode 100644 index 00000000..ef4fe297 --- /dev/null +++ b/src/forward_view.py @@ -0,0 +1,418 @@ +"""Deterministic composition of evidence-bound forward research context.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from types import MappingProxyType +from typing import Any, Mapping + +from src.quarterly_business_trend import QuarterlyTrendPacket + + +@dataclass(frozen=True) +class ForwardViewSection: + name: str + state: str + answer: str + details: tuple[Mapping[str, object], ...] + boundary: str + + +@dataclass(frozen=True) +class ForwardViewPacket: + ticker: str + status: str + source_cutoff: str + freshness_state: str + model_version: str + historical_trend: ForwardViewSection + valuation_scenarios: ForwardViewSection + peer_context: ForwardViewSection + thesis_context: ForwardViewSection + earnings_outlook: ForwardViewSection + withheld_fields: tuple[str, ...] + next_research_task: str + boundary: str + + +def _text(value: object) -> str: + text = str(value or "").strip() + return "" if text.lower() in {"nan", "none", "null", ""} else text + + +def _provenance_complete(rows: object) -> bool: + return bool(rows) and all( + isinstance(row, Mapping) + and ( + (_text(row.get("source")) and _text(row.get("source_ref"))) + or ( + _text(row.get("provider")) + and _text(row.get("retrieved_at")) + and "official" in row + ) + ) + for row in rows + ) + + +def _freeze(value: object) -> object: + if isinstance(value, Mapping): + return MappingProxyType({str(key): _freeze(item) for key, item in value.items()}) + if isinstance(value, (list, tuple)): + return tuple(_freeze(item) for item in value) + return value + + +def _display_value(value: object) -> object: + if isinstance(value, Mapping): + return {str(key): _display_value(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [_display_value(item) for item in value] + return value + + +def _frozen_row(**values: object) -> Mapping[str, object]: + return _freeze(values) # type: ignore[return-value] + + +def _normalized_provenance(rows: object) -> tuple[Mapping[str, object], ...]: + if not rows: + return () + return tuple( + _frozen_row( + provider=_text(row.get("provider") or row.get("source")), + source_ref=_text(row.get("source_ref")), + freshness=_text(row.get("freshness")), + retrieved_at=_text(row.get("retrieved_at") or row.get("as_of_date")), + official=row.get("official"), + notes=tuple(row.get("notes") or ()), + ) + for row in rows + if isinstance(row, Mapping) + ) + + +def _blocked(name: str, answer: str, boundary: str) -> ForwardViewSection: + return ForwardViewSection(name, "blocked", answer, (), boundary) + + +def _historical_section(packet: QuarterlyTrendPacket) -> ForwardViewSection: + if packet.status == "blocked": + return _blocked( + "Historical Trend", + packet.message, + "Explicit, compatible quarterly Revenue and EPS observations are required.", + ) + details = tuple( + _frozen_row( + metric=label, + period=trend.latest_fiscal_period, + value=trend.latest_value, + sequential_change_pct=trend.sequential_change_pct, + year_over_year_change_pct=trend.year_over_year_change_pct, + source_ref=trend.latest_source_ref, + ) + for label, trend in (("Revenue", packet.revenue), ("EPS", packet.eps)) + if trend.latest_value is not None and trend.latest_source_ref + ) + state = "usable_now" if packet.status == "ready" else "partial" + return ForwardViewSection( + "Historical Trend", + state, + packet.message, + details, + "Trend is descriptive source evidence, not a future estimate.", + ) + + +def _valuation_section(report: Mapping[str, object], *, stale: bool) -> ForwardViewSection: + readiness = { + **dict(report.get("readiness") or {}), + **dict(report.get("valuation_readiness") or {}), + } + valuation = report.get("valuation_snapshot") if isinstance(report.get("valuation_snapshot"), Mapping) else {} + valuation = valuation or {} + source_rows = valuation.get("source_metadata") or [] + scenarios = valuation.get("scenarios") or [] + dcf_ready = bool(readiness.get("dcf_ready")) + if not dcf_ready or not _provenance_complete(source_rows): + return _blocked( + "Valuation Scenarios", + "Bull, base, and bear valuation scenarios are withheld.", + "DCF readiness and complete source provenance are required before scenario values appear.", + ) + + provenance = _normalized_provenance(source_rows) + details: list[Mapping[str, object]] = [] + for scenario in scenarios: + if not isinstance(scenario, Mapping): + continue + name = _text(scenario.get("name")).lower() + result = scenario.get("dcf_result") if isinstance(scenario.get("dcf_result"), Mapping) else {} + result = result or {} + assumptions = scenario.get("assumptions") if isinstance(scenario.get("assumptions"), Mapping) else {} + if name not in {"bear", "base", "bull"} or _text(result.get("status")).lower() != "calculated": + continue + value = result.get("fair_value_per_share") + if value is None: + continue + details.append( + _frozen_row( + name=name, + per_share_value=value, + assumptions=dict(assumptions or {}), + provenance=provenance, + ) + ) + order = {"bear": 0, "base": 1, "bull": 2} + details.sort(key=lambda row: order[str(row["name"])]) + if [row["name"] for row in details] != ["bear", "base", "bull"]: + return _blocked( + "Valuation Scenarios", + "A complete bull, base, and bear scenario set is unavailable.", + "All three bounded scenarios must calculate from the same source-backed input snapshot.", + ) + return ForwardViewSection( + "Valuation Scenarios", + "partial" if stale else "usable_now", + "Three bounded valuation scenarios are available from the saved DCF input set.", + tuple(details), + ( + "Saved inputs are stale; review their effective dates before relying on scenario sensitivity." + if stale + else "Scenario values test assumptions; they are not price predictions or recommendations." + ), + ) + + +def _peer_section(peer_map: object | None) -> ForwardViewSection: + if peer_map is None: + return _blocked( + "Trusted Peer Context", + "Trusted peer read-through is unavailable.", + "Source-backed peer relationships and comparable peer results are required.", + ) + trusted = int(getattr(peer_map, "trusted_count", 0) or 0) + candidate = int(getattr(peer_map, "candidate_count", 0) or 0) + reviewable = int(getattr(peer_map, "reviewable_count", 0) or 0) + if not trusted or not reviewable: + state = "candidate_context_only" if candidate else "blocked" + return ForwardViewSection( + "Trusted Peer Context", + state, + f"{candidate} candidate peer relationship(s) are visible; no trusted read-through is reviewable.", + (), + "Candidate peer context is not trusted evidence and cannot change scenarios or forecasts.", + ) + return ForwardViewSection( + "Trusted Peer Context", + "usable_now", + f"{reviewable} source-backed peer result(s) are available for contextual review.", + (_frozen_row(trusted_count=trusted, reviewable_count=reviewable, candidate_count=candidate),), + _text(getattr(peer_map, "boundary", "")) or "Peer evidence remains context only.", + ) + + +def _journal_details(entries: object) -> tuple[Mapping[str, object], ...]: + return tuple( + _frozen_row( + summary=_text(getattr(entry, "summary", "")), + source=_text(getattr(entry, "source", "")), + source_ref=_text(getattr(entry, "source_ref", "")), + ) + for entry in tuple(entries or ()) + if _text(getattr(entry, "summary", "")) + and _text(getattr(entry, "source", "")) + and _text(getattr(entry, "source_ref", "")) + ) + + +def _thesis_section(journal_state: object | None) -> ForwardViewSection: + if journal_state is None: + return _blocked( + "Reviewer Thesis Context", + "No reviewed catalyst, risk, or invalidation evidence is available.", + "Generated narrative cannot substitute for reviewer-authored, source-backed journal entries.", + ) + details: list[Mapping[str, object]] = [] + for category, entries in ( + ("catalyst", getattr(journal_state, "catalysts", ())), + ("risk", getattr(journal_state, "risks", ())), + ("invalidation", getattr(journal_state, "invalidation_conditions", ())), + ): + details.extend(_frozen_row(category=category, **dict(row)) for row in _journal_details(entries)) + if not details: + return _blocked( + "Reviewer Thesis Context", + "No source-backed catalyst, risk, or invalidation entry is available.", + "The research journal remains reviewer-authored and append-only.", + ) + return ForwardViewSection( + "Reviewer Thesis Context", + "usable_now", + f"{len(details)} reviewed catalyst, risk, or invalidation item(s) are available.", + tuple(details), + "Journal evidence documents a hypothesis; it is not conviction, expected return, or an action instruction.", + ) + + +def _nowcast_section(packet: Mapping[str, object] | None) -> ForwardViewSection: + if not packet or not isinstance(packet.get("forecast"), Mapping): + return _blocked( + "Earnings Outlook", + "No real source-backed Earnings Outlook range is available.", + "Exact-period point-in-time consensus and compatible quarterly actuals are required.", + ) + readiness = packet.get("readiness") if isinstance(packet.get("readiness"), Mapping) else {} + forecast = packet.get("forecast") or {} + state = _text(readiness.get("state")).lower() + if state not in {"baseline_ready", "signal_context_ready", "backtest_ready", "calibrated"}: + return _blocked( + "Earnings Outlook", + "Earnings Outlook evidence does not pass the baseline gate.", + "No numerical range is shown until the source-backed baseline is ready.", + ) + details = tuple( + _frozen_row(metric=metric, low=forecast.get(low), high=forecast.get(high)) + for metric, low, high in ( + ("Revenue", "revenue_low", "revenue_high"), + ("EPS", "eps_low", "eps_high"), + ) + if forecast.get(low) is not None and forecast.get(high) is not None + ) + calibrated = state == "calibrated" and bool((packet.get("calibration") or {}).get("probability_available")) + return ForwardViewSection( + "Earnings Outlook", + "usable_now" if details else "partial", + "A deterministic source-backed Revenue/EPS range is available." if details else "Only part of the Earnings Outlook range is available.", + details, + ( + "Calibration evidence passed; any eligible probability remains governed by the separate Nowcast contract." + if calibrated + else "Numerical surprise probability withheld until calibration evidence passes." + ), + ) + + +def _apply_freshness_gate(section: ForwardViewSection, freshness: str) -> ForwardViewSection: + if freshness in {"current", "fresh"} or section.state == "blocked": + return section + state = "partial" if section.state == "usable_now" else section.state + return ForwardViewSection( + section.name, + state, + section.answer, + section.details, + f"{section.boundary} Saved evidence freshness is {freshness}; treat this section as review-due.", + ) + + +def build_forward_view( + report_payload: Mapping[str, object], + quarterly_trend: QuarterlyTrendPacket, + *, + journal_state: object | None = None, + peer_map: object | None = None, + nowcast_packet: Mapping[str, object] | None = None, + freshness_state: str = "unknown", +) -> ForwardViewPacket: + ticker = _text(report_payload.get("ticker")).upper() + source_cutoff = _text(report_payload.get("generated_at") or report_payload.get("as_of_timestamp")) + freshness = _text(freshness_state).lower() or "unknown" + raw_sections = ( + _historical_section(quarterly_trend), + _valuation_section(report_payload, stale=freshness not in {"current", "fresh"}), + _peer_section(peer_map), + _thesis_section(journal_state), + _nowcast_section(nowcast_packet), + ) + sections = tuple(_apply_freshness_gate(section, freshness) for section in raw_sections) + withheld = tuple( + key + for key, section in zip( + ("historical_trend", "valuation_scenarios", "trusted_peer_context", "thesis_context", "earnings_outlook"), + sections, + ) + if section.state in {"blocked", "partial", "candidate_context_only"} + ) + if sections[4].state == "blocked": + next_task = "Add exact-period point-in-time consensus before reviewing an Earnings Outlook range." + elif sections[0].state == "blocked": + next_task = "Review canonical quarterly Revenue and EPS evidence." + elif sections[1].state == "blocked": + next_task = "Review source-backed valuation inputs and scenario provenance." + elif sections[3].state == "blocked": + next_task = "Record one source-backed thesis risk or invalidation condition." + else: + next_task = "Review changed evidence and update one explicit research wait condition." + status = "ready" if not withheld else "partial" if any(section.state == "usable_now" for section in sections) else "blocked" + return ForwardViewPacket( + ticker=ticker, + status=status, + source_cutoff=source_cutoff, + freshness_state=freshness, + model_version=_text(report_payload.get("method_version")) or "forward-view-v1", + historical_trend=sections[0], + valuation_scenarios=sections[1], + peer_context=sections[2], + thesis_context=sections[3], + earnings_outlook=sections[4], + withheld_fields=withheld, + next_research_task=next_task, + boundary=( + f"Saved evidence freshness is {freshness}. Forward View composes existing evidence and scenario math only; " + "it does not predict post-earnings price direction or provide an investment recommendation." + ), + ) + + +def forward_view_cards(packet: ForwardViewPacket) -> list[dict[str, object]]: + sections = ( + packet.historical_trend, + packet.valuation_scenarios, + packet.peer_context, + packet.thesis_context, + packet.earnings_outlook, + ) + cards = [ + { + "kicker": section.name.upper(), + "title": section.state.replace("_", " ").title(), + "body": f"{section.answer} {section.boundary}", + "state": section.state, + "badges": [section.state.replace("_", " "), "evidence-bound"], + "command": "", + } + for section in sections + ] + cards.append( + { + "kicker": "FORWARD-VIEW LANE UNBLOCK", + "title": packet.next_research_task, + "body": packet.boundary, + "state": "review_now" if packet.status != "blocked" else "wait_for_evidence", + "badges": ["research-only", packet.freshness_state.replace("_", " ")], + "command": "", + } + ) + return cards + + +def forward_view_rows(packet: ForwardViewPacket) -> list[dict[str, object]]: + return [ + { + "Section": section.name, + "State": section.state.replace("_", " "), + "Answer": section.answer, + "Evidence": json.dumps(_display_value(section.details), sort_keys=True, default=str), + "Boundary": section.boundary, + } + for section in ( + packet.historical_trend, + packet.valuation_scenarios, + packet.peer_context, + packet.thesis_context, + packet.earnings_outlook, + ) + ] diff --git a/src/historical_valuation_regime.py b/src/historical_valuation_regime.py new file mode 100644 index 00000000..5259c3ff --- /dev/null +++ b/src/historical_valuation_regime.py @@ -0,0 +1,226 @@ +"""Point-in-time historical valuation regime without denominator backfilling.""" + +from __future__ import annotations + +import csv +import math +from dataclasses import dataclass +from datetime import date +from pathlib import Path +from statistics import median +from typing import Iterable, Mapping + +from src.commercial_source_rights import ( + SourceRights, + load_source_rights_registry, + review_commercial_field_scope, +) +from src.earnings_nowcast_contract import parse_utc_timestamp + + +@dataclass(frozen=True) +class ValuationObservation: + ticker: str + metric: str + numerator: float + denominator: float + numerator_as_of: str + denominator_period_end: str + denominator_available_at: str + definition_id: str + source: str + source_ref: str + retrieved_at: str + + +@dataclass(frozen=True) +class ValuationRegimePacket: + ticker: str + metric: str + state: str + definition_id: str + observation_count: int + rejected_count: int + segment_count: int + latest_multiple: float | None + minimum_multiple: float | None + median_multiple: float | None + maximum_multiple: float | None + percentile_rank: float | None + freshness_state: str + rejected_reasons: tuple[str, ...] + source_refs: tuple[str, ...] + commercial_blocker_count: int + commercial_blockers: tuple[str, ...] + boundary: str + + +def load_valuation_observations(path: Path | str) -> tuple[ValuationObservation, ...]: + source = Path(path) + if not source.is_file(): + return () + with source.open(newline="", encoding="utf-8") as handle: + rows = [] + for row in csv.DictReader(handle): + rows.append( + ValuationObservation( + ticker=str(row.get("ticker") or ""), + metric=str(row.get("metric") or ""), + numerator=float(row.get("numerator") or 0), + denominator=float(row.get("denominator") or 0), + numerator_as_of=str(row.get("numerator_as_of") or ""), + denominator_period_end=str(row.get("denominator_period_end") or ""), + denominator_available_at=str(row.get("denominator_available_at") or ""), + definition_id=str(row.get("definition_id") or ""), + source=str(row.get("source") or ""), + source_ref=str(row.get("source_ref") or ""), + retrieved_at=str(row.get("retrieved_at") or ""), + ) + ) + return tuple(rows) + + +def _validate_observation(row: ValuationObservation, *, as_of: str) -> str | None: + if not row.ticker.strip() or not row.metric.strip() or not row.definition_id.strip(): + return "ticker, metric, and definition_id are required" + if not row.source.strip() or not row.source_ref.strip(): + return "source and source_ref are required" + if not math.isfinite(row.numerator) or not math.isfinite(row.denominator): + return "numerator and denominator must be finite" + if row.denominator == 0: + return "denominator cannot be zero" + period_end = str(row.denominator_period_end or "").strip() + try: + if date.fromisoformat(period_end).isoformat() != period_end: + raise ValueError + except ValueError: + return "denominator_period_end must use YYYY-MM-DD" + try: + numerator_at = parse_utc_timestamp(row.numerator_as_of, label="numerator_as_of") + denominator_at = parse_utc_timestamp(row.denominator_available_at, label="denominator_available_at") + retrieved_at = parse_utc_timestamp(row.retrieved_at, label="retrieved_at") + cutoff = parse_utc_timestamp(as_of, label="valuation cutoff") + except ValueError as exc: + return str(exc) + if denominator_at > numerator_at: + return "denominator was not public at the price timestamp" + if numerator_at > retrieved_at: + return "numerator timestamp cannot be after retrieval" + if numerator_at > cutoff or denominator_at > cutoff or retrieved_at > cutoff: + return "observation contains post-cutoff evidence" + return None + + +def build_valuation_regime( + observations: Iterable[ValuationObservation], + *, + ticker: str, + metric: str, + as_of: str, + minimum_observations: int = 8, + stale_after_days: int = 120, + commercial_mode: bool = False, + rights_registry: Mapping[str, SourceRights] | None = None, +) -> ValuationRegimePacket: + symbol = str(ticker or "").strip().upper() + metric_name = str(metric or "").strip().lower() + accepted: list[ValuationObservation] = [] + rejected: list[str] = [] + for row in observations: + if row.ticker.strip().upper() != symbol or row.metric.strip().lower() != metric_name: + continue + reason = _validate_observation(row, as_of=as_of) + if reason: + rejected.append(reason) + else: + accepted.append(row) + segments: dict[str, list[ValuationObservation]] = {} + for row in accepted: + segments.setdefault(row.definition_id, []).append(row) + if not segments: + return ValuationRegimePacket( + symbol, metric_name, "insufficient_history", "", 0, len(rejected), 0, + None, None, None, None, None, "stale_or_unknown", tuple(rejected), (), + 0, (), + "Historical valuation is withheld until aligned point-in-time observations exist; current denominators are never backfilled over old prices.", + ) + latest_definition = max( + segments, + key=lambda definition: max(parse_utc_timestamp(row.numerator_as_of) for row in segments[definition]), + ) + active = sorted(segments[latest_definition], key=lambda row: parse_utc_timestamp(row.numerator_as_of)) + commercial_blockers: list[str] = [] + if commercial_mode: + registry = rights_registry if rights_registry is not None else load_source_rights_registry() + for row in active: + review = review_commercial_field_scope( + registry, row.source, ("valuation_history",) + ) + if not review.commercial_evidence_ready: + missing = ", ".join(review.missing_supported_fields) or "valuation_history" + commercial_blockers.append( + f"{row.source_ref}: exact source {row.source or '-'} is {review.rights_status}; " + f"registered scope missing: {missing}" + ) + if commercial_blockers: + return ValuationRegimePacket( + symbol, metric_name, "commercial_evidence_blocked", latest_definition, + 0, len(rejected), len(segments), None, None, None, None, None, + "stale_or_unknown", tuple(rejected), (), len(commercial_blockers), + tuple(commercial_blockers), + "Historical valuation is withheld until every used row has approved exact-source rights and registered valuation_history scope.", + ) + multiples = [row.numerator / row.denominator for row in active] + latest = multiples[-1] + percentile = round(sum(value <= latest for value in multiples) / len(multiples) * 100.0, 2) + latest_at = parse_utc_timestamp(active[-1].numerator_as_of) + age_days = (parse_utc_timestamp(as_of) - latest_at).total_seconds() / 86400 + state = "ready" if len(active) >= minimum_observations else "insufficient_history" + return ValuationRegimePacket( + ticker=symbol, + metric=metric_name, + state=state, + definition_id=latest_definition, + observation_count=len(active), + rejected_count=len(rejected), + segment_count=len(segments), + latest_multiple=round(latest, 6), + minimum_multiple=round(min(multiples), 6), + median_multiple=round(median(multiples), 6), + maximum_multiple=round(max(multiples), 6), + percentile_rank=percentile, + freshness_state="current" if age_days <= stale_after_days else "stale", + rejected_reasons=tuple(rejected), + source_refs=tuple(row.source_ref for row in active), + commercial_blocker_count=0, + commercial_blockers=(), + boundary="This is descriptive point-in-time valuation context, not a cheap/expensive label, forecast, recommendation, or action.", + ) + + +def valuation_regime_cards(packet: ValuationRegimePacket) -> list[dict[str, object]]: + if packet.state == "commercial_evidence_blocked": + title = "Historical valuation commercial evidence is blocked" + body = ( + f"{packet.commercial_blocker_count} observation(s) lack approved exact-source " + "rights or registered valuation-history scope. Technical rows cannot override that gate." + ) + elif packet.state != "ready": + title = "Historical valuation context is withheld" + body = ( + f"{packet.observation_count} compatible point-in-time observation(s) are available in the latest definition segment. " + "At least 8 are required; current denominators are never backfilled over historical prices." + ) + else: + title = f"Historical {packet.metric.replace('_', ' ')} regime is reviewable" + body = ( + f"Latest {packet.latest_multiple:.2f}; observed range {packet.minimum_multiple:.2f} to {packet.maximum_multiple:.2f}; " + f"latest observation percentile {packet.percentile_rank:.0f}. {packet.boundary}" + ) + return [{ + "kicker": "VALUATION HISTORY", + "title": title, + "body": body, + "badges": [packet.state.replace("_", " "), packet.freshness_state.replace("_", " "), "descriptive only"], + "command": "", + }] diff --git a/src/hosted_access_control.py b/src/hosted_access_control.py new file mode 100644 index 00000000..945fe575 --- /dev/null +++ b/src/hosted_access_control.py @@ -0,0 +1,233 @@ +"""Provider-neutral, deny-by-default workspace authorization policy.""" + +from __future__ import annotations + +import unicodedata +from dataclasses import dataclass +from enum import Enum + + +class WorkspaceRole(str, Enum): + VIEWER = "viewer" + EDITOR = "editor" + OWNER = "owner" + + +class WorkspaceAction(str, Enum): + READ = "read" + APPEND = "append" + UPDATE = "update" + DELETE = "delete" + EXPORT = "export" + MANAGE = "manage" + + +class WorkspaceResource(str, Enum): + RESEARCH_RECORD = "research_record" + SAVED_WORKSPACE_STATE = "saved_workspace_state" + WORKSPACE_MEMBERSHIP = "workspace_membership" + WORKSPACE_AUDIT = "workspace_audit" + + +@dataclass(frozen=True) +class PrincipalContext: + principal_id: object + authenticated: object + + +@dataclass(frozen=True) +class WorkspaceMembership: + principal_id: object + workspace_id: object + role: object + active: object + + +@dataclass(frozen=True) +class WorkspaceAccessRequest: + request_id: object + workspace_id: object + action: object + resource: object + + +@dataclass(frozen=True) +class AuditObligation: + event_type: str + outcome: str + reason_code: str + required_fields: tuple[str, ...] + + +@dataclass(frozen=True) +class WorkspaceAccessDecision: + allowed: bool + reason_code: str + audit: AuditObligation + + +_AUDIT_FIELDS = ( + "request_id", + "principal_id", + "workspace_id", + "resource", + "action", + "outcome", + "reason_code", + "occurred_at", +) + +_MISSING_FIELD = object() + + +def _valid_identifier(value: object) -> bool: + if type(value) is not str or not 1 <= len(value) <= 128: + return False + if value != value.strip(): + return False + return all( + unicodedata.category(character) not in {"Cc", "Cs", "Zl", "Zp"} + for character in value + ) + + +def _decision(*, allowed: bool, reason_code: str) -> WorkspaceAccessDecision: + outcome = "allowed" if allowed else "denied" + return WorkspaceAccessDecision( + allowed=allowed, + reason_code=reason_code, + audit=AuditObligation( + event_type="workspace_access_decision", + outcome=outcome, + reason_code=reason_code, + required_fields=_AUDIT_FIELDS, + ), + ) + + +def _structurally_valid( + principal: object, + membership: object, + request: object, +) -> bool: + if ( + type(principal) is not PrincipalContext + or type(membership) is not WorkspaceMembership + or type(request) is not WorkspaceAccessRequest + ): + return False + + principal_id = getattr(principal, "principal_id", _MISSING_FIELD) + authenticated = getattr(principal, "authenticated", _MISSING_FIELD) + membership_principal_id = getattr( + membership, + "principal_id", + _MISSING_FIELD, + ) + membership_workspace_id = getattr( + membership, + "workspace_id", + _MISSING_FIELD, + ) + role = getattr(membership, "role", _MISSING_FIELD) + active = getattr(membership, "active", _MISSING_FIELD) + request_id = getattr(request, "request_id", _MISSING_FIELD) + request_workspace_id = getattr(request, "workspace_id", _MISSING_FIELD) + action = getattr(request, "action", _MISSING_FIELD) + resource = getattr(request, "resource", _MISSING_FIELD) + + return ( + _valid_identifier(principal_id) + and type(authenticated) is bool + and _valid_identifier(membership_principal_id) + and _valid_identifier(membership_workspace_id) + and isinstance(role, WorkspaceRole) + and type(active) is bool + and _valid_identifier(request_id) + and _valid_identifier(request_workspace_id) + and isinstance(action, WorkspaceAction) + and isinstance(resource, WorkspaceResource) + ) + + +_ALLOWED_ACTIONS = { + WorkspaceResource.RESEARCH_RECORD: { + WorkspaceRole.VIEWER: frozenset({WorkspaceAction.READ}), + WorkspaceRole.EDITOR: frozenset( + {WorkspaceAction.READ, WorkspaceAction.APPEND} + ), + WorkspaceRole.OWNER: frozenset( + { + WorkspaceAction.READ, + WorkspaceAction.APPEND, + WorkspaceAction.EXPORT, + } + ), + }, + WorkspaceResource.SAVED_WORKSPACE_STATE: { + WorkspaceRole.VIEWER: frozenset({WorkspaceAction.READ}), + WorkspaceRole.EDITOR: frozenset( + { + WorkspaceAction.READ, + WorkspaceAction.APPEND, + WorkspaceAction.UPDATE, + } + ), + WorkspaceRole.OWNER: frozenset( + { + WorkspaceAction.READ, + WorkspaceAction.APPEND, + WorkspaceAction.UPDATE, + WorkspaceAction.DELETE, + WorkspaceAction.EXPORT, + } + ), + }, + WorkspaceResource.WORKSPACE_MEMBERSHIP: { + WorkspaceRole.VIEWER: frozenset(), + WorkspaceRole.EDITOR: frozenset(), + WorkspaceRole.OWNER: frozenset( + {WorkspaceAction.READ, WorkspaceAction.MANAGE} + ), + }, + WorkspaceResource.WORKSPACE_AUDIT: { + WorkspaceRole.VIEWER: frozenset(), + WorkspaceRole.EDITOR: frozenset(), + WorkspaceRole.OWNER: frozenset( + {WorkspaceAction.READ, WorkspaceAction.EXPORT} + ), + }, +} + + +def evaluate_workspace_access( + principal: object, + membership: object, + request: object, +) -> WorkspaceAccessDecision: + """Return one immutable policy decision without side effects.""" + if not _structurally_valid(principal, membership, request): + return _decision(allowed=False, reason_code="invalid_request") + if principal.authenticated is not True: + return _decision( + allowed=False, + reason_code="authentication_required", + ) + if membership.principal_id != principal.principal_id: + return _decision(allowed=False, reason_code="principal_mismatch") + if membership.active is not True: + return _decision(allowed=False, reason_code="membership_inactive") + if request.workspace_id != membership.workspace_id: + return _decision(allowed=False, reason_code="workspace_mismatch") + if ( + request.resource is WorkspaceResource.RESEARCH_RECORD + and request.action in {WorkspaceAction.UPDATE, WorkspaceAction.DELETE} + ): + return _decision( + allowed=False, + reason_code="append_only_mutation_denied", + ) + allowed_actions = _ALLOWED_ACTIONS[request.resource][membership.role] + if request.action not in allowed_actions: + return _decision(allowed=False, reason_code="role_action_denied") + return _decision(allowed=True, reason_code="allowed") diff --git a/src/peer_evidence_quality.py b/src/peer_evidence_quality.py new file mode 100644 index 00000000..fb39cf21 --- /dev/null +++ b/src/peer_evidence_quality.py @@ -0,0 +1,111 @@ +"""Fail-closed evidence quality for trusted peer relationships.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Mapping + + +ALLOWED_PEER_ROLES = frozenset( + { + "core_peer", + "secondary_peer", + "aspirational_peer", + "negative_peer", + "excluded_close_peer", + "not_clean_comp", + } +) +VALUATION_ANCHOR_ROLES = frozenset({"core_peer", "secondary_peer"}) +_YES_VALUES = frozenset({"1", "true", "yes", "eligible"}) +_NO_VALUES = frozenset({"0", "false", "no", "withheld", "context_only"}) + + +@dataclass(frozen=True) +class PeerEvidenceQuality: + peer_role: str + relationship_state: str + role_state: str + comparability_state: str + valuation_anchor_state: str + blockers: tuple[str, ...] + + +def _text(value: object) -> str: + text = str(value or "").strip() + return "" if text.lower() in {"nan", "none", "null", ""} else text + + +def assess_peer_evidence(row: Mapping[str, object]) -> PeerEvidenceQuality: + """Classify one relationship without inferring missing review evidence.""" + + role = _text(row.get("peer_role")).lower() + source = _text(row.get("source")) + as_of_date = _text(row.get("as_of_date")) + rationale = _text(row.get("relationship_rationale")) + comparability_basis = _text(row.get("comparability_basis")) + anchor_decision = _text(row.get("valuation_anchor_eligible")).lower() + candidate_state = _text(row.get("candidate_state")).lower() + + if candidate_state: + return PeerEvidenceQuality( + peer_role=role or "unreviewed", + relationship_state="candidate_context_only", + role_state="reviewed_role" if role in ALLOWED_PEER_ROLES else "unreviewed_role", + comparability_state="context_only", + valuation_anchor_state="withheld", + blockers=("candidate_context_not_trusted",), + ) + + blockers: list[str] = [] + if not source: + blockers.append("relationship_source_missing") + if not as_of_date: + blockers.append("relationship_as_of_missing") + relationship_state = "source_backed" if source and as_of_date else "provenance_missing" + + if not role: + role_state = "unreviewed_role" + blockers.append("peer_role_missing") + elif role not in ALLOWED_PEER_ROLES: + role_state = "invalid_role" + blockers.append("peer_role_invalid") + else: + role_state = "reviewed_role" + + if not rationale: + blockers.append("relationship_rationale_missing") + if not comparability_basis: + blockers.append("comparability_basis_missing") + + if not anchor_decision: + blockers.append("valuation_anchor_decision_missing") + elif anchor_decision not in _YES_VALUES | _NO_VALUES: + blockers.append("valuation_anchor_decision_invalid") + elif anchor_decision in _NO_VALUES: + blockers.append("valuation_anchor_explicitly_withheld") + elif role_state == "reviewed_role" and role not in VALUATION_ANCHOR_ROLES: + blockers.append("peer_role_not_anchor_eligible") + + anchor_ready = not blockers + if role_state == "reviewed_role" and role not in VALUATION_ANCHOR_ROLES: + comparability_state = "context_only" + elif anchor_decision in _NO_VALUES and rationale and comparability_basis: + comparability_state = "context_only" + elif role_state == "reviewed_role" and rationale and comparability_basis: + comparability_state = "reviewed_comparable" + else: + comparability_state = "unreviewed" + + return PeerEvidenceQuality( + peer_role=role or "unreviewed", + relationship_state=relationship_state, + role_state=role_state, + comparability_state=comparability_state, + valuation_anchor_state="eligible" if anchor_ready else "withheld", + blockers=tuple(blockers), + ) + + +def is_valuation_anchor_eligible(row: Mapping[str, object]) -> bool: + return assess_peer_evidence(row).valuation_anchor_state == "eligible" diff --git a/src/peer_mapping_source_review.py b/src/peer_mapping_source_review.py index 1047bda6..b27eaa53 100644 --- a/src/peer_mapping_source_review.py +++ b/src/peer_mapping_source_review.py @@ -14,6 +14,7 @@ from pathlib import Path from typing import Iterable +from src.peer_evidence_quality import ALLOWED_PEER_ROLES, VALUATION_ANCHOR_ROLES from src.reviewed_batch import FreshnessStatus, readiness_freshness_status @@ -31,9 +32,12 @@ "peer_group", "sector", "industry", + "peer_role", "source", "as_of_date", "relationship_rationale", + "comparability_basis", + "valuation_anchor_eligible", "reviewer", "review_date", "source_proof_status", @@ -51,13 +55,28 @@ REQUIRED_REVIEW_FIELDS = ( "proposed_peer_ticker", "peer_group", + "peer_role", "source", "as_of_date", "relationship_rationale", + "comparability_basis", + "valuation_anchor_eligible", "reviewer", "review_date", ) -IMPORT_ROW_COLUMNS = ("ticker", "peer_ticker", "peer_group", "sector", "industry", "source", "as_of_date") +IMPORT_ROW_COLUMNS = ( + "ticker", + "peer_ticker", + "peer_group", + "sector", + "industry", + "peer_role", + "relationship_rationale", + "comparability_basis", + "valuation_anchor_eligible", + "source", + "as_of_date", +) READY_SOURCE_PROOF_STATUSES = {"reviewed", "supported", "source_backed", "source-backed"} READY_IMPORT_VALUES = {"yes", "true", "ready", "1"} @@ -81,6 +100,9 @@ class PeerMappingReviewRow: focus_command: str validation_sequence: str do_not_proceed_if: str + peer_role: str = "" + comparability_basis: str = "" + valuation_anchor_eligible: str = "" candidate_context_state: str = "not_loaded" candidate_context_source: str = "not_loaded" candidate_context_count: str = "0" @@ -186,6 +208,14 @@ def peer_mapping_import_csv_header() -> str: def peer_mapping_source_review_missing_fields(row: PeerMappingReviewRow) -> tuple[str, ...]: missing = [field for field in REQUIRED_REVIEW_FIELDS if _is_placeholder(getattr(row, field))] + peer_role = str(row.peer_role or "").strip().lower() + anchor_decision = str(row.valuation_anchor_eligible or "").strip().lower() + if not _is_placeholder(row.peer_role) and peer_role not in ALLOWED_PEER_ROLES: + missing.append("peer_role_invalid") + if not _is_placeholder(row.valuation_anchor_eligible) and anchor_decision not in {"yes", "no"}: + missing.append("valuation_anchor_eligible_invalid") + if anchor_decision == "yes" and peer_role in ALLOWED_PEER_ROLES and peer_role not in VALUATION_ANCHOR_ROLES: + missing.append("peer_role_not_anchor_eligible") proof_status = str(row.source_proof_status or "").strip().lower() if proof_status not in READY_SOURCE_PROOF_STATUSES: missing.append("source_proof_status") @@ -206,6 +236,10 @@ def peer_mapping_import_row_scaffold(row: PeerMappingReviewRow) -> str: row.peer_group, "" if _is_placeholder(row.sector) else row.sector, "" if _is_placeholder(row.industry) else row.industry, + row.peer_role, + row.relationship_rationale, + row.comparability_basis, + row.valuation_anchor_eligible, row.source, row.as_of_date, ) @@ -578,9 +612,12 @@ def build_peer_mapping_source_review_packet( peer_group="", sector="", industry="", + peer_role="", source="", as_of_date="", relationship_rationale="", + comparability_basis="", + valuation_anchor_eligible="", reviewer="", review_date="", source_proof_status="needs_review", @@ -642,8 +679,8 @@ def render_peer_mapping_source_review_markdown(packet: PeerMappingSourceReviewPa "", "## Source Proof Contract", "", - "- Import schema: `ticker, peer_ticker, peer_group, sector, industry, source, as_of_date`.", - "- Required review fields before import: proposed peer ticker, peer group, source, as-of date, relationship rationale, reviewer, and review date.", + "- Import schema: `ticker, peer_ticker, peer_group, sector, industry, peer_role, relationship_rationale, comparability_basis, valuation_anchor_eligible, source, as_of_date`.", + "- Required review fields before import: proposed peer ticker, peer group, peer role, source, as-of date, relationship rationale, comparability basis, valuation-anchor decision, reviewer, and review date.", "- Accepted proof: a durable URL or local document that names the peer relationship or supports comparable business context.", "- Rejected shortcuts: memory, popularity, sector/theme similarity alone, row-count convenience, or placeholders.", "- Candidate context: local classification leads may help source review, but remain `candidate_context_only` and never count as trusted peer proof.", @@ -680,8 +717,11 @@ def render_peer_mapping_source_review_markdown(packet: PeerMappingSourceReviewPa f"- Post-apply proof: `{import_preview.post_apply_proof}`", f"- Proposed peer ticker: `{row.proposed_peer_ticker}`", f"- Peer group: `{row.peer_group}`", + f"- Peer role: `{row.peer_role}`", f"- Source: `{row.source}`", f"- Relationship rationale: `{row.relationship_rationale}`", + f"- Comparability basis: `{row.comparability_basis}`", + f"- Valuation anchor eligible: `{row.valuation_anchor_eligible}`", f"- Candidate context state: `{row.candidate_context_state}`", f"- Candidate context source: `{row.candidate_context_source}`", f"- Candidate context count: `{row.candidate_context_count}`", @@ -784,9 +824,12 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser.add_argument("--peer-group", default="") parser.add_argument("--sector", default="") parser.add_argument("--industry", default="") + parser.add_argument("--peer-role", default="") parser.add_argument("--source", default="") parser.add_argument("--as-of-date", default="") parser.add_argument("--relationship-rationale", default="") + parser.add_argument("--comparability-basis", default="") + parser.add_argument("--valuation-anchor-eligible", default="") parser.add_argument("--reviewer", default="") parser.add_argument("--review-date", default="") parser.add_argument("--source-proof-status", default="needs_review") @@ -804,9 +847,12 @@ def main(argv: list[str] | None = None) -> int: peer_group=args.peer_group, sector=args.sector, industry=args.industry, + peer_role=args.peer_role, source=args.source, as_of_date=args.as_of_date, relationship_rationale=args.relationship_rationale, + comparability_basis=args.comparability_basis, + valuation_anchor_eligible=args.valuation_anchor_eligible, reviewer=args.reviewer, review_date=args.review_date, source_proof_status=args.source_proof_status, diff --git a/src/peer_read_through_map.py b/src/peer_read_through_map.py index 86f6b1f8..29ee2f00 100644 --- a/src/peer_read_through_map.py +++ b/src/peer_read_through_map.py @@ -7,6 +7,7 @@ from dataclasses import asdict, dataclass from typing import Any, Mapping +from src.peer_evidence_quality import assess_peer_evidence @dataclass(frozen=True) class PeerReadThroughEdge: @@ -14,6 +15,10 @@ class PeerReadThroughEdge: peer_ticker: str relationship_state: str peer_group: str + peer_role: str + comparability_state: str + valuation_anchor_state: str + evidence_quality_blockers: tuple[str, ...] business_overlap: str fiscal_timing: str result_evidence: str @@ -37,6 +42,8 @@ class PeerReadThroughMap: candidate_count: int reviewable_count: int withheld_count: int + valuation_anchor_count: int + comparability_withheld_count: int boundary: str @@ -91,6 +98,7 @@ def _trusted_edge( ) -> PeerReadThroughEdge: peer = _text(row.get("peer_ticker")).upper() overlap = _business_overlap(row) + quality = assess_peer_evidence(row) relationship_ready = _relationship_source_ready(row) result = row.get("peer_result") if isinstance(row.get("peer_result"), Mapping) else {} result = result or {} @@ -125,6 +133,10 @@ def _trusted_edge( peer_ticker=peer, relationship_state=relationship_state, peer_group=_text(row.get("peer_group")) or "Not documented", + peer_role=quality.peer_role, + comparability_state=quality.comparability_state, + valuation_anchor_state=quality.valuation_anchor_state, + evidence_quality_blockers=quality.blockers, business_overlap=overlap, fiscal_timing=fiscal_timing, result_evidence=result_label, @@ -139,11 +151,16 @@ def _trusted_edge( def _candidate_edge(*, ticker: str, row: Mapping[str, object]) -> PeerReadThroughEdge: + quality = assess_peer_evidence(row) return PeerReadThroughEdge( subject_ticker=ticker, peer_ticker=_text(row.get("peer_ticker")).upper(), relationship_state="candidate_context_only", peer_group=_text(row.get("peer_group")) or "Not documented", + peer_role=quality.peer_role, + comparability_state=quality.comparability_state, + valuation_anchor_state=quality.valuation_anchor_state, + evidence_quality_blockers=quality.blockers, business_overlap=_business_overlap(row), fiscal_timing="Not established", result_evidence="Not reviewed", @@ -170,7 +187,20 @@ def build_peer_read_through_map( if asset_type in {"etf", "index_proxy", "fund"}: identity_payload = {"ticker": ticker, "profile_key": profile_key, "status": "excluded", "edges": []} identity = hashlib.sha256(json.dumps(identity_payload, sort_keys=True).encode("utf-8")).hexdigest() - return PeerReadThroughMap(ticker, profile_key, "excluded", identity, (), 0, 0, 0, 0, common_boundary) + return PeerReadThroughMap( + ticker=ticker, + profile_key=profile_key, + status="excluded", + map_identity=identity, + edges=(), + trusted_count=0, + candidate_count=0, + reviewable_count=0, + withheld_count=0, + valuation_anchor_count=0, + comparability_withheld_count=0, + boundary=common_boundary, + ) earnings = report_payload.get("earnings_summary") if isinstance(report_payload.get("earnings_summary"), Mapping) else {} target_period = _text((earnings or {}).get("fiscal_period")) @@ -204,6 +234,14 @@ def build_peer_read_through_map( trusted_count = sum(edge.relationship_state == "trusted_peer_ready" for edge in edges) candidate_count = sum(edge.relationship_state == "candidate_context_only" for edge in edges) reviewable_count = sum(edge.read_through_state == "reviewable_context" for edge in edges) + valuation_anchor_count = sum( + edge.relationship_state == "trusted_peer_ready" and edge.valuation_anchor_state == "eligible" + for edge in edges + ) + comparability_withheld_count = sum( + edge.relationship_state == "trusted_peer_ready" and edge.valuation_anchor_state != "eligible" + for edge in edges + ) status = "ready_for_context_review" if reviewable_count else "evidence_withheld" if edges else "no_relationship_evidence" return PeerReadThroughMap( ticker=ticker, @@ -215,6 +253,8 @@ def build_peer_read_through_map( candidate_count=candidate_count, reviewable_count=reviewable_count, withheld_count=len(edges) - reviewable_count, + valuation_anchor_count=valuation_anchor_count, + comparability_withheld_count=comparability_withheld_count, boundary=common_boundary, ) @@ -228,12 +268,30 @@ def peer_read_through_rows(read_through: PeerReadThroughMap) -> list[dict[str, s "awaiting_peer_result": "Awaiting peer result", "awaiting_fiscal_timing": "Awaiting fiscal timing", } + role_labels = { + "core_peer": "Core peer", + "secondary_peer": "Secondary peer", + "aspirational_peer": "Aspirational peer", + "negative_peer": "Negative peer", + "excluded_close_peer": "Excluded close peer", + "not_clean_comp": "Not clean comp", + "unreviewed": "Unreviewed", + } + comparability_labels = { + "reviewed_comparable": "Reviewed comparable", + "context_only": "Context only", + "unreviewed": "Unreviewed", + } + anchor_labels = {"eligible": "Eligible", "withheld": "Withheld"} rows: list[dict[str, str]] = [] for edge in read_through.edges: rows.append( { "Peer": edge.peer_ticker, "Relationship": "Trusted peer" if edge.relationship_state == "trusted_peer_ready" else state_labels.get(edge.relationship_state, edge.relationship_state), + "Peer Role": role_labels.get(edge.peer_role, edge.peer_role), + "Comparability": comparability_labels.get(edge.comparability_state, edge.comparability_state), + "Valuation Anchor": anchor_labels.get(edge.valuation_anchor_state, edge.valuation_anchor_state), "Business Overlap": edge.business_overlap, "Fiscal Timing": edge.fiscal_timing, "Peer Result": edge.result_evidence, diff --git a/src/pilot_readiness.py b/src/pilot_readiness.py index b3ede5b7..6e1f6b79 100644 --- a/src/pilot_readiness.py +++ b/src/pilot_readiness.py @@ -304,14 +304,17 @@ def _hygiene_check(root: Path) -> PilotReadinessCheck: def _freshness_check(root: Path) -> PilotReadinessCheck: freshness = readiness_freshness_status(root) status = "green" if freshness.status == "current" else "blocked" - command = "make status-check TOP_N=5" if freshness.status == "current" else "make readiness" + command = "make status-check TOP_N=5" if freshness.status == "current" else "make readiness-preview TOP_N=20" return PilotReadinessCheck( area="Readiness freshness", status=status, title=f"Readiness artifacts are {freshness.status}", detail=freshness.message, command=command, - stop_rule="Stop before quoting final counts or proof deltas if readiness artifacts are stale or missing.", + stop_rule=( + "Stop before quoting final counts or proof deltas if readiness artifacts are stale or missing. " + "The preview is inspection evidence only; an intentional reviewed make readiness run remains the separate rebuild boundary." + ), ) diff --git a/src/point_in_time_universe.py b/src/point_in_time_universe.py new file mode 100644 index 00000000..a2ac4d2e --- /dev/null +++ b/src/point_in_time_universe.py @@ -0,0 +1,3325 @@ +from __future__ import annotations + +from collections import Counter +import hashlib +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from types import MappingProxyType +from typing import Mapping + +from src.commercial_source_rights import ( + parse_source_rights_registry, + review_commercial_field_scope, +) +from src.point_in_time_universe_contracts import ( + EVENT_TYPES, + IdentityObservation, + ParsedUniverseEvidence, + RAW_MISSING_CELL, + parse_universe_evidence, + parse_utc, +) +from src.point_in_time_universe_identifiers import ( + escape_structural_token, + is_control_free, +) +from src.point_in_time_universe_lineage import resolve_lineage +from src.point_in_time_universe_manifest import UniverseManifest, load_universe_package + + +@dataclass(frozen=True) +class Decision: + area: str + status: str + reason_codes: tuple[str, ...] + + +@dataclass(frozen=True) +class ExcludedRow: + contract: str + source_row: int + row_id: str + reason_codes: tuple[str, ...] + + +@dataclass(frozen=True) +class RowReference: + contract: str + source_row: int + row_id: str + evaluation_row_id: str = "" + + +@dataclass(frozen=True) +class MembershipDigest: + universe_id: str + evaluation_at: str + member_count: int + sha256: str + + +@dataclass(frozen=True) +class EvaluationMemberScope: + security_ids: frozenset[str] + resolved: bool + + +@dataclass(frozen=True) +class ScopedLineageComposition: + leaves: tuple + reason_codes: tuple[str, ...] + reason_records: Mapping[str, tuple] + reasons_by_scope: Mapping[object, tuple[str, ...]] + + +@dataclass(frozen=True) +class EffectiveIdentityResolver: + history: tuple[IdentityObservation, ...] + invalid_scopes: frozenset[str] + + def active_at( + self, + security_id: str, + effective_at: datetime, + *, + end_inclusive: bool = False, + start_strict: bool = False, + ) -> tuple[IdentityObservation, ...]: + return tuple( + identity + for identity in self.history + if ( + identity.security_id == security_id + and ( + identity.valid_from < effective_at + if start_strict + else identity.valid_from <= effective_at + ) + and ( + identity.valid_to is None + or ( + effective_at <= identity.valid_to + if end_inclusive + else effective_at < identity.valid_to + ) + ) + ) + ) + + def security_scope_invalid(self, security_id: str) -> bool: + return security_id in self.invalid_scopes + + +@dataclass(frozen=True) +class RecordSourceIndex: + source_rows: Mapping[str, Mapping[int, int]] + + def source_row(self, contract: str, record) -> int: + return self.source_rows[contract][id(record)] + + +@dataclass(frozen=True) +class PointInTimeUniversePacket: + dataset_id: str + manifest_id: str + analysis_eligible: bool + decisions: Mapping[str, Decision] + raw_count: int + normalized_count: int + raw_rows: tuple[RowReference, ...] + normalized_rows: tuple[RowReference, ...] + excluded: tuple[ExcludedRow, ...] + excluded_count: int + exclusion_reason_counts: Mapping[str, int] + analysis_eligible_rows: tuple[RowReference, ...] + analysis_eligible_row_count: int + membership_digests: tuple[MembershipDigest, ...] + display_tickers: Mapping[str, str] + boundary: str + + +DECISION_ORDER = ( + "manifest_integrity", + "technical_validity", + "temporal_validity", + "identity_coverage", + "membership_coverage", + "corporate_action_coverage", + "delisting_coverage", + "source_rights_eligibility", + "reproduction_ready", + "leakage_safe", +) +MAX_PREVIEW_EXCLUSION_ROWS = 100 +SUCCESSOR_EVENT_TYPES = frozenset( + {"merger", "acquisition", "spinoff"} +) +REPLACEMENT_SUCCESSOR_EVENT_TYPES = frozenset( + {"merger", "acquisition"} +) +INVALID_SUCCESSOR_IDENTIFIERS = frozenset( + {"unknown", "ambiguous"} +) +RESTRICTIVE_LISTING_STATE_EVENT_TYPES = MappingProxyType( + { + "delisted": "delisting", + "suspended": "suspension", + } +) +EVENT_LISTING_STATE_CONTRACT = MappingProxyType( + { + "listing": frozenset({"", "active"}), + "ticker_change": frozenset( + {"", "active", "delisted", "suspended"} + ), + "exchange_change": frozenset( + {"", "active", "delisted", "suspended"} + ), + "split": frozenset( + {"", "active", "delisted", "suspended"} + ), + "reverse_split": frozenset( + {"", "active", "delisted", "suspended"} + ), + "merger": frozenset( + {"", "active", "delisted", "suspended"} + ), + "acquisition": frozenset( + {"", "active", "delisted", "suspended"} + ), + "spinoff": frozenset( + {"", "active", "delisted", "suspended"} + ), + "delisting": frozenset({"delisted"}), + "suspension": frozenset({"suspended"}), + "reactivation": frozenset({"active"}), + } +) +CONTRACT_TIMESTAMP_FIELDS = MappingProxyType({ + "security_identity": ( + "valid_from", + "valid_to", + "source_published_at", + "retrieved_at", + ), + "membership": ( + "effective_from", + "effective_to", + "observation_at", + "source_published_at", + "retrieved_at", + ), + "events": ( + "effective_at", + "source_published_at", + "retrieved_at", + ), + "evaluations": ( + "evaluation_at", + "available_at", + ), +}) + + +def _validate_top_n(top_n: int) -> None: + if ( + isinstance(top_n, bool) + or not isinstance(top_n, int) + or top_n < 0 + or top_n > MAX_PREVIEW_EXCLUSION_ROWS + ): + raise ValueError("top_n_invalid") + +ROW_ID_FIELDS = { + "security_identity": "identity_row_id", + "membership": "membership_row_id", + "events": "event_row_id", + "evaluations": "evaluation_row_id", +} + +RECORD_ROW_ID_ATTRIBUTES = { + "security_identity": "identity_row_id", + "membership": "membership_row_id", + "events": "event_row_id", + "evaluations": "evaluation_row_id", +} + + +def _membership_digest( + universe_id: str, + evaluation_at: str, + members: set[str], +) -> MembershipDigest: + payload = "\n".join(sorted(members)).encode("utf-8") + return MembershipDigest( + universe_id, + evaluation_at, + len(members), + hashlib.sha256(payload).hexdigest(), + ) + + +def _contains(start, end, at): + return start <= at and (end is None or at < end) + + +def _record_row_id(contract: str, record) -> str: + return getattr(record, RECORD_ROW_ID_ATTRIBUTES[contract]) + + +def _record_source_rows( + parsed: ParsedUniverseEvidence, + contract: str, + records, +) -> Mapping[int, int]: + finding_rows = { + (finding.contract, finding.source_row) + for finding in parsed.findings + } + normalized_raw = tuple( + row + for row in parsed.raw + if ( + row.contract == contract + and (contract, row.source_row) not in finding_rows + ) + ) + if len(normalized_raw) != len(records): + raise RuntimeError("normalized_record_source_row_mismatch") + return MappingProxyType( + { + id(record): raw.source_row + for record, raw in zip( + records, + normalized_raw, + strict=True, + ) + } + ) + + +def _build_record_source_index( + parsed: ParsedUniverseEvidence, +) -> RecordSourceIndex: + records_by_contract = { + "security_identity": parsed.identities, + "membership": parsed.memberships, + "events": parsed.events, + "evaluations": parsed.evaluations, + } + return RecordSourceIndex( + MappingProxyType( + { + contract: _record_source_rows( + parsed, + contract, + records, + ) + for contract, records in records_by_contract.items() + } + ) + ) + + +def _excluded_record( + source_index: RecordSourceIndex, + contract: str, + record, + reason_codes: tuple[str, ...], +) -> ExcludedRow: + return ExcludedRow( + contract, + source_index.source_row(contract, record), + _record_row_id(contract, record), + reason_codes, + ) + + +def _record_exclusions( + source_index: RecordSourceIndex, + contract: str, + records, + reason_codes: tuple[str, ...], +) -> tuple[ExcludedRow, ...]: + return tuple( + _excluded_record( + source_index, + contract, + record, + reason_codes, + ) + for record in records + ) + + +def _lineage_reason_records( + records, + *, + row_id, + parent_id, + scope, + available_at, + cutoff, +) -> Mapping[str, tuple]: + eligible = tuple( + record for record in records if available_at(record) <= cutoff + ) + marked: dict[str, list] = {} + + def mark(reason: str, *items) -> None: + bucket = marked.setdefault(reason, []) + for item in items: + if all(existing is not item for existing in bucket): + bucket.append(item) + + by_identifier: dict[str, list] = {} + for record in eligible: + by_identifier.setdefault(row_id(record), []).append(record) + for duplicates in by_identifier.values(): + if len(duplicates) > 1: + mark("lineage_duplicate_id", *duplicates) + + by_id = { + identifier: items[0] + for identifier, items in by_identifier.items() + if len(items) == 1 + } + roots: dict[str, list] = {} + children: dict[str, list] = {} + for record in eligible: + parent_identifier = parent_id(record) + record_scope = scope(record) + if not parent_identifier: + roots.setdefault(record_scope, []).append(record) + continue + matching_parents = by_identifier.get(parent_identifier) + if matching_parents is None: + mark("lineage_missing_parent", record) + continue + if len(matching_parents) != 1: + mark("lineage_duplicate_id", record) + continue + children.setdefault(parent_identifier, []).append(record) + parent = matching_parents[0] + if scope(parent) != record_scope: + mark("lineage_cross_scope_parent", parent, record) + if available_at(record) <= available_at(parent): + mark("lineage_order_reversed", parent, record) + + for scope_roots in roots.values(): + if len(scope_roots) > 1: + mark("lineage_multiple_roots", *scope_roots) + for parent_identifier, child_records in children.items(): + if len(child_records) > 1: + mark( + "lineage_fork", + *by_identifier.get(parent_identifier, ()), + *child_records, + ) + + for start in eligible: + path: list = [] + positions: dict[str, int] = {} + current = start + while parent_id(current): + current_identifier = row_id(current) + if current_identifier in positions: + mark( + "lineage_cycle", + *path[positions[current_identifier]:], + ) + break + positions[current_identifier] = len(path) + path.append(current) + parent = by_id.get(parent_id(current)) + if parent is None: + break + current = parent + + return MappingProxyType( + { + reason: tuple(items) + for reason, items in sorted(marked.items()) + } + ) + + +def _compose_scoped_lineage( + records, + *, + row_id, + parent_id, + scope, + available_at, + cutoff, +) -> ScopedLineageComposition: + eligible = tuple( + record for record in records if available_at(record) <= cutoff + ) + reason_records = _lineage_reason_records( + records, + row_id=row_id, + parent_id=parent_id, + scope=scope, + available_at=available_at, + cutoff=cutoff, + ) + reasons_by_scope: dict[object, set[str]] = {} + for reason, offenders in reason_records.items(): + for offender in offenders: + reasons_by_scope.setdefault(scope(offender), set()).add(reason) + + records_by_scope: dict[str, list] = {} + for record in eligible: + records_by_scope.setdefault(scope(record), []).append(record) + leaves: list = [] + for record_scope, scoped_records in sorted(records_by_scope.items()): + if record_scope in reasons_by_scope: + continue + resolved = resolve_lineage( + scoped_records, + row_id=row_id, + parent_id=parent_id, + scope=scope, + available_at=available_at, + cutoff=cutoff, + ) + if resolved.reason_codes: + raise RuntimeError("scoped_lineage_composition_mismatch") + leaves.extend(resolved.leaves) + + return ScopedLineageComposition( + tuple(leaves), + tuple(reason_records), + MappingProxyType(dict(reason_records)), + MappingProxyType( + { + record_scope: tuple(sorted(reasons)) + for record_scope, reasons in sorted( + reasons_by_scope.items() + ) + } + ), + ) + + +def _effective_identity_interval_history( + records: tuple[IdentityObservation, ...], + lineage: ScopedLineageComposition, + cutoff: datetime, +) -> tuple[ + tuple[IdentityObservation, ...], + frozenset[str], +]: + eligible_by_scope: dict[ + str, + list[IdentityObservation], + ] = {} + for record in records: + record_scope = record.security_id + if ( + max(record.source_published_at, record.retrieved_at) + <= cutoff + and record_scope not in lineage.reasons_by_scope + ): + eligible_by_scope.setdefault( + record_scope, + [], + ).append(record) + + effective_history: list[IdentityObservation] = [] + invalid_scopes: set[str] = set() + for leaf in lineage.leaves: + record_scope = leaf.security_id + scoped_records = eligible_by_scope[record_scope] + by_id = { + record.identity_row_id: record + for record in scoped_records + } + chain: list[IdentityObservation] = [] + current = leaf + while True: + chain.append(current) + if not current.supersedes_identity_row_id: + break + current = by_id[current.supersedes_identity_row_id] + chain.reverse() + + effective: list[IdentityObservation] = [] + direct_parent: IdentityObservation | None = None + for record in chain: + if direct_parent is None: + effective.append(record) + elif ( + direct_parent.valid_from < record.valid_from + and direct_parent.valid_to == record.valid_from + ): + effective.append(record) + else: + effective[-1] = record + while len(effective) > 1: + prior_effective = effective[-2] + corrected_effective = effective[-1] + if ( + prior_effective.valid_from + < corrected_effective.valid_from + and prior_effective.valid_to + == corrected_effective.valid_from + ): + break + del effective[-2] + direct_parent = record + + ordered = tuple( + sorted( + effective, + key=lambda record: ( + record.valid_from, + record.identity_row_id, + ), + ) + ) + if any( + record.valid_to is not None + and record.valid_to < record.valid_from + for record in ordered + ) or any( + prior.valid_to is None + or prior.valid_to > current.valid_from + for prior, current in zip( + ordered, + ordered[1:], + strict=False, + ) + ): + invalid_scopes.add(record_scope) + continue + effective_history.extend(ordered) + + return tuple(effective_history), frozenset(invalid_scopes) + + +def _effective_identity_resolver( + records: tuple[IdentityObservation, ...], + lineage: ScopedLineageComposition, + cutoff: datetime, +) -> EffectiveIdentityResolver: + history, invalid_scopes = _effective_identity_interval_history( + records, + lineage, + cutoff, + ) + return EffectiveIdentityResolver(history, invalid_scopes) + + +def _event_identity_endpoints( + event, + resolver: EffectiveIdentityResolver, +) -> tuple[ + tuple[IdentityObservation, ...], + tuple[IdentityObservation, ...], +]: + if event.event_type not in SUCCESSOR_EVENT_TYPES: + return (), () + replacement = ( + event.event_type in REPLACEMENT_SUCCESSOR_EVENT_TYPES + ) + predecessors = resolver.active_at( + event.security_id, + event.effective_at, + end_inclusive=replacement, + start_strict=replacement, + ) + successors = resolver.active_at( + event.successor_security_id, + event.effective_at, + ) + return predecessors, successors + + +def _identity_transition_event_requirements( + identity_history: tuple[IdentityObservation, ...], + evaluation_at: datetime, +) -> tuple[tuple[IdentityObservation, str], ...]: + by_security: dict[str, list[IdentityObservation]] = {} + for identity in identity_history: + by_security.setdefault(identity.security_id, []).append(identity) + + requirements: list[tuple[IdentityObservation, str]] = [] + for identities in by_security.values(): + ordered = sorted( + identities, + key=lambda identity: ( + identity.valid_from, + identity.identity_row_id, + ), + ) + for prior, current in zip( + ordered, + ordered[1:], + strict=False, + ): + if ( + prior.valid_from >= current.valid_from + or prior.valid_to != current.valid_from + or current.valid_from > evaluation_at + ): + continue + if prior.ticker != current.ticker: + requirements.append((current, "ticker_change")) + if prior.exchange != current.exchange: + requirements.append((current, "exchange_change")) + return tuple(requirements) + + +def _identity_security_reuse_records( + records: tuple[IdentityObservation, ...], + evaluation_at: datetime, +) -> tuple[IdentityObservation, ...]: + effective_by_security_and_start: dict[ + str, + dict[datetime, IdentityObservation], + ] = {} + for record in records: + if ( + max(record.source_published_at, record.retrieved_at) + > evaluation_at + or record.valid_from > evaluation_at + ): + continue + by_start = effective_by_security_and_start.setdefault( + record.security_id, + {}, + ) + prior = by_start.get(record.valid_from) + if prior is None or ( + max(record.source_published_at, record.retrieved_at), + record.identity_row_id, + record.source_ref, + ) > ( + max(prior.source_published_at, prior.retrieved_at), + prior.identity_row_id, + prior.source_ref, + ): + by_start[record.valid_from] = record + + reuse_records: list[IdentityObservation] = [] + for by_start in effective_by_security_and_start.values(): + ordered = tuple( + by_start[valid_from] + for valid_from in sorted(by_start) + ) + reuse_records.extend( + current + for prior, current in zip( + ordered, + ordered[1:], + strict=False, + ) + if prior.issuer_id != current.issuer_id + ) + return tuple( + sorted( + reuse_records, + key=lambda record: ( + record.security_id, + record.valid_from, + record.identity_row_id, + record.source_ref, + ), + ) + ) + + +def _identity_cross_issuer_security_ids( + records: tuple[IdentityObservation, ...], + evaluation_at: datetime, +) -> frozenset[str]: + visible = tuple( + record + for record in records + if ( + max(record.source_published_at, record.retrieved_at) + <= evaluation_at + and record.valid_from <= evaluation_at + ) + ) + records_by_id: dict[str, list[IdentityObservation]] = {} + for record in visible: + records_by_id.setdefault( + record.identity_row_id, + [], + ).append(record) + by_id = { + identity_row_id: matching_records[0] + for identity_row_id, matching_records in records_by_id.items() + if len(matching_records) == 1 + } + children_by_parent: dict[str, list[IdentityObservation]] = {} + for record in visible: + if record.supersedes_identity_row_id in by_id: + children_by_parent.setdefault( + record.supersedes_identity_row_id, + [], + ).append(record) + + def edge_is_cyclic(parent_id: str, child_id: str) -> bool: + current_id = parent_id + visited: set[str] = set() + while current_id in by_id and current_id not in visited: + visited.add(current_id) + next_id = by_id[current_id].supersedes_identity_row_id + if next_id == child_id: + return True + current_id = next_id + return False + + def is_valid_correction_edge( + parent: IdentityObservation, + child: IdentityObservation, + ) -> bool: + return ( + by_id.get(parent.identity_row_id) is parent + and by_id.get(child.identity_row_id) is child + and child.security_id == parent.security_id + and child.valid_from == parent.valid_from + and max( + child.source_published_at, + child.retrieved_at, + ) + > max( + parent.source_published_at, + parent.retrieved_at, + ) + and not edge_is_cyclic( + parent.identity_row_id, + child.identity_row_id, + ) + ) + + corrected_parent_ids: set[str] = set() + for parent_id, children in children_by_parent.items(): + parent = by_id[parent_id] + correction_candidates = tuple( + child + for child in children + if is_valid_correction_edge(parent, child) + ) + if len(correction_candidates) == 1: + corrected_parent_ids.add(parent_id) + issuers_by_security: dict[str, set[str]] = {} + for record in visible: + if record.identity_row_id in corrected_parent_ids: + continue + issuers_by_security.setdefault( + record.security_id, + set(), + ).add(record.issuer_id) + return frozenset( + security_id + for security_id, issuer_ids in issuers_by_security.items() + if len(issuer_ids) > 1 + ) + + +def _identity_interval_overlap_records( + records: tuple[IdentityObservation, ...], + evaluation_at: datetime, +) -> tuple[IdentityObservation, ...]: + visible_active = tuple( + record + for record in records + if ( + max(record.source_published_at, record.retrieved_at) + <= evaluation_at + and _contains( + record.valid_from, + record.valid_to, + evaluation_at, + ) + ) + ) + superseded_same_effective_ids = { + record.supersedes_identity_row_id + for record in visible_active + for parent in visible_active + if ( + record.supersedes_identity_row_id + == parent.identity_row_id + and record.security_id == parent.security_id + and record.valid_from == parent.valid_from + and record.valid_to == parent.valid_to + ) + } + effective = tuple( + record + for record in visible_active + if record.identity_row_id not in superseded_same_effective_ids + ) + by_security: dict[str, list[IdentityObservation]] = {} + for record in effective: + by_security.setdefault(record.security_id, []).append(record) + return tuple( + sorted( + ( + record + for security_records in by_security.values() + if len(security_records) > 1 + for record in security_records + ), + key=lambda record: ( + record.security_id, + record.valid_from, + record.identity_row_id, + record.source_ref, + ), + ) + ) + + +def _unexplained_snapshot_omissions( + memberships, + lineage: ScopedLineageComposition, + *, + universe_id: str, + universe_kind: str, + latest_snapshot_at: datetime | None, + evaluation_at: datetime, +): + if latest_snapshot_at is None: + return () + explicit_latest_security_ids = { + row.security_id + for row in lineage.leaves + if ( + row.universe_id == universe_id + and row.universe_kind == universe_kind + and row.observation_at == latest_snapshot_at + and _contains( + row.effective_from, + row.effective_to, + evaluation_at, + ) + ) + } + prior_inclusion_by_security = {} + for row in memberships: + if ( + row.universe_id != universe_id + or row.universe_kind != universe_kind + or row.membership_state != "included" + or row.observation_at >= latest_snapshot_at + or max( + row.observation_at, + row.source_published_at, + row.retrieved_at, + ) + > evaluation_at + or not _contains( + row.effective_from, + row.effective_to, + evaluation_at, + ) + ): + continue + prior = prior_inclusion_by_security.get(row.security_id) + if prior is None or ( + row.observation_at, + row.source_published_at, + row.retrieved_at, + row.membership_row_id, + row.source_ref, + ) > ( + prior.observation_at, + prior.source_published_at, + prior.retrieved_at, + prior.membership_row_id, + prior.source_ref, + ): + prior_inclusion_by_security[row.security_id] = row + return tuple( + prior_inclusion_by_security[security_id] + for security_id in sorted( + set(prior_inclusion_by_security) + - explicit_latest_security_ids + ) + ) + + +def _identity_membership_decisions( + manifest: UniverseManifest, + parsed: ParsedUniverseEvidence, + source_index: RecordSourceIndex, + evaluations, + evaluation_reasons: Mapping[str, tuple[str, ...]], +): + declared = { + item["universe_id"]: item["universe_kind"] + for item in manifest.declared_universes + } + identity_reasons: set[str] = set() + membership_reasons: set[str] = set() + excluded: list[ExcludedRow] = [] + digests: list[MembershipDigest] = [] + display_candidates: dict[str, tuple[tuple, str | None]] = {} + complete_snapshot_supported = ( + manifest.coverage_semantics == "complete_snapshot" + ) + if not complete_snapshot_supported: + membership_reasons.add( + "membership_coverage_semantics_unsupported" + ) + for evaluation in parsed.evaluations: + owned_reasons = tuple( + reason + for reason in evaluation_reasons[ + evaluation.evaluation_row_id + ] + if reason.startswith("membership_") + ) + if not owned_reasons: + continue + membership_reasons.update(owned_reasons) + excluded.append( + ExcludedRow( + "evaluations", + source_index.source_row("evaluations", evaluation), + evaluation.evaluation_row_id, + owned_reasons, + ) + ) + evaluations = tuple( + sorted( + ( + item + for item in evaluations + ), + key=lambda item: ( + item.evaluation_at, + item.universe_id, + item.evaluation_row_id, + item.available_at, + item.partition, + item.source_ref, + ), + ) + ) + + for evaluation in evaluations: + visible_memberships = tuple( + row + for row in parsed.memberships + if max( + row.observation_at, + row.source_published_at, + row.retrieved_at, + ) + <= evaluation.evaluation_at + ) + for row in visible_memberships: + expected_row_kind = declared.get(row.universe_id) + if expected_row_kind is None: + reason = "membership_universe_undeclared" + elif row.universe_kind != expected_row_kind: + reason = "membership_universe_kind_mismatch" + else: + continue + membership_reasons.add(reason) + excluded.append( + _excluded_record( + source_index, + "membership", + row, + (reason,), + ) + ) + + membership_lineage = _compose_scoped_lineage( + parsed.memberships, + row_id=lambda row: row.membership_row_id, + parent_id=lambda row: row.supersedes_membership_row_id, + scope=lambda row: (row.universe_id, row.security_id), + available_at=lambda row: max( + row.observation_at, + row.source_published_at, + row.retrieved_at, + ), + cutoff=evaluation.evaluation_at, + ) + membership_reasons.update(membership_lineage.reason_codes) + if membership_lineage.reason_codes: + for reason in membership_lineage.reason_codes: + excluded.extend( + _record_exclusions( + source_index, + "membership", + membership_lineage.reason_records.get( + reason, + (), + ), + (reason,), + ) + ) + identity_lineage = _compose_scoped_lineage( + parsed.identities, + row_id=lambda row: row.identity_row_id, + parent_id=lambda row: row.supersedes_identity_row_id, + scope=lambda row: row.security_id, + available_at=lambda row: max( + row.source_published_at, + row.retrieved_at, + ), + cutoff=evaluation.evaluation_at, + ) + identity_reasons.update(identity_lineage.reason_codes) + if identity_lineage.reason_codes: + for reason in identity_lineage.reason_codes: + excluded.extend( + _record_exclusions( + source_index, + "security_identity", + identity_lineage.reason_records.get( + reason, + (), + ), + (reason,), + ) + ) + + issuer_reuse_records = _identity_security_reuse_records( + parsed.identities, + evaluation.evaluation_at, + ) + cross_issuer_security_ids = _identity_cross_issuer_security_ids( + parsed.identities, + evaluation.evaluation_at, + ) + issuer_reuse_records = tuple( + record + for record in issuer_reuse_records + if not ( + set( + identity_lineage.reasons_by_scope.get( + record.security_id, + (), + ) + ) + - {"lineage_multiple_roots"} + ) + ) + if issuer_reuse_records: + reason = "identity_security_id_reused_across_issuers" + identity_reasons.add(reason) + excluded.extend( + _record_exclusions( + source_index, + "security_identity", + issuer_reuse_records, + (reason,), + ) + ) + + overlap_records = _identity_interval_overlap_records( + parsed.identities, + evaluation.evaluation_at, + ) + overlap_records = tuple( + record + for record in overlap_records + if not ( + set( + identity_lineage.reasons_by_scope.get( + record.security_id, + (), + ) + ) + - {"lineage_multiple_roots"} + ) + ) + if overlap_records: + identity_reasons.add("identity_interval_overlap") + excluded.extend( + _record_exclusions( + source_index, + "security_identity", + overlap_records, + ("identity_interval_overlap",), + ) + ) + + identity_resolver = _effective_identity_resolver( + parsed.identities, + identity_lineage, + evaluation.evaluation_at, + ) + identity_interval_history = identity_resolver.history + expected_kind = declared.get(evaluation.universe_id) + if expected_kind is None: + membership_reasons.add("membership_universe_undeclared") + continue + + scoped_memberships = tuple( + row + for row in parsed.memberships + if row.universe_id == evaluation.universe_id + ) + cutoff_available_memberships = tuple( + row + for row in scoped_memberships + if max( + row.observation_at, + row.source_published_at, + row.retrieved_at, + ) + <= evaluation.evaluation_at + ) + latest_snapshot_at = max( + ( + row.observation_at + for row in cutoff_available_memberships + ), + default=None, + ) + if not complete_snapshot_supported: + continue + + active_identity_by_security: dict[str, IdentityObservation] = {} + issuer_reuse_security_ids = { + record.security_id + for record in issuer_reuse_records + } + overlapping_identity_security_ids = { + record.security_id + for record in overlap_records + } + identity_lineage_reasons_by_security: dict[str, set[str]] = {} + for reason, offenders in identity_lineage.reason_records.items(): + for offender in offenders: + identity_lineage_reasons_by_security.setdefault( + offender.security_id, + set(), + ).add(reason) + available_identity_by_security: dict[str, list] = {} + for row in identity_interval_history: + available_identity_by_security.setdefault( + row.security_id, + [], + ).append(row) + for security_id in available_identity_by_security: + active = identity_resolver.active_at( + security_id, + evaluation.evaluation_at, + ) + if len(active) > 1: + identity_reasons.add("identity_interval_overlap") + overlapping_identity_security_ids.add(security_id) + excluded.extend( + _record_exclusions( + source_index, + "security_identity", + active, + ("identity_interval_overlap",), + ) + ) + elif len(active) == 1: + active_identity_by_security[security_id] = active[0] + + omission_records = _unexplained_snapshot_omissions( + scoped_memberships, + membership_lineage, + universe_id=evaluation.universe_id, + universe_kind=expected_kind, + latest_snapshot_at=latest_snapshot_at, + evaluation_at=evaluation.evaluation_at, + ) + if omission_records: + reason = "membership_snapshot_omission_unexplained" + membership_reasons.add(reason) + excluded.extend( + _record_exclusions( + source_index, + "membership", + omission_records, + (reason,), + ) + ) + continue + + members: set[str] = set() + structurally_eligible_members = 0 + digest_eligible = True + for leaf in membership_lineage.leaves: + if leaf.universe_id != evaluation.universe_id: + continue + if leaf.observation_at != latest_snapshot_at: + continue + if leaf.universe_kind != expected_kind: + membership_reasons.add("membership_universe_kind_mismatch") + continue + if not _contains( + leaf.effective_from, + leaf.effective_to, + evaluation.evaluation_at, + ): + membership_reasons.add("membership_interval_inactive") + excluded.append( + _excluded_record( + source_index, + "membership", + leaf, + ("membership_interval_inactive",), + ) + ) + continue + if leaf.membership_state == "excluded": + continue + + security_id = leaf.security_id + structurally_eligible_members += 1 + evaluation_key = ( + evaluation.evaluation_at, + evaluation.universe_id, + evaluation.evaluation_row_id, + evaluation.available_at, + evaluation.partition, + evaluation.source_ref, + ) + prior_display = display_candidates.get(security_id) + is_latest_display = ( + prior_display is None + or evaluation_key > prior_display[0] + ) + if security_id in issuer_reuse_security_ids: + digest_eligible = False + if is_latest_display: + display_candidates[security_id] = ( + evaluation_key, + None, + ) + excluded.append( + _excluded_record( + source_index, + "membership", + leaf, + ("identity_missing",), + ) + ) + continue + if security_id in overlapping_identity_security_ids: + if is_latest_display: + display_candidates[security_id] = ( + evaluation_key, + None, + ) + excluded.append( + _excluded_record( + source_index, + "membership", + leaf, + ("identity_interval_overlap",), + ) + ) + continue + if security_id in identity_lineage_reasons_by_security: + if security_id in cross_issuer_security_ids: + digest_eligible = False + if is_latest_display: + display_candidates[security_id] = ( + evaluation_key, + None, + ) + excluded.append( + _excluded_record( + source_index, + "membership", + leaf, + ("identity_missing",), + ) + ) + continue + + active_identity = active_identity_by_security.get(security_id) + if active_identity is None: + identity_rows = tuple( + row + for row in identity_interval_history + if row.security_id == security_id + ) + identity_reasons.add("identity_missing") + if is_latest_display: + display_candidates[security_id] = ( + evaluation_key, + None, + ) + excluded.append( + _excluded_record( + source_index, + "membership", + leaf, + ("identity_missing",), + ) + ) + excluded.extend( + _record_exclusions( + source_index, + "security_identity", + identity_rows, + ("identity_missing",), + ) + ) + continue + + if is_latest_display: + display_candidates[security_id] = ( + evaluation_key, + active_identity.ticker, + ) + members.add(security_id) + + if structurally_eligible_members == 0: + membership_reasons.add("membership_no_eligible_members") + if digest_eligible: + digests.append( + _membership_digest( + evaluation.universe_id, + evaluation.evaluation_at.isoformat().replace( + "+00:00", + "Z", + ), + members, + ) + ) + + kinds = set(declared.values()) + if "benchmark" not in kinds: + membership_reasons.add("membership_benchmark_missing") + if "research_universe" not in kinds: + membership_reasons.add("membership_research_universe_missing") + evaluated_universes = {item.universe_id for item in evaluations} + if any( + universe_id not in evaluated_universes + for universe_id in declared + ): + membership_reasons.add("membership_no_evaluation") + + display = { + security_id: candidate[1] + for security_id, candidate in sorted(display_candidates.items()) + if candidate[1] is not None + } + sorted_digests = tuple( + sorted( + digests, + key=lambda item: ( + item.evaluation_at, + item.universe_id, + item.sha256, + item.member_count, + ), + ) + ) + canonical_digests: list[MembershipDigest] = [] + first_digest_by_cutoff: dict[ + tuple[str, str], + MembershipDigest, + ] = {} + for digest in sorted_digests: + key = (digest.universe_id, digest.evaluation_at) + first_digest = first_digest_by_cutoff.get(key) + if first_digest == digest: + continue + canonical_digests.append(digest) + first_digest_by_cutoff.setdefault(key, digest) + return ( + Decision( + "identity_coverage", + "blocked" if identity_reasons else "passed", + tuple(sorted(identity_reasons)), + ), + Decision( + "membership_coverage", + "blocked" if membership_reasons else "passed", + tuple(sorted(membership_reasons)), + ), + tuple(canonical_digests), + MappingProxyType(display), + tuple(excluded), + ) + + +def _temporal_decision( + manifest: UniverseManifest, + parsed: ParsedUniverseEvidence, + source_index: RecordSourceIndex, + evaluations, + evaluation_reasons: Mapping[str, tuple[str, ...]], +) -> tuple[Decision, tuple[str, ...], tuple[ExcludedRow, ...]]: + temporal_reasons: set[str] = set() + leakage_reasons: set[str] = set() + exclusion_reasons: dict[tuple[str, int, str], set[str]] = {} + valid_evaluation_ids = { + evaluation.evaluation_row_id + for evaluation in evaluations + } + declared_universe_ids = { + item["universe_id"] + for item in manifest.declared_universes + } + + def record_exclusion( + contract: str, + record, + *reason_codes: str, + ) -> None: + exclusion_reasons.setdefault( + ( + contract, + source_index.source_row(contract, record), + _record_row_id(contract, record), + ), + set(), + ).update(reason_codes) + + manifest_created_at = parse_utc(manifest.manifest_created_at) + for raw_row in parsed.raw: + post_creation_timestamp = False + for field in CONTRACT_TIMESTAMP_FIELDS[raw_row.contract]: + value = raw_row.values.get(field, "") + if not value: + continue + try: + timestamp = parse_utc(value) + except (TypeError, ValueError): + continue + if timestamp > manifest_created_at: + post_creation_timestamp = True + if not post_creation_timestamp: + continue + temporal_reasons.add( + "temporal_evidence_after_manifest_creation" + ) + exclusion_reasons.setdefault( + ( + raw_row.contract, + raw_row.source_row, + raw_row.values.get( + ROW_ID_FIELDS[raw_row.contract], + "", + ), + ), + set(), + ).add("temporal_evidence_after_manifest_creation") + + for evaluation in parsed.evaluations: + classified_reasons = evaluation_reasons[ + evaluation.evaluation_row_id + ] + if "cutoff_evaluation_after_manifest" in classified_reasons: + temporal_reasons.add("cutoff_evaluation_after_manifest") + leakage_reasons.add( + "leakage_evaluation_after_manifest_cutoff" + ) + record_exclusion( + "evaluations", + evaluation, + "cutoff_evaluation_after_manifest", + "leakage_evaluation_after_manifest_cutoff", + ) + continue + if "cutoff_evaluation_unavailable" in classified_reasons: + temporal_reasons.add("cutoff_evaluation_unavailable") + leakage_reasons.add("leakage_evaluation_available_late") + record_exclusion( + "evaluations", + evaluation, + "cutoff_evaluation_unavailable", + "leakage_evaluation_available_late", + ) + if evaluation.evaluation_row_id not in valid_evaluation_ids: + continue + + def classify_scope( + contract, + rows, + available_at, + *, + required, + ) -> None: + available = tuple( + row + for row in rows + if available_at(row) <= evaluation.evaluation_at + ) + post_cutoff = tuple( + row + for row in rows + if available_at(row) > evaluation.evaluation_at + ) + if not post_cutoff: + return + if required and not available: + temporal_reasons.update( + { + "cutoff_post_evaluation_evidence", + "cutoff_required_scope_unavailable", + } + ) + leakage_reasons.add("leakage_post_cutoff_evidence") + for row in post_cutoff: + record_exclusion( + contract, + row, + "cutoff_post_evaluation_evidence", + "cutoff_required_scope_unavailable", + "leakage_post_cutoff_evidence", + ) + return + reason = ( + "cutoff_later_revision_invisible" + if required + else "cutoff_unrelated_scope_invisible" + ) + for row in post_cutoff: + record_exclusion(contract, row, reason) + + membership_rows = tuple( + row + for row in parsed.memberships + if row.universe_id == evaluation.universe_id + ) + cutoff_available_memberships = tuple( + row + for row in membership_rows + if max( + row.observation_at, + row.source_published_at, + row.retrieved_at, + ) + <= evaluation.evaluation_at + ) + latest_snapshot_at = max( + ( + row.observation_at + for row in cutoff_available_memberships + ), + default=None, + ) + latest_snapshot_security_ids = ( + { + row.security_id + for row in cutoff_available_memberships + if row.observation_at == latest_snapshot_at + } + if manifest.coverage_semantics == "complete_snapshot" + else set() + ) + memberships_by_security: dict[str, list] = {} + for row in membership_rows: + memberships_by_security.setdefault( + row.security_id, + [], + ).append(row) + for group in memberships_by_security.values(): + classify_scope( + "membership", + group, + lambda row: max( + row.observation_at, + row.source_published_at, + row.retrieved_at, + ), + required=( + not cutoff_available_memberships + or group[0].security_id + in latest_snapshot_security_ids + ), + ) + undeclared_memberships_by_scope: dict[ + tuple[str, str], + list, + ] = {} + for row in parsed.memberships: + if row.universe_id in declared_universe_ids: + continue + undeclared_memberships_by_scope.setdefault( + (row.universe_id, row.security_id), + [], + ).append(row) + for group in undeclared_memberships_by_scope.values(): + classify_scope( + "membership", + group, + lambda row: max( + row.observation_at, + row.source_published_at, + row.retrieved_at, + ), + required=False, + ) + + membership_lineage = _compose_scoped_lineage( + parsed.memberships, + row_id=lambda row: row.membership_row_id, + parent_id=lambda row: row.supersedes_membership_row_id, + scope=lambda row: (row.universe_id, row.security_id), + available_at=lambda row: max( + row.observation_at, + row.source_published_at, + row.retrieved_at, + ), + cutoff=evaluation.evaluation_at, + ) + member_security_ids = { + row.security_id + for row in membership_lineage.leaves + if ( + manifest.coverage_semantics == "complete_snapshot" + and row.universe_id == evaluation.universe_id + and row.observation_at == latest_snapshot_at + and row.membership_state == "included" + and _contains( + row.effective_from, + row.effective_to, + evaluation.evaluation_at, + ) + ) + } + + identities_by_security: dict[str, list] = {} + for row in parsed.identities: + identities_by_security.setdefault( + row.security_id, + [], + ).append(row) + for security_id, group in identities_by_security.items(): + classify_scope( + "security_identity", + group, + lambda row: max( + row.source_published_at, + row.retrieved_at, + ), + required=security_id in member_security_ids, + ) + + events_by_scope: dict[tuple[str, str], list] = {} + for row in parsed.events: + events_by_scope.setdefault( + (row.security_id, row.event_type), + [], + ).append(row) + for (security_id, event_type), group in events_by_scope.items(): + classify_scope( + "events", + group, + lambda row: max( + row.effective_at, + row.source_published_at, + row.retrieved_at, + ), + required=( + security_id in member_security_ids + and manifest.corporate_action_policy.get(event_type) + == "required" + and any( + row.effective_at <= evaluation.evaluation_at + for row in group + ) + ), + ) + + excluded = tuple( + ExcludedRow( + contract, + source_row, + row_id, + tuple(sorted(reasons)), + ) + for ( + contract, + source_row, + row_id, + ), reasons in sorted( + exclusion_reasons.items() + ) + ) + return ( + Decision( + "temporal_validity", + "blocked" if temporal_reasons else "passed", + tuple(sorted(temporal_reasons)), + ), + tuple(sorted(leakage_reasons)), + excluded, + ) + + +def _classify_evaluations( + manifest, + evaluations, +) -> tuple[tuple, Mapping[str, tuple[str, ...]], tuple[str, ...]]: + evaluations = tuple(evaluations) + row_reasons: dict[str, set[str]] = { + evaluation.evaluation_row_id: set() + for evaluation in evaluations + } + global_reasons: set[str] = set() + manifest_cutoff = ( + parse_utc(manifest.observation_cutoff_at) + if hasattr(manifest, "observation_cutoff_at") + else None + ) + declared_universe_ids = ( + { + item["universe_id"] + for item in manifest.declared_universes + } + if hasattr(manifest, "declared_universes") + else None + ) + for evaluation in evaluations: + reasons = row_reasons[evaluation.evaluation_row_id] + if ( + manifest_cutoff is not None + and evaluation.evaluation_at > manifest_cutoff + ): + reasons.add("cutoff_evaluation_after_manifest") + if evaluation.available_at > evaluation.evaluation_at: + reasons.add("cutoff_evaluation_unavailable") + if ( + declared_universe_ids is not None + and evaluation.universe_id not in declared_universe_ids + ): + reasons.add("membership_universe_undeclared") + + policy = manifest.evaluation_policy + if not isinstance(policy, Mapping): + global_reasons.add("partition_policy_invalid") + elif policy.get("kind") == "walk_forward": + minimum = policy.get("minimum_history_count") + if ( + not isinstance(minimum, int) + or isinstance(minimum, bool) + or minimum <= 0 + ): + global_reasons.add("partition_minimum_history_invalid") + else: + for evaluation in evaluations: + if evaluation.partition != "walk_forward": + row_reasons[evaluation.evaluation_row_id].add( + "partition_assignment_invalid" + ) + history_by_universe: dict[str, set[datetime]] = {} + for evaluation in evaluations: + if row_reasons[evaluation.evaluation_row_id]: + continue + history_by_universe.setdefault( + evaluation.universe_id, + set(), + ).add(evaluation.evaluation_at) + for evaluation in evaluations: + reasons = row_reasons[evaluation.evaluation_row_id] + if reasons: + continue + prior_history = { + timestamp + for timestamp in history_by_universe.get( + evaluation.universe_id, + set(), + ) + if timestamp < evaluation.evaluation_at + } + if len(prior_history) < minimum: + reasons.add("partition_minimum_history_unmet") + elif policy.get("kind") == "train_validation_test": + try: + train_end = parse_utc(policy["train_end_at"]) + validation_start = parse_utc(policy["validation_start_at"]) + validation_end = parse_utc(policy["validation_end_at"]) + test_start = parse_utc(policy["test_start_at"]) + except (KeyError, TypeError, ValueError): + global_reasons.add("partition_schema_invalid") + else: + if ( + train_end > validation_start + or validation_end > test_start + ): + global_reasons.add("partition_overlap") + if not ( + train_end + < validation_start + < validation_end + < test_start + ): + global_reasons.add("partition_order_invalid") + if not global_reasons: + for evaluation in evaluations: + reasons = row_reasons[ + evaluation.evaluation_row_id + ] + if reasons.intersection( + { + "cutoff_evaluation_after_manifest", + "cutoff_evaluation_unavailable", + "membership_universe_undeclared", + } + ): + continue + at = evaluation.evaluation_at + if at <= train_end: + expected_partition = "train" + elif validation_start <= at <= validation_end: + expected_partition = "validation" + elif at >= test_start: + expected_partition = "test" + else: + expected_partition = None + if expected_partition is None: + reasons.add("partition_boundary_unassigned") + elif evaluation.partition != expected_partition: + reasons.add("partition_assignment_invalid") + else: + global_reasons.add("partition_policy_invalid") + + valid = ( + () + if global_reasons + else tuple( + evaluation + for evaluation in evaluations + if not row_reasons[evaluation.evaluation_row_id] + ) + ) + return ( + valid, + MappingProxyType( + { + row_id: tuple(sorted(reasons)) + for row_id, reasons in sorted(row_reasons.items()) + } + ), + tuple(sorted(global_reasons)), + ) + + +def _partition_validation( + manifest, + evaluations, + extra_reasons=(), + parsed: ParsedUniverseEvidence | None = None, + *, + source_index: RecordSourceIndex | None = None, + evaluation_reasons: Mapping[str, tuple[str, ...]] | None = None, + evaluation_global_reasons: tuple[str, ...] | None = None, +) -> tuple[Decision, tuple[ExcludedRow, ...]]: + reasons = set(extra_reasons) + exclusion_reasons: dict[int, tuple[object, set[str]]] = {} + + def exclude(evaluation, reason: str) -> None: + if reason != "partition_minimum_history_unmet": + reasons.add(reason) + _, row_reasons = exclusion_reasons.setdefault( + id(evaluation), + (evaluation, set()), + ) + row_reasons.add(reason) + + if ( + evaluation_reasons is None + or evaluation_global_reasons is None + ): + _, validity_reasons, global_reasons = _classify_evaluations( + manifest, + evaluations, + ) + else: + validity_reasons = evaluation_reasons + global_reasons = evaluation_global_reasons + reasons.update(global_reasons) + for evaluation in evaluations: + for reason in validity_reasons[evaluation.evaluation_row_id]: + if reason.startswith("partition_"): + exclude(evaluation, reason) + excluded = tuple( + ExcludedRow( + "evaluations", + ( + source_index.source_row("evaluations", evaluation) + if source_index is not None + else 0 + ), + evaluation.evaluation_row_id, + tuple(sorted(row_reasons)), + ) + for evaluation, row_reasons in sorted( + exclusion_reasons.values(), + key=lambda item: ( + item[0].evaluation_row_id, + item[0].evaluation_at, + item[0].source_ref, + ), + ) + ) + return ( + Decision( + "leakage_safe", + "blocked" if reasons else "passed", + tuple(sorted(reasons)), + ), + excluded, + ) + + +def _partition_decision( + manifest, + evaluations, + extra_reasons=(), +) -> Decision: + return _partition_validation( + manifest, + evaluations, + extra_reasons, + )[0] + + +def _reproduction_decision( + manifest, + digests, + evaluations=(), +) -> Decision: + reasons: set[str] = set() + if ( + getattr( + manifest, + "coverage_semantics", + "complete_snapshot", + ) + != "complete_snapshot" + ): + reasons.add("reproduction_coverage_semantics_unsupported") + if ( + manifest.reproduction_contract + != "membership_count_and_sha256_at_cutoff_v1" + ): + reasons.add("reproduction_contract_unsupported") + keys = [ + (item.universe_id, item.evaluation_at) + for item in digests + ] + if len(keys) != len(set(keys)): + reasons.add("reproduction_duplicate_evaluation") + if evaluations: + manifest_cutoff = parse_utc(manifest.observation_cutoff_at) + if any( + evaluation.evaluation_at > manifest_cutoff + for evaluation in evaluations + ): + reasons.add("reproduction_evaluation_after_manifest_cutoff") + expected_keys = { + ( + evaluation.universe_id, + evaluation.evaluation_at.isoformat().replace( + "+00:00", + "Z", + ), + ) + for evaluation in evaluations + } + if ( + manifest.coverage_semantics == "complete_snapshot" + and expected_keys - set(keys) + ): + reasons.add("reproduction_digest_missing") + for item in digests: + try: + parse_utc(item.evaluation_at) + except (TypeError, ValueError): + valid_evaluation_at = False + else: + valid_evaluation_at = True + if ( + not isinstance(item.universe_id, str) + or not item.universe_id + or not valid_evaluation_at + or not isinstance(item.member_count, int) + or isinstance(item.member_count, bool) + or item.member_count < 0 + or not isinstance(item.sha256, str) + or len(item.sha256) != 64 + or any( + character not in "0123456789abcdef" + for character in item.sha256 + ) + ): + reasons.add("reproduction_digest_invalid") + return Decision( + "reproduction_ready", + "blocked" if reasons else "passed", + tuple(sorted(reasons)), + ) + + +def _final_eligibility( + decisions, + digests, + declared_universes, +) -> bool: + if any( + decision.status != "passed" + and not ( + area == "delisting_coverage" + and decision.status == "not_applicable" + ) + for area, decision in decisions.items() + ): + return False + kinds = { + item["universe_id"]: item["universe_kind"] + for item in declared_universes + } + eligible_ids = { + digest.universe_id + for digest in digests + if digest.member_count > 0 + } + return ( + any(kinds.get(item) == "benchmark" for item in eligible_ids) + and any( + kinds.get(item) == "research_universe" + for item in eligible_ids + ) + ) + + +def _member_security_ids_for_evaluation( + manifest, + parsed, + evaluation, +) -> EvaluationMemberScope: + if manifest.coverage_semantics != "complete_snapshot": + return EvaluationMemberScope(frozenset(), False) + expected_kind = { + item["universe_id"]: item["universe_kind"] + for item in manifest.declared_universes + }.get(evaluation.universe_id) + if expected_kind is None: + return EvaluationMemberScope(frozenset(), False) + scoped_memberships = tuple( + row + for row in parsed.memberships + if row.universe_id == evaluation.universe_id + ) + cutoff_available = tuple( + row + for row in scoped_memberships + if max( + row.observation_at, + row.source_published_at, + row.retrieved_at, + ) + <= evaluation.evaluation_at + ) + latest_snapshot_at = max( + (row.observation_at for row in cutoff_available), + default=None, + ) + if latest_snapshot_at is None: + return EvaluationMemberScope(frozenset(), False) + lineage = _compose_scoped_lineage( + parsed.memberships, + row_id=lambda row: row.membership_row_id, + parent_id=lambda row: row.supersedes_membership_row_id, + scope=lambda row: (row.universe_id, row.security_id), + available_at=lambda row: max( + row.observation_at, + row.source_published_at, + row.retrieved_at, + ), + cutoff=evaluation.evaluation_at, + ) + universe_scope_invalid = any( + record_scope[0] == evaluation.universe_id + for record_scope in lineage.reasons_by_scope + ) + security_ids = frozenset({ + row.security_id + for row in lineage.leaves + if ( + row.universe_id == evaluation.universe_id + and row.universe_kind == expected_kind + and row.observation_at == latest_snapshot_at + and row.membership_state == "included" + and _contains( + row.effective_from, + row.effective_to, + evaluation.evaluation_at, + ) + ) + }) + return EvaluationMemberScope( + security_ids, + bool(security_ids) and not universe_scope_invalid, + ) + + +def _event_touches_member(event, member_security_ids) -> bool: + return ( + event.security_id in member_security_ids + or ( + event.event_type in SUCCESSOR_EVENT_TYPES + and event.successor_security_id in member_security_ids + ) + ) + + +def _state_event_matches_action(manifest, state_event, action) -> bool: + return ( + state_event.event_type + == RESTRICTIVE_LISTING_STATE_EVENT_TYPES[ + action.listing_state_after + ] + and state_event.security_id == action.security_id + and state_event.effective_at == action.effective_at + and state_event.listing_state_after == action.listing_state_after + and manifest.corporate_action_policy.get( + state_event.event_type + ) + == "required" + ) + + +def _event_is_applicable( + manifest, + event, + member_security_ids, + restrictive_state_actions, +) -> bool: + return ( + _event_touches_member(event, member_security_ids) + or ( + event.event_type + in RESTRICTIVE_LISTING_STATE_EVENT_TYPES.values() + and any( + _event_touches_member(action, member_security_ids) + and _state_event_matches_action( + manifest, + event, + action, + ) + for action in restrictive_state_actions + ) + ) + ) + + +def _event_decisions( + manifest, + parsed, + source_index: RecordSourceIndex, + evaluations, +) -> tuple[Decision, Decision, tuple[ExcludedRow, ...]]: + action_reasons: set[str] = set() + delisting_reasons: set[str] = set() + exclusion_reasons: dict[tuple[int, str], set[str]] = {} + listing_state_event_types = { + "delisting", + "suspension", + "reactivation", + } + + def target_reasons(event_type: str) -> set[str]: + if event_type in listing_state_event_types: + return delisting_reasons + return action_reasons + + def exclude(event, *reasons: str) -> None: + exclusion_reasons.setdefault( + ( + source_index.source_row("events", event), + event.event_row_id, + ), + set(), + ).update(reasons) + + if any( + finding.contract == "events" + and "schema_delisting_listing_state_invalid" + in finding.reason_codes + for finding in parsed.findings + ): + delisting_reasons.add("delisting_state_invalid") + + evaluations = tuple( + sorted( + evaluations, + key=lambda evaluation: ( + evaluation.evaluation_at, + evaluation.universe_id, + evaluation.evaluation_row_id, + ), + ) + ) + delisting_applicable = any( + manifest.corporate_action_policy.get(event_type) == "required" + for event_type in listing_state_event_types + ) + covered_required_event_types: set[str] = set() + required_member_scope_unresolved = False + + if not evaluations: + for event_type, state in ( + manifest.corporate_action_policy.items() + ): + if state != "required": + continue + if event_type in listing_state_event_types: + delisting_reasons.add("delisting_evidence_missing") + else: + action_reasons.add( + "corporate_action_evidence_missing" + ) + + for evaluation in evaluations: + evaluation_at = evaluation.evaluation_at + member_scope = _member_security_ids_for_evaluation( + manifest, + parsed, + evaluation, + ) + required_member_scope_unresolved = ( + required_member_scope_unresolved + or not member_scope.resolved + ) + member_security_ids = member_scope.security_ids + lineage = _compose_scoped_lineage( + parsed.events, + row_id=lambda event: event.event_row_id, + parent_id=lambda event: event.supersedes_event_row_id, + scope=lambda event: ( + event.security_id, + event.event_type, + ), + available_at=lambda event: max( + event.effective_at, + event.source_published_at, + event.retrieved_at, + ), + cutoff=evaluation_at, + ) + for reason, offenders in lineage.reason_records.items(): + for event in offenders: + target_reasons(event.event_type).add(reason) + exclude(event, reason) + leaves = lineage.leaves + + identity_lineage = _compose_scoped_lineage( + parsed.identities, + row_id=lambda row: row.identity_row_id, + parent_id=lambda row: row.supersedes_identity_row_id, + scope=lambda row: row.security_id, + available_at=lambda row: max( + row.source_published_at, + row.retrieved_at, + ), + cutoff=evaluation_at, + ) + identity_resolver = _effective_identity_resolver( + parsed.identities, + identity_lineage, + evaluation_at, + ) + identity_interval_history = identity_resolver.history + identity_transition_requirements = ( + _identity_transition_event_requirements( + identity_interval_history, + evaluation_at, + ) + ) + for identity, event_type in identity_transition_requirements: + matching_event = any( + event.event_type == event_type + and event.security_id == identity.security_id + and event.effective_at == identity.valid_from + for event in leaves + ) + if ( + manifest.corporate_action_policy.get(event_type) + != "required" + or not matching_event + ): + action_reasons.add("corporate_action_evidence_missing") + + restrictive_state_actions = tuple( + event + for event in leaves + if ( + event.event_type != "listing" + and event.event_type not in listing_state_event_types + and event.listing_state_after + in RESTRICTIVE_LISTING_STATE_EVENT_TYPES + ) + ) + + for action in restrictive_state_actions: + if not any( + _state_event_matches_action( + manifest, + event, + action, + ) + for event in leaves + ): + delisting_reasons.add("delisting_evidence_missing") + exclude(action, "delisting_evidence_missing") + + applicable_leaves = tuple( + event + for event in leaves + if _event_is_applicable( + manifest, + event, + member_security_ids, + restrictive_state_actions, + ) + ) + listing_state_by_security: dict[ + str, + tuple[str, datetime], + ] = {} + listing_state_events: dict[ + tuple[str, datetime], + list, + ] = {} + invalid_transition_ids: set[str] = set() + for event in sorted( + leaves, + key=lambda item: ( + item.security_id, + item.effective_at, + item.event_row_id, + ), + ): + reasons: set[str] = set() + policy = manifest.corporate_action_policy.get( + event.event_type + ) + if policy == "unsupported": + reasons.add("corporate_action_policy_unsupported") + elif policy == "not_applicable": + reasons.add( + "corporate_action_policy_not_applicable" + ) + allowed_listing_states = ( + EVENT_LISTING_STATE_CONTRACT[event.event_type] + ) + listing_state_valid = ( + event.listing_state_after + in allowed_listing_states + ) + if not listing_state_valid: + if event.event_type in listing_state_event_types: + reasons.add( + "delisting_state_invalid" + if event.event_type == "delisting" + else "delisting_transition_invalid" + ) + else: + reasons.add( + "corporate_action_listing_state_invalid" + ) + if event.event_type in {"split", "reverse_split"} and ( + event.ratio_numerator is None + or event.ratio_denominator is None + ): + reasons.add("corporate_action_ratio_required") + if event.event_type in SUCCESSOR_EVENT_TYPES: + predecessor_scope_invalid = ( + event.security_id + in identity_lineage.reasons_by_scope + ) or identity_resolver.security_scope_invalid( + event.security_id + ) + ( + active_predecessor_identities, + active_successor_identities, + ) = _event_identity_endpoints( + event, + identity_resolver, + ) + predecessor_identity_resolved = True + predecessor_identity_reason = "" + if ( + predecessor_scope_invalid + or len(active_predecessor_identities) > 1 + ): + predecessor_identity_resolved = False + predecessor_identity_reason = ( + "corporate_action_predecessor_identity_ambiguous" + ) + elif not active_predecessor_identities: + predecessor_identity_resolved = False + predecessor_identity_reason = ( + "corporate_action_predecessor_identity_missing" + ) + successor = event.successor_security_id + if not successor: + reasons.add( + "corporate_action_successor_required" + ) + elif successor == event.security_id: + reasons.add("corporate_action_successor_self") + elif ( + successor.casefold() + in INVALID_SUCCESSOR_IDENTIFIERS + ): + reasons.add( + "corporate_action_successor_invalid" + ) + else: + successor_scope_invalid = ( + successor + in identity_lineage.reasons_by_scope + ) or identity_resolver.security_scope_invalid( + successor + ) + if ( + successor_scope_invalid + or len(active_successor_identities) > 1 + ): + reasons.add( + "corporate_action_successor_identity_ambiguous" + ) + elif not active_successor_identities: + reasons.add( + "corporate_action_successor_identity_missing" + ) + else: + if predecessor_identity_reason: + reasons.add(predecessor_identity_reason) + if ( + event.event_type + in REPLACEMENT_SUCCESSOR_EVENT_TYPES + and event.security_id in member_security_ids + and predecessor_identity_resolved + ): + reasons.add( + "corporate_action_successor_" + "membership_inconsistent" + ) + if ( + event.event_type + in { + "listing", + "delisting", + "suspension", + "reactivation", + } + and event.listing_state_after + and listing_state_valid + ): + listing_state_events.setdefault( + (event.security_id, event.effective_at), + [], + ).append(event) + if reasons: + target_reasons(event.event_type).update(reasons) + exclude(event, *reasons) + if "delisting_transition_invalid" in reasons: + invalid_transition_ids.add(event.event_row_id) + + for ( + security_id, + effective_at, + ), simultaneous_events in sorted( + listing_state_events.items() + ): + states = { + event.listing_state_after + for event in simultaneous_events + } + if len(states) > 1 or len(simultaneous_events) > 1: + delisting_reasons.add( + "delisting_transition_invalid" + ) + for event in simultaneous_events: + exclude(event, "delisting_transition_invalid") + continue + if any( + event.event_row_id in invalid_transition_ids + for event in simultaneous_events + ): + continue + prior_listing_state = listing_state_by_security.get( + security_id + ) + allowed_prior_states = { + "listing": {None, "delisted"}, + "suspension": {None, "active"}, + "reactivation": {"suspended"}, + "delisting": {None, "active", "suspended"}, + } + invalid_transitions = tuple( + event + for event in simultaneous_events + if ( + ( + None + if prior_listing_state is None + else prior_listing_state[0] + ) + not in allowed_prior_states[event.event_type] + or ( + prior_listing_state is not None + and prior_listing_state[1] >= effective_at + ) + ) + ) + if invalid_transitions: + delisting_reasons.add( + "delisting_transition_invalid" + ) + for event in invalid_transitions: + exclude(event, "delisting_transition_invalid") + continue + listing_state_by_security[security_id] = ( + next(iter(states)), + effective_at, + ) + + for event_type, state in ( + manifest.corporate_action_policy.items() + ): + if state != "required": + continue + if member_scope.resolved and any( + event.event_type == event_type + and _event_is_applicable( + manifest, + event, + member_security_ids, + restrictive_state_actions, + ) + for event in leaves + ): + covered_required_event_types.add(event_type) + delisting_applicable = ( + delisting_applicable + or bool(restrictive_state_actions) + or any( + event.event_type in listing_state_event_types + for event in applicable_leaves + ) + ) + + if evaluations: + for event_type, state in ( + manifest.corporate_action_policy.items() + ): + if ( + state != "required" + or ( + not required_member_scope_unresolved + and event_type in covered_required_event_types + ) + ): + continue + if event_type in listing_state_event_types: + delisting_reasons.add("delisting_evidence_missing") + else: + action_reasons.add( + "corporate_action_evidence_missing" + ) + + if manifest.delisting_policy.get("retain_historical_members") is not True: + delisting_reasons.add("delisting_survivorship_policy_invalid") + if ( + manifest.survivorship_policy.get("filter_by_current_listing_state") + is not False + ): + delisting_reasons.add("delisting_survivorship_policy_invalid") + excluded = tuple( + ExcludedRow( + "events", + source_row, + row_id, + tuple(sorted(reasons)), + ) + for ( + source_row, + row_id, + ), reasons in sorted(exclusion_reasons.items()) + ) + return ( + Decision( + "corporate_action_coverage", + "blocked" if action_reasons else "passed", + tuple(sorted(action_reasons)), + ), + Decision( + "delisting_coverage", + "blocked" + if delisting_reasons + else "passed" + if delisting_applicable + else "not_applicable", + tuple(sorted(delisting_reasons)), + ), + excluded, + ) + + +def _raw_required_rights_scope(row) -> tuple[str, ...] | None: + if row.contract == "security_identity": + return ("security_identity",) + if row.contract == "membership": + return ("universe_membership",) + if row.contract != "events": + return () + event_type = row.values.get("event_type", "") + if event_type == "delisting": + return ("delistings",) + if event_type in {"suspension", "reactivation"}: + return ("corporate_actions", "delistings") + if event_type in EVENT_TYPES: + return ("corporate_actions",) + return None + + +def _rights_decision( + manifest, + parsed, + registry, +) -> tuple[Decision, tuple[ExcludedRow, ...]]: + blockers: set[str] = set() + row_blockers: dict[tuple[str, int, str], set[str]] = {} + + def block(row, reason: str) -> None: + blockers.add(reason) + row_id = row.values.get(ROW_ID_FIELDS[row.contract], "") + row_blockers.setdefault( + (row.contract, row.source_row, row_id), + set(), + ).add(reason) + + source_rows = tuple( + row + for row in parsed.raw + if row.contract in {"security_identity", "membership", "events"} + ) + for row in source_rows: + source_id = row.values.get("source_id", RAW_MISSING_CELL) + if source_id == "": + block(row, "source_rights_source_missing") + continue + if ( + source_id == RAW_MISSING_CELL + or source_id != source_id.strip() + or not is_control_free(source_id) + ): + block(row, "source_rights_source_unreadable") + continue + if source_id not in manifest.allowed_source_ids: + block(row, "source_rights_source_not_allowed") + required_scope = _raw_required_rights_scope(row) + if required_scope is None: + block(row, "source_rights_event_scope_unreadable") + required_scope = () + review = review_commercial_field_scope( + registry, + source_id, + required_scope, + ) + if not review.commercial_rights_approved: + block(row, f"source_rights_{review.rights_status}") + if review.missing_supported_fields: + block(row, "source_rights_field_scope_missing") + + raw_locations = { + (row.contract, row.source_row) + for row in source_rows + } + for finding in parsed.findings: + if ( + finding.contract + in {"security_identity", "membership", "events"} + and "schema_columns_invalid" in finding.reason_codes + and (finding.contract, finding.source_row) + not in raw_locations + ): + blockers.add("source_rights_source_unreadable") + row_blockers.setdefault( + ( + finding.contract, + finding.source_row, + finding.row_id, + ), + set(), + ).add("source_rights_source_unreadable") + + return ( + Decision( + "source_rights_eligibility", + "blocked" if blockers else "passed", + tuple(sorted(blockers)), + ), + tuple( + ExcludedRow( + contract, + source_row, + row_id, + tuple(sorted(reasons)), + ) + for ( + contract, + source_row, + row_id, + ), reasons in sorted(row_blockers.items()) + ), + ) + + +def _row_reference( + source_index: RecordSourceIndex, + contract: str, + record, + *, + evaluation_row_id: str = "", +) -> RowReference: + return RowReference( + contract, + source_index.source_row(contract, record), + _record_row_id(contract, record), + evaluation_row_id, + ) + + +def _complete_row_references( + parsed: ParsedUniverseEvidence, +) -> tuple[tuple[RowReference, ...], tuple[RowReference, ...]]: + raw_rows = tuple( + RowReference( + row.contract, + row.source_row, + row.values.get(ROW_ID_FIELDS[row.contract], ""), + ) + for row in parsed.raw + ) + finding_rows = { + (finding.contract, finding.source_row) + for finding in parsed.findings + } + normalized_rows = tuple( + row + for row in raw_rows + if (row.contract, row.source_row) not in finding_rows + ) + return raw_rows, normalized_rows + + +def _analysis_row_references( + manifest: UniverseManifest, + parsed: ParsedUniverseEvidence, + source_index: RecordSourceIndex, + evaluations, +) -> tuple[RowReference, ...]: + references: set[RowReference] = set() + declared = { + item["universe_id"]: item["universe_kind"] + for item in manifest.declared_universes + } + for evaluation in sorted( + evaluations, + key=lambda row: ( + row.evaluation_at, + row.universe_id, + row.evaluation_row_id, + ), + ): + evaluation_row_id = evaluation.evaluation_row_id + references.add( + _row_reference( + source_index, + "evaluations", + evaluation, + evaluation_row_id=evaluation_row_id, + ) + ) + membership_lineage = _compose_scoped_lineage( + parsed.memberships, + row_id=lambda row: row.membership_row_id, + parent_id=lambda row: row.supersedes_membership_row_id, + scope=lambda row: (row.universe_id, row.security_id), + available_at=lambda row: max( + row.observation_at, + row.source_published_at, + row.retrieved_at, + ), + cutoff=evaluation.evaluation_at, + ) + identity_lineage = _compose_scoped_lineage( + parsed.identities, + row_id=lambda row: row.identity_row_id, + parent_id=lambda row: row.supersedes_identity_row_id, + scope=lambda row: row.security_id, + available_at=lambda row: max( + row.source_published_at, + row.retrieved_at, + ), + cutoff=evaluation.evaluation_at, + ) + if ( + membership_lineage.reason_codes + or identity_lineage.reason_codes + ): + return () + identity_resolver = _effective_identity_resolver( + parsed.identities, + identity_lineage, + evaluation.evaluation_at, + ) + if identity_resolver.invalid_scopes: + return () + + cutoff_memberships = tuple( + row + for row in parsed.memberships + if ( + row.universe_id == evaluation.universe_id + and max( + row.observation_at, + row.source_published_at, + row.retrieved_at, + ) + <= evaluation.evaluation_at + ) + ) + latest_snapshot_at = max( + (row.observation_at for row in cutoff_memberships), + default=None, + ) + expected_kind = declared.get(evaluation.universe_id) + member_leaves = tuple( + row + for row in membership_lineage.leaves + if ( + row.universe_id == evaluation.universe_id + and row.universe_kind == expected_kind + and row.observation_at == latest_snapshot_at + and row.membership_state == "included" + and _contains( + row.effective_from, + row.effective_to, + evaluation.evaluation_at, + ) + ) + ) + member_security_ids: set[str] = set() + for member in member_leaves: + active_identities = identity_resolver.active_at( + member.security_id, + evaluation.evaluation_at, + ) + if len(active_identities) != 1: + return () + active_identity = active_identities[0] + member_security_ids.add(member.security_id) + references.add( + _row_reference( + source_index, + "membership", + member, + evaluation_row_id=evaluation_row_id, + ) + ) + references.add( + _row_reference( + source_index, + "security_identity", + active_identity, + evaluation_row_id=evaluation_row_id, + ) + ) + + event_lineage = _compose_scoped_lineage( + parsed.events, + row_id=lambda event: event.event_row_id, + parent_id=lambda event: event.supersedes_event_row_id, + scope=lambda event: ( + event.security_id, + event.event_type, + ), + available_at=lambda event: max( + event.effective_at, + event.source_published_at, + event.retrieved_at, + ), + cutoff=evaluation.evaluation_at, + ) + if event_lineage.reason_codes: + return () + restrictive_state_actions = tuple( + event + for event in event_lineage.leaves + if ( + event.event_type != "listing" + and event.event_type + not in RESTRICTIVE_LISTING_STATE_EVENT_TYPES.values() + and event.listing_state_after + in RESTRICTIVE_LISTING_STATE_EVENT_TYPES + ) + ) + for event in event_lineage.leaves: + if not _event_is_applicable( + manifest, + event, + member_security_ids, + restrictive_state_actions, + ): + continue + references.add( + _row_reference( + source_index, + "events", + event, + evaluation_row_id=evaluation_row_id, + ) + ) + predecessors, successors = _event_identity_endpoints( + event, + identity_resolver, + ) + if event.event_type in SUCCESSOR_EVENT_TYPES and ( + len(predecessors) != 1 + or len(successors) != 1 + ): + return () + for endpoint_identity in predecessors + successors: + references.add( + _row_reference( + source_index, + "security_identity", + endpoint_identity, + evaluation_row_id=evaluation_row_id, + ) + ) + + return tuple( + sorted( + references, + key=lambda row: ( + row.evaluation_row_id, + row.contract, + row.source_row, + row.row_id, + ), + ) + ) + + +def validate_point_in_time_universe( + manifest_path: Path, + registry_path: Path, + *, + top_n: int = 20, +) -> PointInTimeUniversePacket: + _validate_top_n(top_n) + + package = load_universe_package(manifest_path, registry_path) + parsed = parse_universe_evidence(package) + source_index = _build_record_source_index(parsed) + decisions: dict[str, Decision] = {} + excluded: list[ExcludedRow] = [ + ExcludedRow( + finding.contract, + finding.source_row, + finding.row_id, + finding.reason_codes, + ) + for finding in parsed.findings + ] + decisions["manifest_integrity"] = Decision( + "manifest_integrity", + "passed", + (), + ) + decisions["technical_validity"] = Decision( + "technical_validity", + "blocked" if parsed.findings else "passed", + tuple( + sorted( + { + code + for finding in parsed.findings + for code in finding.reason_codes + } + ) + ), + ) + ( + valid_evaluations, + evaluation_reasons, + evaluation_global_reasons, + ) = _classify_evaluations( + package.manifest, + parsed.evaluations, + ) + identity, membership, digests, display, composed_excluded = ( + _identity_membership_decisions( + package.manifest, + parsed, + source_index, + valid_evaluations, + evaluation_reasons, + ) + ) + decisions[identity.area] = identity + decisions[membership.area] = membership + excluded.extend(composed_excluded) + temporal, cutoff_leakage, temporal_excluded = _temporal_decision( + package.manifest, + parsed, + source_index, + valid_evaluations, + evaluation_reasons, + ) + decisions[temporal.area] = temporal + excluded.extend(temporal_excluded) + corporate_action, delisting, event_excluded = _event_decisions( + package.manifest, + parsed, + source_index, + valid_evaluations, + ) + source_rights, rights_excluded = _rights_decision( + package.manifest, + parsed, + parse_source_rights_registry(package.registry_snapshot), + ) + decisions[corporate_action.area] = corporate_action + decisions[delisting.area] = delisting + decisions[source_rights.area] = source_rights + excluded.extend(event_excluded) + excluded.extend(rights_excluded) + reproduction = _reproduction_decision( + package.manifest, + digests, + valid_evaluations, + ) + decisions[reproduction.area] = reproduction + leakage, partition_excluded = _partition_validation( + package.manifest, + parsed.evaluations, + cutoff_leakage, + parsed, + source_index=source_index, + evaluation_reasons=evaluation_reasons, + evaluation_global_reasons=evaluation_global_reasons, + ) + decisions[leakage.area] = leakage + excluded.extend(partition_excluded) + ordered_decisions = MappingProxyType( + { + name: decisions[name] + for name in DECISION_ORDER + } + ) + canonical_exclusion_reasons: dict[ + tuple[str, int, str], + set[str], + ] = {} + for item in excluded: + canonical_exclusion_reasons.setdefault( + (item.contract, item.source_row, item.row_id), + set(), + ).update(item.reason_codes) + canonical_excluded = tuple( + ExcludedRow( + contract, + source_row, + row_id, + tuple(sorted(reason_codes)), + ) + for ( + contract, + source_row, + row_id, + ), reason_codes in sorted(canonical_exclusion_reasons.items()) + ) + raw_rows, normalized_rows = _complete_row_references(parsed) + exclusion_reason_counts = MappingProxyType( + dict( + sorted( + Counter( + reason + for item in canonical_excluded + for reason in item.reason_codes + ).items() + ) + ) + ) + analysis_eligible = _final_eligibility( + ordered_decisions, + digests, + package.manifest.declared_universes, + ) + analysis_eligible_rows = ( + _analysis_row_references( + package.manifest, + parsed, + source_index, + valid_evaluations, + ) + if analysis_eligible + else () + ) + + return PointInTimeUniversePacket( + dataset_id=package.manifest.dataset_id, + manifest_id=package.manifest.manifest_id, + analysis_eligible=analysis_eligible, + decisions=ordered_decisions, + raw_count=len(raw_rows), + normalized_count=len(normalized_rows), + raw_rows=raw_rows, + normalized_rows=normalized_rows, + excluded=canonical_excluded, + excluded_count=len(canonical_excluded), + exclusion_reason_counts=exclusion_reason_counts, + analysis_eligible_rows=analysis_eligible_rows, + analysis_eligible_row_count=len(analysis_eligible_rows), + membership_digests=digests, + display_tickers=display, + boundary=( + "Local evidence eligibility only; no readiness, backtest, probability, " + "recommendation, or trading activation." + ), + ) + + +def render_status(packet: PointInTimeUniversePacket) -> str: + token = escape_structural_token + lines = [ + "Point-in-Time Universe Status", + ( + "Read-only: validates one supplied immutable package; it does not " + "fetch, write, apply, refresh, or rebuild data." + ), + ( + "Research-only: this does not activate readiness, backtesting, " + "calibration, or probability and is not investment advice." + ), + ( + "Synthetic or technically valid packages are local software " + "evidence only." + ), + ( + "Priority 4 still requires one independently reviewed, permitted " + "real dataset." + ), + f"dataset_id: {token(packet.dataset_id)}", + f"manifest_id: {token(packet.manifest_id)}", + f"analysis_eligible: {str(packet.analysis_eligible).lower()}", + f"raw_count: {packet.raw_count}", + f"normalized_count: {packet.normalized_count}", + f"excluded_count: {packet.excluded_count}", + ( + "analysis_eligible_row_count: " + f"{packet.analysis_eligible_row_count}" + ), + ] + lines.extend( + ( + f"{name}: {token(packet.decisions[name].status)}; " + "reasons=" + f"{','.join(token(reason) for reason in packet.decisions[name].reason_codes) or 'none'}" + ) + for name in DECISION_ORDER + ) + lines.append(f"boundary: {token(packet.boundary)}") + return "\n".join(lines) + + +def render_preview( + packet: PointInTimeUniversePacket, + *, + top_n: int = 20, +) -> str: + _validate_top_n(top_n) + token = escape_structural_token + + lines = [render_status(packet), "", "Membership reproduction:"] + lines.extend( + ( + f"- {token(item.universe_id)} @ {token(item.evaluation_at)}: " + f"members={item.member_count}; sha256={token(item.sha256)}" + ) + for item in packet.membership_digests + ) + lines.append("Exclusion reason counts:") + if packet.exclusion_reason_counts: + lines.extend( + f"- {token(reason)}: {count}" + for reason, count in packet.exclusion_reason_counts.items() + ) + else: + lines.append("- none") + lines.append("Excluded sample:") + lines.extend( + ( + f"- {token(item.contract)}:{item.source_row}:" + f"{token(item.row_id)}; " + f"reasons={','.join(token(reason) for reason in item.reason_codes)}" + ) + for item in packet.excluded[:top_n] + ) + return "\n".join(lines) + + +def main(argv: list[str] | None = None) -> int: + import argparse + import csv + + import yaml + + parser = argparse.ArgumentParser( + description="Validate one immutable point-in-time universe package." + ) + parser.add_argument("mode", choices=("status", "preview")) + parser.add_argument("--manifest", type=Path) + parser.add_argument( + "--registry", + type=Path, + default=Path("config/source_rights.yml"), + ) + parser.add_argument("--top-n", type=int, default=20) + args = parser.parse_args(argv) + if args.manifest is None: + parser.error("MANIFEST is required") + if args.top_n < 0 or args.top_n > MAX_PREVIEW_EXCLUSION_ROWS: + parser.error("top_n_invalid") + + try: + packet = validate_point_in_time_universe( + args.manifest, + args.registry, + top_n=args.top_n, + ) + except (csv.Error, OSError, ValueError, yaml.YAMLError) as exc: + parser.error(str(exc)) + except RuntimeError as exc: + if "Symlink loop" not in str(exc): + raise + parser.error(str(exc)) + + output = ( + render_preview(packet, top_n=args.top_n) + if args.mode == "preview" + else render_status(packet) + ) + print(output) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/point_in_time_universe_contracts.py b/src/point_in_time_universe_contracts.py new file mode 100644 index 00000000..50b2c61b --- /dev/null +++ b/src/point_in_time_universe_contracts.py @@ -0,0 +1,477 @@ +from __future__ import annotations + +import csv +import io +import math +from dataclasses import dataclass +from datetime import datetime, timezone +from types import MappingProxyType +from typing import Mapping + +from src.point_in_time_universe_identifiers import require_control_free +from src.point_in_time_universe_manifest import ( + RFC3339_UTC, + LoadedUniversePackage, +) + + +EVENT_TYPES = frozenset({ + "listing", "ticker_change", "exchange_change", "split", "reverse_split", "merger", + "acquisition", "spinoff", "delisting", "suspension", "reactivation", +}) +LISTING_STATES = frozenset({"", "active", "delisted", "suspended"}) +PARTITIONS = frozenset({"train", "validation", "test", "walk_forward"}) +RAW_MISSING_CELL = "__missing_csv_cell__" +RAW_SURPLUS_CELL_PREFIX = "__surplus_cell_" + + +@dataclass(frozen=True) +class RawEvidenceRow: + contract: str + source_file: str + source_row: int + values: Mapping[str, str] + + +@dataclass(frozen=True) +class ContractFinding: + contract: str + source_row: int + row_id: str + reason_codes: tuple[str, ...] + + +@dataclass(frozen=True) +class IdentityObservation: + identity_row_id: str + security_id: str + issuer_id: str + ticker: str + exchange: str + security_type: str + currency: str + valid_from: datetime + valid_to: datetime | None + source_id: str + source_ref: str + source_published_at: datetime + retrieved_at: datetime + supersedes_identity_row_id: str + + +@dataclass(frozen=True) +class MembershipObservation: + membership_row_id: str + universe_id: str + universe_kind: str + security_id: str + membership_state: str + effective_from: datetime + effective_to: datetime | None + observation_at: datetime + source_id: str + source_ref: str + source_published_at: datetime + retrieved_at: datetime + supersedes_membership_row_id: str + + +@dataclass(frozen=True) +class UniverseEvent: + event_row_id: str + security_id: str + event_type: str + effective_at: datetime + successor_security_id: str + ratio_numerator: float | None + ratio_denominator: float | None + listing_state_after: str + source_id: str + source_ref: str + source_published_at: datetime + retrieved_at: datetime + supersedes_event_row_id: str + + +@dataclass(frozen=True) +class EvaluationObservation: + evaluation_row_id: str + universe_id: str + evaluation_at: datetime + available_at: datetime + partition: str + source_ref: str + + +@dataclass(frozen=True) +class ParsedUniverseEvidence: + raw: tuple[RawEvidenceRow, ...] + identities: tuple[IdentityObservation, ...] + memberships: tuple[MembershipObservation, ...] + events: tuple[UniverseEvent, ...] + evaluations: tuple[EvaluationObservation, ...] + findings: tuple[ContractFinding, ...] + + +def parse_utc(value: str) -> datetime: + if not isinstance(value, str) or not RFC3339_UTC.fullmatch(value): + raise ValueError("schema_timestamp_invalid") + try: + parsed = datetime.fromisoformat(value[:-1] + "+00:00") + except ValueError as exc: + raise ValueError("schema_timestamp_invalid") from exc + if parsed.tzinfo != timezone.utc: + raise ValueError("schema_timestamp_invalid") + return parsed + + +def optional_utc(value: str) -> datetime | None: + return None if value == "" else parse_utc(value) + + +def optional_positive_float(value: str) -> float | None: + if not str(value or "").strip(): + return None + parsed = float(value) + if not math.isfinite(parsed) or parsed <= 0: + raise ValueError("schema_ratio_invalid") + return parsed + + +IDENTITY_COLUMNS = ( + "identity_row_id", "security_id", "issuer_id", "ticker", "exchange", + "security_type", "currency", "valid_from", "valid_to", "source_id", + "source_ref", "source_published_at", "retrieved_at", "supersedes_identity_row_id", +) +MEMBERSHIP_COLUMNS = ( + "membership_row_id", "universe_id", "universe_kind", "security_id", + "membership_state", "effective_from", "effective_to", "observation_at", + "source_id", "source_ref", "source_published_at", "retrieved_at", + "supersedes_membership_row_id", +) +EVENT_COLUMNS = ( + "event_row_id", "security_id", "event_type", "effective_at", "successor_security_id", + "ratio_numerator", "ratio_denominator", "listing_state_after", "source_id", + "source_ref", "source_published_at", "retrieved_at", "supersedes_event_row_id", +) +EVALUATION_COLUMNS = ( + "evaluation_row_id", "universe_id", "evaluation_at", "available_at", "partition", + "source_ref", +) +COLUMNS = { + "security_identity": IDENTITY_COLUMNS, + "membership": MEMBERSHIP_COLUMNS, + "events": EVENT_COLUMNS, + "evaluations": EVALUATION_COLUMNS, +} +ROW_ID_FIELDS = { + "security_identity": "identity_row_id", + "membership": "membership_row_id", + "events": "event_row_id", + "evaluations": "evaluation_row_id", +} +STRUCTURAL_IDENTIFIER_FIELDS = frozenset({ + "identity_row_id", + "security_id", + "issuer_id", + "ticker", + "exchange", + "security_type", + "currency", + "source_id", + "source_ref", + "supersedes_identity_row_id", + "membership_row_id", + "universe_id", + "universe_kind", + "membership_state", + "supersedes_membership_row_id", + "event_row_id", + "event_type", + "successor_security_id", + "listing_state_after", + "supersedes_event_row_id", + "evaluation_row_id", + "partition", +}) + + +def _required( + row: Mapping[str, str], + *names: str, + display_normalized: frozenset[str] = frozenset(), +) -> tuple[str, ...]: + values = tuple(row.get(name, "") for name in names) + for name, value in zip(names, values, strict=True): + if not value or (name in display_normalized and not value.strip()): + raise ValueError("schema_required_field_missing") + if name not in display_normalized and value != value.strip(): + raise ValueError("schema_whitespace_invalid") + if name in STRUCTURAL_IDENTIFIER_FIELDS: + require_control_free( + value, + "schema_identifier_control_character", + ) + return values + + +def _optional_opaque(row: Mapping[str, str], name: str) -> str: + value = row[name] + if value and value != value.strip(): + raise ValueError("schema_whitespace_invalid") + require_control_free( + value, + "schema_identifier_control_character", + ) + return value + + +def _has_exact_row_shape(values: Mapping[object, object], columns: tuple[str, ...]) -> bool: + return ( + tuple(values) == columns + and all(isinstance(key, str) and isinstance(values[key], str) for key in columns) + ) + + +def _raw_values(values: Mapping[object, object], columns: tuple[str, ...]) -> Mapping[str, str]: + raw = { + column: values[column] if isinstance(values.get(column), str) else RAW_MISSING_CELL + for column in columns + } + surplus = values.get(None, ()) + if isinstance(surplus, list): + for index, value in enumerate(surplus): + key = f"{RAW_SURPLUS_CELL_PREFIX}{index}__" + while key in raw: + key = f"_{key}" + raw[key] = value if isinstance(value, str) else RAW_MISSING_CELL + return MappingProxyType(raw) + + +def _require_publication_before_retrieval( + source_published_at: datetime, + retrieved_at: datetime, +) -> None: + if retrieved_at < source_published_at: + raise ValueError("schema_retrieved_before_publication") + + +def _parse_identity(row: Mapping[str, str]) -> IdentityObservation: + required = _required( + row, "identity_row_id", "security_id", "issuer_id", "ticker", "exchange", + "security_type", "currency", "valid_from", "source_id", "source_ref", + "source_published_at", "retrieved_at", + display_normalized=frozenset({"ticker"}), + ) + if required[1] == required[2]: + raise ValueError("schema_identity_issuer_matches_security") + observation = IdentityObservation( + identity_row_id=required[0], security_id=required[1], issuer_id=required[2], + ticker=required[3].strip().upper(), exchange=required[4], security_type=required[5], + currency=required[6], valid_from=parse_utc(required[7]), + valid_to=optional_utc(row["valid_to"]), source_id=required[8], source_ref=required[9], + source_published_at=parse_utc(required[10]), retrieved_at=parse_utc(required[11]), + supersedes_identity_row_id=_optional_opaque(row, "supersedes_identity_row_id"), + ) + if ( + observation.valid_to is not None + and observation.valid_to <= observation.valid_from + ): + raise ValueError("schema_identity_interval_reversed") + _require_publication_before_retrieval( + observation.source_published_at, + observation.retrieved_at, + ) + return observation + + +def _parse_membership(row: Mapping[str, str]) -> MembershipObservation: + required = _required( + row, "membership_row_id", "universe_id", "universe_kind", "security_id", + "membership_state", "effective_from", "observation_at", "source_id", "source_ref", + "source_published_at", "retrieved_at", + ) + if required[2] not in {"benchmark", "research_universe"}: + raise ValueError("schema_enum_invalid") + if required[4] not in {"included", "excluded"}: + raise ValueError("schema_enum_invalid") + observation = MembershipObservation( + membership_row_id=required[0], universe_id=required[1], universe_kind=required[2], + security_id=required[3], membership_state=required[4], + effective_from=parse_utc(required[5]), effective_to=optional_utc(row["effective_to"]), + observation_at=parse_utc(required[6]), source_id=required[7], source_ref=required[8], + source_published_at=parse_utc(required[9]), retrieved_at=parse_utc(required[10]), + supersedes_membership_row_id=_optional_opaque(row, "supersedes_membership_row_id"), + ) + if ( + observation.effective_to is not None + and observation.effective_to <= observation.effective_from + ): + raise ValueError("schema_membership_interval_reversed") + _require_publication_before_retrieval( + observation.source_published_at, + observation.retrieved_at, + ) + return observation + + +def _parse_event(row: Mapping[str, str]) -> UniverseEvent: + required = _required( + row, "event_row_id", "security_id", "event_type", "effective_at", "source_id", + "source_ref", "source_published_at", "retrieved_at", + ) + listing_state_after = require_control_free( + row["listing_state_after"], + "schema_identifier_control_character", + ) + if required[2] not in EVENT_TYPES or listing_state_after not in LISTING_STATES: + raise ValueError("schema_enum_invalid") + try: + numerator = optional_positive_float(row["ratio_numerator"]) + denominator = optional_positive_float(row["ratio_denominator"]) + except (TypeError, ValueError) as exc: + raise ValueError("schema_ratio_invalid") from exc + if (numerator is None) != (denominator is None): + raise ValueError("schema_ratio_pair_required") + if required[2] in {"split", "reverse_split"} and numerator is None: + raise ValueError("schema_ratio_pair_required") + if required[2] == "delisting" and listing_state_after != "delisted": + raise ValueError("schema_delisting_listing_state_invalid") + observation = UniverseEvent( + event_row_id=required[0], security_id=required[1], event_type=required[2], + effective_at=parse_utc(required[3]), + successor_security_id=_optional_opaque(row, "successor_security_id"), ratio_numerator=numerator, + ratio_denominator=denominator, listing_state_after=listing_state_after, + source_id=required[4], source_ref=required[5], + source_published_at=parse_utc(required[6]), retrieved_at=parse_utc(required[7]), + supersedes_event_row_id=_optional_opaque(row, "supersedes_event_row_id"), + ) + _require_publication_before_retrieval( + observation.source_published_at, + observation.retrieved_at, + ) + return observation + + +def _parse_evaluation(row: Mapping[str, str]) -> EvaluationObservation: + required = _required( + row, "evaluation_row_id", "universe_id", "evaluation_at", "available_at", "partition", + "source_ref", + ) + if required[4] not in PARTITIONS: + raise ValueError("schema_enum_invalid") + return EvaluationObservation( + evaluation_row_id=required[0], universe_id=required[1], + evaluation_at=parse_utc(required[2]), available_at=parse_utc(required[3]), + partition=required[4], source_ref=required[5], + ) + + +PARSERS = { + "security_identity": _parse_identity, + "membership": _parse_membership, + "events": _parse_event, + "evaluations": _parse_evaluation, +} + + +def parse_universe_evidence(package: LoadedUniversePackage) -> ParsedUniverseEvidence: + raw_rows: list[RawEvidenceRow] = [] + parsed: dict[str, list] = {name: [] for name in COLUMNS} + findings: list[ContractFinding] = [] + for contract in ("security_identity", "membership", "events", "evaluations"): + path = package.files[contract] + snapshot = package.contract_snapshots[contract] + with io.StringIO(snapshot.decode("utf-8"), newline="") as handle: + reader = csv.DictReader(handle) + if tuple(reader.fieldnames or ()) != COLUMNS[contract]: + raise ValueError("package_csv_columns_invalid") + for source_row, values in enumerate(reader, start=2): + clean = _raw_values(values, COLUMNS[contract]) + raw_rows.append(RawEvidenceRow( + contract, + path.relative_to(package.manifest_path.parent).as_posix(), + source_row, + clean, + )) + row_id = clean.get(ROW_ID_FIELDS[contract], "") + if not _has_exact_row_shape(values, COLUMNS[contract]): + findings.append(ContractFinding( + contract, + source_row, + row_id, + ("schema_columns_invalid",), + )) + continue + try: + parsed[contract].append(PARSERS[contract](clean)) + except (KeyError, TypeError, ValueError) as exc: + reason = str(exc) + if not reason.startswith("schema_"): + reason = "schema_value_invalid" + findings.append(ContractFinding(contract, source_row, row_id, (reason,))) + event_rows_by_id: dict[str, list[int]] = {} + evaluation_rows_by_id: dict[str, list[int]] = {} + for row in raw_rows: + if row.contract == "events": + row_id = row.values.get("event_row_id", "") + row_numbers = event_rows_by_id + elif row.contract == "evaluations": + row_id = row.values.get("evaluation_row_id", "") + row_numbers = evaluation_rows_by_id + else: + continue + if row_id and row_id != RAW_MISSING_CELL: + row_numbers.setdefault(row_id, []).append( + row.source_row + ) + duplicate_event_ids = { + row_id + for row_id, source_rows in event_rows_by_id.items() + if len(source_rows) > 1 + } + if duplicate_event_ids: + parsed["events"] = [ + row + for row in parsed["events"] + if row.event_row_id not in duplicate_event_ids + ] + findings.extend( + ContractFinding( + "events", + source_row, + row_id, + ("schema_event_row_id_duplicate",), + ) + for row_id in sorted(duplicate_event_ids) + for source_row in event_rows_by_id[row_id] + ) + duplicate_evaluation_ids = { + row_id + for row_id, source_rows in evaluation_rows_by_id.items() + if len(source_rows) > 1 + } + if duplicate_evaluation_ids: + parsed["evaluations"] = [ + row + for row in parsed["evaluations"] + if row.evaluation_row_id not in duplicate_evaluation_ids + ] + findings.extend( + ContractFinding( + "evaluations", + source_row, + row_id, + ("schema_evaluation_row_id_duplicate",), + ) + for row_id in sorted(duplicate_evaluation_ids) + for source_row in evaluation_rows_by_id[row_id] + ) + return ParsedUniverseEvidence( + raw=tuple(raw_rows), + identities=tuple(parsed["security_identity"]), + memberships=tuple(parsed["membership"]), + events=tuple(parsed["events"]), + evaluations=tuple(parsed["evaluations"]), + findings=tuple(findings), + ) diff --git a/src/point_in_time_universe_identifiers.py b/src/point_in_time_universe_identifiers.py new file mode 100644 index 00000000..65dd8645 --- /dev/null +++ b/src/point_in_time_universe_identifiers.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import unicodedata + + +UNSAFE_STRUCTURAL_CATEGORIES = frozenset({"Cs", "Zl", "Zp"}) + + +def _unsafe_structural_character(character: str) -> bool: + codepoint = ord(character) + return ( + codepoint < 0x20 + or 0x7F <= codepoint <= 0x9F + or unicodedata.category(character) + in UNSAFE_STRUCTURAL_CATEGORIES + ) + + +def is_control_free(value: object) -> bool: + """Reject controls, record separators, and non-scalar surrogates.""" + + return isinstance(value, str) and not any( + _unsafe_structural_character(character) + for character in value + ) + + +def require_control_free(value: object, reason: str) -> str: + if not is_control_free(value): + raise ValueError(reason) + return value + + +def escape_structural_token(value: object) -> str: + if not isinstance(value, str): + raise ValueError("render_structural_token_invalid") + return "".join( + ( + f"\\u{ord(character):04x}" + if _unsafe_structural_character(character) + else character + ) + for character in value + ) diff --git a/src/point_in_time_universe_lineage.py b/src/point_in_time_universe_lineage.py new file mode 100644 index 00000000..7a0594e6 --- /dev/null +++ b/src/point_in_time_universe_lineage.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import Callable, Generic, Iterable, TypeVar + + +T = TypeVar("T") + + +@dataclass(frozen=True) +class LineageResult(Generic[T]): + leaves: tuple[T, ...] + excluded: tuple[T, ...] + reason_codes: tuple[str, ...] + + +def resolve_lineage( + records: Iterable[T], + *, + row_id: Callable[[T], str], + parent_id: Callable[[T], str], + scope: Callable[[T], str], + available_at: Callable[[T], datetime], + cutoff: datetime, +) -> LineageResult[T]: + eligible = tuple(record for record in records if available_at(record) <= cutoff) + ids = [row_id(record) for record in eligible] + reasons: set[str] = set() + if len(ids) != len(set(ids)): + reasons.add("lineage_duplicate_id") + + by_id = {row_id(record): record for record in eligible} + children: dict[str, list[T]] = {} + roots: dict[str, list[T]] = {} + for record in eligible: + parent = parent_id(record) + record_scope = scope(record) + if not parent: + roots.setdefault(record_scope, []).append(record) + continue + prior = by_id.get(parent) + if prior is None: + reasons.add("lineage_missing_parent") + continue + if scope(prior) != record_scope: + reasons.add("lineage_cross_scope_parent") + if available_at(record) <= available_at(prior): + reasons.add("lineage_order_reversed") + children.setdefault(parent, []).append(record) + + if any(len(value) > 1 for value in roots.values()): + reasons.add("lineage_multiple_roots") + if any(len(value) > 1 for value in children.values()): + reasons.add("lineage_fork") + + for start in eligible: + seen: set[str] = set() + current = start + while parent_id(current): + current_id = row_id(current) + if current_id in seen: + reasons.add("lineage_cycle") + break + seen.add(current_id) + parent = by_id.get(parent_id(current)) + if parent is None: + break + current = parent + + if reasons: + return LineageResult((), eligible, tuple(sorted(reasons))) + + leaves = tuple(record for record in eligible if row_id(record) not in children) + return LineageResult( + leaves, + tuple(record for record in eligible if record not in leaves), + (), + ) diff --git a/src/point_in_time_universe_manifest.py b/src/point_in_time_universe_manifest.py new file mode 100644 index 00000000..22357e6d --- /dev/null +++ b/src/point_in_time_universe_manifest.py @@ -0,0 +1,843 @@ +from __future__ import annotations + +import csv +import hashlib +import io +import json +import os +import re +import stat +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from types import MappingProxyType +from typing import Any, Mapping + +from src.point_in_time_universe_identifiers import is_control_free + + +REQUIRED_CONTRACTS = frozenset({"security_identity", "membership", "events", "evaluations"}) +ALLOWED_COVERAGE_SEMANTICS = frozenset({"complete_snapshot", "event_history"}) +ALLOWED_UNIVERSE_KINDS = frozenset({"benchmark", "research_universe"}) +ALLOWED_EVENT_TYPES = frozenset({ + "listing", "ticker_change", "exchange_change", "split", "reverse_split", "merger", + "acquisition", "spinoff", "delisting", "suspension", "reactivation", +}) +ALLOWED_ACTION_POLICY_STATES = frozenset({"required", "not_applicable", "unsupported"}) +REPRODUCTION_CONTRACT = "membership_count_and_sha256_at_cutoff_v1" +RFC3339_UTC = re.compile( + r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$" +) + +# Local safeguards for one supplied package. These are not coverage, scale, +# hosted-reliability, or commercial-capacity claims. +MAX_MANIFEST_BYTES = 1 * 1024 * 1024 +MAX_CONTRACT_SNAPSHOT_BYTES = 32 * 1024 * 1024 +MAX_TOTAL_CONTRACT_SNAPSHOT_BYTES = 64 * 1024 * 1024 +MAX_RIGHTS_REGISTRY_BYTES = 4 * 1024 * 1024 +MAX_DECLARED_ROWS_PER_CONTRACT = 250_000 +MAX_PACKAGE_TRAVERSAL_ENTRIES = 32 +MAX_MANIFEST_NESTING_DEPTH = 64 + + +@dataclass(frozen=True) +class ManifestFile: + path: str + contract: str + sha256: str + row_count: int + + +def _freeze_manifest_value(value: Any) -> Any: + if isinstance(value, Mapping): + return MappingProxyType({ + key: _freeze_manifest_value(item) + for key, item in value.items() + }) + if isinstance(value, (list, tuple)): + return tuple(_freeze_manifest_value(item) for item in value) + if isinstance(value, (set, frozenset)): + return frozenset(_freeze_manifest_value(item) for item in value) + return value + + +def _validate_manifest_nesting(value: Any) -> None: + pending = [(value, 0)] + while pending: + item, depth = pending.pop() + if depth > MAX_MANIFEST_NESTING_DEPTH: + raise ValueError("manifest_nesting_limit_exceeded") + if isinstance(item, Mapping): + pending.extend( + (nested, depth + 1) + for nested in item.values() + ) + elif isinstance(item, (list, tuple, set, frozenset)): + pending.extend( + (nested, depth + 1) + for nested in item + ) + + +@dataclass(frozen=True) +class UniverseManifest: + schema_version: str + dataset_id: str + manifest_id: str + manifest_created_at: str + observation_cutoff_at: str + coverage_semantics: str + declared_universes: tuple[Mapping[str, str], ...] + allowed_source_ids: tuple[str, ...] + source_rights_registry_sha256: str + files: tuple[ManifestFile, ...] + evaluation_policy: Mapping[str, Any] + corporate_action_policy: Mapping[str, str] + delisting_policy: Mapping[str, Any] + survivorship_policy: Mapping[str, Any] + reproduction_contract: str + + def __post_init__(self) -> None: + for name in ( + "declared_universes", + "allowed_source_ids", + "files", + "evaluation_policy", + "corporate_action_policy", + "delisting_policy", + "survivorship_policy", + ): + object.__setattr__( + self, + name, + _freeze_manifest_value(getattr(self, name)), + ) + + +@dataclass(frozen=True) +class LoadedUniversePackage: + manifest_path: Path + registry_path: Path + manifest: UniverseManifest + files: Mapping[str, Path] + contract_snapshots: Mapping[str, bytes] + registry_snapshot: bytes + + +def _sha256(snapshot: bytes) -> str: + return hashlib.sha256(snapshot).hexdigest() + + +def _csv_row_count(snapshot: bytes) -> int: + with io.StringIO(snapshot.decode("utf-8"), newline="") as handle: + return sum(1 for _ in csv.DictReader(handle)) + + +def _bounded_snapshot( + path: Path, + *, + maximum_bytes: int, + size_error: str, + unreadable_error: str, + combined_maximum_bytes: int | None = None, + combined_size_error: str | None = None, + _dir_fd: int | None = None, + _no_follow: bool = False, +) -> bytes: + descriptor: int | None = None + try: + flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr( + os, + "O_NONBLOCK", + 0, + ) + if _no_follow: + no_follow = getattr(os, "O_NOFOLLOW", None) + if not isinstance(no_follow, int) or no_follow == 0: + raise ValueError(unreadable_error) + flags |= no_follow + descriptor = os.open(path, flags, dir_fd=_dir_fd) + before = os.fstat(descriptor) + if not stat.S_ISREG(before.st_mode): + raise ValueError(unreadable_error) + if before.st_size > maximum_bytes: + raise ValueError(size_error) + if ( + combined_maximum_bytes is not None + and before.st_size > combined_maximum_bytes + ): + raise ValueError(combined_size_error or size_error) + read_maximum = ( + maximum_bytes + if combined_maximum_bytes is None + else min(maximum_bytes, combined_maximum_bytes) + ) + chunks: list[bytes] = [] + remaining = read_maximum + 1 + while remaining: + chunk = os.read(descriptor, remaining) + if not chunk: + break + chunks.append(chunk) + remaining -= len(chunk) + snapshot = b"".join(chunks) + after = os.fstat(descriptor) + if len(snapshot) > maximum_bytes or after.st_size > maximum_bytes: + raise ValueError(size_error) + if ( + combined_maximum_bytes is not None + and ( + len(snapshot) > combined_maximum_bytes + or after.st_size > combined_maximum_bytes + ) + ): + raise ValueError(combined_size_error or size_error) + stable_metadata = ( + before.st_dev, + before.st_ino, + before.st_size, + before.st_mtime_ns, + before.st_ctime_ns, + ) + if stable_metadata != ( + after.st_dev, + after.st_ino, + after.st_size, + after.st_mtime_ns, + after.st_ctime_ns, + ): + raise ValueError(size_error) + if len(snapshot) != after.st_size: + raise ValueError(unreadable_error) + except ValueError: + raise + except (OSError, TypeError, NotImplementedError) as exc: + raise ValueError(unreadable_error) from exc + finally: + if descriptor is not None: + os.close(descriptor) + return snapshot + + +def _open_directory_descriptor( + path: Path | str, + *, + unreadable_error: str, + dir_fd: int | None = None, +) -> int: + descriptor: int | None = None + try: + directory_only = getattr(os, "O_DIRECTORY", None) + no_follow = getattr(os, "O_NOFOLLOW", None) + if ( + not isinstance(directory_only, int) + or directory_only == 0 + or not isinstance(no_follow, int) + or no_follow == 0 + ): + raise ValueError(unreadable_error) + flags = ( + os.O_RDONLY + | getattr(os, "O_CLOEXEC", 0) + | directory_only + | no_follow + ) + descriptor = os.open(path, flags, dir_fd=dir_fd) + if not stat.S_ISDIR(os.fstat(descriptor).st_mode): + raise ValueError(unreadable_error) + return descriptor + except ValueError: + if descriptor is not None: + os.close(descriptor) + raise + except (OSError, TypeError, NotImplementedError) as exc: + if descriptor is not None: + os.close(descriptor) + raise ValueError(unreadable_error) from exc + + +def _relative_bounded_snapshot( + package_descriptor: int, + relative_path: str, + *, + maximum_bytes: int, + size_error: str, + unreadable_error: str, + combined_maximum_bytes: int | None = None, + combined_size_error: str | None = None, +) -> bytes: + parts = Path(relative_path).parts + current_descriptor: int | None = None + try: + current_descriptor = os.dup(package_descriptor) + for component in parts[:-1]: + next_descriptor = _open_directory_descriptor( + component, + unreadable_error=unreadable_error, + dir_fd=current_descriptor, + ) + os.close(current_descriptor) + current_descriptor = next_descriptor + return _bounded_snapshot( + Path(parts[-1]), + maximum_bytes=maximum_bytes, + size_error=size_error, + unreadable_error=unreadable_error, + combined_maximum_bytes=combined_maximum_bytes, + combined_size_error=combined_size_error, + _dir_fd=current_descriptor, + _no_follow=True, + ) + except (OSError, TypeError, NotImplementedError) as exc: + raise ValueError(unreadable_error) from exc + finally: + if current_descriptor is not None: + os.close(current_descriptor) + + +def _safe_child(base: Path, relative: str) -> Path: + if not isinstance(relative, str) or not relative: + raise ValueError("manifest_path_unsafe") + relative_path = Path(relative) + if relative_path.is_absolute() or ".." in relative_path.parts: + raise ValueError("manifest_path_unsafe") + resolved_base = base.resolve() + resolved = (base / relative_path).resolve() + if resolved == resolved_base or resolved_base not in resolved.parents: + raise ValueError("manifest_path_unsafe") + return resolved + + +def _nonempty_string(value: Any) -> bool: + return isinstance(value, str) and bool(value.strip()) + + +def _nonempty_identifier(value: Any) -> bool: + return ( + _nonempty_string(value) + and value == value.strip() + and is_control_free(value) + ) + + +def _unique_json_object( + pairs: list[tuple[str, Any]], +) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ValueError("manifest_duplicate_key") + result[key] = value + return result + + +def _utc_timestamp(value: Any) -> datetime | None: + if ( + not isinstance(value, str) + or RFC3339_UTC.fullmatch(value) is None + ): + return None + try: + timestamp = datetime.fromisoformat(value.removesuffix("Z") + "+00:00") + except ValueError: + return None + if timestamp.tzinfo is None or timestamp.utcoffset() is None or timestamp.utcoffset().total_seconds() != 0: + return None + return timestamp + + +def _valid_evaluation_policy(policy: Any) -> bool: + if not isinstance(policy, dict): + return False + if policy.get("kind") == "walk_forward": + history = policy.get("minimum_history_count") + return isinstance(history, int) and not isinstance(history, bool) and history > 0 + if policy.get("kind") != "train_validation_test": + return False + boundaries = [ + _utc_timestamp(policy.get("train_end_at")), + _utc_timestamp(policy.get("validation_start_at")), + _utc_timestamp(policy.get("validation_end_at")), + _utc_timestamp(policy.get("test_start_at")), + ] + return all(boundaries) and boundaries == sorted(boundaries) and len(set(boundaries)) == 4 + + +def _validate_manifest_semantics(raw: Mapping[str, Any]) -> None: + if not _nonempty_identifier(raw.get("dataset_id")): + raise ValueError("manifest_dataset_id_invalid") + if not _nonempty_identifier(raw.get("manifest_id")): + raise ValueError("manifest_id_invalid") + manifest_created_at = _utc_timestamp(raw.get("manifest_created_at")) + if manifest_created_at is None: + raise ValueError("manifest_created_at_invalid") + observation_cutoff_at = _utc_timestamp( + raw.get("observation_cutoff_at") + ) + if observation_cutoff_at is None: + raise ValueError("manifest_observation_cutoff_at_invalid") + if manifest_created_at < observation_cutoff_at: + raise ValueError( + "manifest_created_before_observation_cutoff" + ) + coverage_semantics = raw.get("coverage_semantics") + if ( + not isinstance(coverage_semantics, str) + or coverage_semantics not in ALLOWED_COVERAGE_SEMANTICS + ): + raise ValueError("manifest_coverage_semantics_invalid") + declared_universes = raw.get("declared_universes") + if not isinstance(declared_universes, list) or not declared_universes: + raise ValueError("manifest_declared_universes_invalid") + declared_ids: set[str] = set() + for universe in declared_universes: + if not isinstance(universe, dict): + raise ValueError("manifest_declared_universes_invalid") + universe_id = universe.get("universe_id") + universe_kind = universe.get("universe_kind") + if ( + not _nonempty_identifier(universe_id) + or not isinstance(universe_kind, str) + or universe_kind not in ALLOWED_UNIVERSE_KINDS + ): + raise ValueError("manifest_declared_universes_invalid") + if universe_id in declared_ids: + raise ValueError("manifest_declared_universes_invalid") + declared_ids.add(universe_id) + allowed_source_ids = raw.get("allowed_source_ids") + if ( + not isinstance(allowed_source_ids, list) + or not allowed_source_ids + or any(not _nonempty_identifier(source_id) for source_id in allowed_source_ids) + or len(allowed_source_ids) != len(set(allowed_source_ids)) + ): + raise ValueError("manifest_allowed_source_ids_invalid") + if not _valid_evaluation_policy(raw.get("evaluation_policy")): + raise ValueError("manifest_evaluation_policy_invalid") + corporate_action_policy = raw.get("corporate_action_policy") + if ( + not isinstance(corporate_action_policy, dict) + or set(corporate_action_policy) != ALLOWED_EVENT_TYPES + or any( + not isinstance(state, str) + or state not in ALLOWED_ACTION_POLICY_STATES + for state in corporate_action_policy.values() + ) + ): + raise ValueError("manifest_corporate_action_policy_invalid") + delisting_policy = raw.get("delisting_policy") + if ( + not isinstance(delisting_policy, dict) + or not isinstance(delisting_policy.get("retain_historical_members"), bool) + or delisting_policy.get("missing_evidence") != "blocked" + ): + raise ValueError("manifest_delisting_policy_invalid") + survivorship_policy = raw.get("survivorship_policy") + if ( + not isinstance(survivorship_policy, dict) + or survivorship_policy.get("filter_by_current_listing_state") is not False + ): + raise ValueError("manifest_survivorship_policy_invalid") + if raw.get("reproduction_contract") != REPRODUCTION_CONTRACT: + raise ValueError("manifest_reproduction_contract_invalid") + + +def _valid_sha256(value: Any) -> bool: + return ( + isinstance(value, str) + and len(value) == 64 + and all(character in "0123456789abcdef" for character in value) + ) + + +def _valid_row_count(value: Any) -> bool: + return isinstance(value, int) and not isinstance(value, bool) and value >= 0 + + +def _manifest_files(raw: Mapping[str, Any]) -> tuple[ManifestFile, ...]: + try: + files = raw["files"] + if not isinstance(files, list): + raise TypeError + for item in files: + if ( + not isinstance(item, dict) + or not _nonempty_identifier(item.get("path")) + or not _nonempty_identifier(item.get("contract")) + or not _valid_sha256(item.get("sha256")) + or not _valid_row_count(item.get("row_count")) + ): + raise ValueError("manifest_file_record_invalid") + if item["row_count"] > MAX_DECLARED_ROWS_PER_CONTRACT: + raise ValueError("manifest_row_count_limit_exceeded") + records = tuple(ManifestFile(**item) for item in files) + except (KeyError, TypeError) as exc: + raise ValueError("manifest_files_invalid") from exc + if len({item.path for item in records}) != len(records): + raise ValueError("manifest_files_invalid") + return records + + +def _reject_unlisted_files( + package_descriptor: int, + manifest_name: str, + file_records: tuple[ManifestFile, ...], +) -> dict[str, int]: + listed_paths = {Path(item.path).as_posix() for item in file_records} + directory_descriptors: dict[str, int] = {} + pending: list[str] = [] + entry_count = 0 + try: + directory_descriptors[""] = os.dup(package_descriptor) + pending.append("") + while pending: + prefix = pending.pop() + current_descriptor = directory_descriptors[prefix] + for name in sorted(os.listdir(current_descriptor)): + entry_count += 1 + if entry_count > MAX_PACKAGE_TRAVERSAL_ENTRIES: + raise ValueError( + "manifest_package_entry_limit_exceeded" + ) + relative_path = ( + f"{prefix}/{name}" + if prefix + else name + ) + metadata = os.stat( + name, + dir_fd=current_descriptor, + follow_symlinks=False, + ) + if stat.S_ISDIR(metadata.st_mode): + child_descriptor = ( + _open_directory_descriptor( + name, + unreadable_error=( + "manifest_package_unreadable" + ), + dir_fd=current_descriptor, + ) + ) + directory_descriptors[relative_path] = ( + child_descriptor + ) + pending.append(relative_path) + elif ( + stat.S_ISREG(metadata.st_mode) + or stat.S_ISLNK(metadata.st_mode) + ) and relative_path != manifest_name: + if relative_path not in listed_paths: + raise ValueError( + "manifest_unlisted_file" + ) + return directory_descriptors + except ValueError: + for descriptor in directory_descriptors.values(): + os.close(descriptor) + raise + except (OSError, TypeError, NotImplementedError) as exc: + for descriptor in directory_descriptors.values(): + os.close(descriptor) + raise ValueError("manifest_package_unreadable") from exc + + +def _verified_contract_parent_descriptor( + directory_descriptors: Mapping[str, int], + relative_path: str, +) -> tuple[int, str]: + path = Path(relative_path) + prefix = "" + try: + for component in path.parts[:-1]: + child_prefix = ( + f"{prefix}/{component}" + if prefix + else component + ) + parent_descriptor = directory_descriptors[prefix] + child_descriptor = directory_descriptors[child_prefix] + visible = os.stat( + component, + dir_fd=parent_descriptor, + follow_symlinks=False, + ) + opened = os.fstat(child_descriptor) + if ( + not stat.S_ISDIR(visible.st_mode) + or (visible.st_dev, visible.st_ino) + != (opened.st_dev, opened.st_ino) + ): + raise ValueError("manifest_file_unreadable") + prefix = child_prefix + return directory_descriptors[prefix], path.name + except ValueError: + raise + except ( + KeyError, + OSError, + TypeError, + NotImplementedError, + ) as exc: + raise ValueError("manifest_file_unreadable") from exc + + +def _validate_open_package_inventory( + directory_descriptors: Mapping[str, int], + manifest_name: str, + file_records: tuple[ManifestFile, ...], +) -> None: + expected_directories = set(directory_descriptors) - {""} + contract_directories: set[str] = set() + for item in file_records: + parts = Path(item.path).parts[:-1] + for length in range(1, len(parts) + 1): + contract_directories.add( + Path(*parts[:length]).as_posix() + ) + expected_files = { + manifest_name, + *(Path(item.path).as_posix() for item in file_records), + } + observed_directories: set[str] = set() + observed_files: set[str] = set() + entry_count = 0 + + def directory_change_error(relative_path: str) -> str: + if relative_path in contract_directories: + return "manifest_file_unreadable" + return "manifest_package_unreadable" + + def expected_non_regular_error(relative_path: str) -> str: + if relative_path == manifest_name: + return "manifest_package_unreadable" + if relative_path in expected_files: + return "manifest_file_unreadable" + if relative_path in expected_directories: + return directory_change_error(relative_path) + return "manifest_unlisted_file" + + try: + for prefix, descriptor in directory_descriptors.items(): + for name in sorted(os.listdir(descriptor)): + entry_count += 1 + if entry_count > MAX_PACKAGE_TRAVERSAL_ENTRIES: + raise ValueError( + "manifest_package_entry_limit_exceeded" + ) + relative_path = ( + f"{prefix}/{name}" + if prefix + else name + ) + metadata = os.stat( + name, + dir_fd=descriptor, + follow_symlinks=False, + ) + if stat.S_ISDIR(metadata.st_mode): + if relative_path in expected_files: + raise ValueError( + expected_non_regular_error(relative_path) + ) + observed_directories.add(relative_path) + mapped_descriptor = directory_descriptors.get( + relative_path + ) + if mapped_descriptor is not None: + opened = os.fstat(mapped_descriptor) + if (metadata.st_dev, metadata.st_ino) != ( + opened.st_dev, + opened.st_ino, + ): + raise ValueError( + directory_change_error(relative_path) + ) + elif stat.S_ISREG(metadata.st_mode): + observed_files.add(relative_path) + else: + raise ValueError( + expected_non_regular_error(relative_path) + ) + + extra_directories = ( + observed_directories - expected_directories + ) + if extra_directories: + raise ValueError("manifest_unlisted_file") + missing_directories = ( + expected_directories - observed_directories + ) + if missing_directories: + error = ( + "manifest_file_unreadable" + if missing_directories & contract_directories + else "manifest_package_unreadable" + ) + raise ValueError(error) + + if observed_files - expected_files: + raise ValueError("manifest_unlisted_file") + missing_files = expected_files - observed_files + if manifest_name in missing_files: + raise ValueError("manifest_package_unreadable") + if missing_files: + raise ValueError("manifest_file_unreadable") + except ValueError: + raise + except (OSError, TypeError, NotImplementedError) as exc: + raise ValueError("manifest_package_unreadable") from exc + + +def load_universe_package(manifest_path: Path, registry_path: Path) -> LoadedUniversePackage: + requested_manifest_path = Path(manifest_path) + package_dir = requested_manifest_path.parent.resolve() + manifest_path = package_dir / requested_manifest_path.name + registry_path = Path(registry_path) + resolved: dict[str, Path] = {} + contract_snapshots: dict[str, bytes] = {} + total_snapshot_bytes = 0 + directory_descriptors: dict[str, int] = {} + package_descriptor = _open_directory_descriptor( + package_dir, + unreadable_error="manifest_package_unreadable", + ) + try: + manifest_snapshot = _bounded_snapshot( + Path(manifest_path.name), + maximum_bytes=MAX_MANIFEST_BYTES, + size_error="manifest_size_limit_exceeded", + unreadable_error="manifest_unreadable", + _dir_fd=package_descriptor, + _no_follow=True, + ) + try: + raw = json.loads( + manifest_snapshot.decode("utf-8"), + object_pairs_hook=_unique_json_object, + ) + except ( + UnicodeDecodeError, + json.JSONDecodeError, + RecursionError, + ) as exc: + raise ValueError("manifest_unreadable") from exc + if not isinstance(raw, dict): + raise ValueError("manifest_unreadable") + _validate_manifest_nesting(raw) + if raw.get("schema_version") != "point_in_time_universe_v1": + raise ValueError("manifest_schema_unsupported") + _validate_manifest_semantics(raw) + file_records = _manifest_files(raw) + contracts = [item.contract for item in file_records] + if ( + set(contracts) != REQUIRED_CONTRACTS + or len(contracts) != len(set(contracts)) + ): + raise ValueError("manifest_contract_set_invalid") + resolved_paths = tuple( + _safe_child(package_dir, item.path) + for item in file_records + ) + directory_descriptors = _reject_unlisted_files( + package_descriptor, + manifest_path.name, + file_records, + ) + for item, path in zip( + file_records, + resolved_paths, + strict=True, + ): + _validate_open_package_inventory( + directory_descriptors, + manifest_path.name, + file_records, + ) + remaining_combined_bytes = max( + MAX_TOTAL_CONTRACT_SNAPSHOT_BYTES + - total_snapshot_bytes, + 0, + ) + parent_descriptor, file_name = ( + _verified_contract_parent_descriptor( + directory_descriptors, + item.path, + ) + ) + snapshot = _bounded_snapshot( + Path(file_name), + maximum_bytes=MAX_CONTRACT_SNAPSHOT_BYTES, + size_error="manifest_file_size_limit_exceeded", + unreadable_error="manifest_file_unreadable", + combined_maximum_bytes=remaining_combined_bytes, + combined_size_error=( + "manifest_total_snapshot_size_limit_exceeded" + ), + _dir_fd=parent_descriptor, + _no_follow=True, + ) + total_snapshot_bytes += len(snapshot) + if ( + total_snapshot_bytes + > MAX_TOTAL_CONTRACT_SNAPSHOT_BYTES + ): + raise ValueError( + "manifest_total_snapshot_size_limit_exceeded" + ) + file_hash = _sha256(snapshot) + row_count = _csv_row_count(snapshot) + if file_hash != item.sha256: + raise ValueError("manifest_hash_mismatch") + if row_count != item.row_count: + raise ValueError("manifest_row_count_mismatch") + resolved[item.contract] = path + contract_snapshots[item.contract] = snapshot + _validate_open_package_inventory( + directory_descriptors, + manifest_path.name, + file_records, + ) + _validate_open_package_inventory( + directory_descriptors, + manifest_path.name, + file_records, + ) + finally: + for descriptor in directory_descriptors.values(): + os.close(descriptor) + os.close(package_descriptor) + registry_snapshot = _bounded_snapshot( + registry_path, + maximum_bytes=MAX_RIGHTS_REGISTRY_BYTES, + size_error="manifest_registry_size_limit_exceeded", + unreadable_error="manifest_registry_unreadable", + ) + registry_hash = _sha256(registry_snapshot) + if registry_hash != raw.get("source_rights_registry_sha256"): + raise ValueError("manifest_registry_digest_mismatch") + manifest = UniverseManifest( + schema_version=raw["schema_version"], + dataset_id=raw["dataset_id"], + manifest_id=raw["manifest_id"], + manifest_created_at=raw["manifest_created_at"], + observation_cutoff_at=raw["observation_cutoff_at"], + coverage_semantics=raw["coverage_semantics"], + declared_universes=tuple(MappingProxyType(dict(item)) for item in raw["declared_universes"]), + allowed_source_ids=tuple(raw["allowed_source_ids"]), + source_rights_registry_sha256=raw["source_rights_registry_sha256"], + files=file_records, + evaluation_policy=MappingProxyType(dict(raw["evaluation_policy"])), + corporate_action_policy=MappingProxyType(dict(raw["corporate_action_policy"])), + delisting_policy=MappingProxyType(dict(raw["delisting_policy"])), + survivorship_policy=MappingProxyType(dict(raw["survivorship_policy"])), + reproduction_contract=raw["reproduction_contract"], + ) + return LoadedUniversePackage( + manifest_path=manifest_path.resolve(), + registry_path=registry_path.resolve(), + manifest=manifest, + files=MappingProxyType(resolved), + contract_snapshots=MappingProxyType(contract_snapshots), + registry_snapshot=registry_snapshot, + ) diff --git a/src/price_import_normalizer.py b/src/price_import_normalizer.py index 67adec4d..9582f58f 100644 --- a/src/price_import_normalizer.py +++ b/src/price_import_normalizer.py @@ -10,6 +10,7 @@ import pandas as pd from src.paths import format_path_context, resolve_project_root +from src.price_lineage_temporal import review_daily_price_retrieval STAGED_PRICE_COLUMNS = [ @@ -22,6 +23,8 @@ "volume", "adjusted_close", "source", + "source_ref", + "retrieved_at", "as_of_date", "notes", ] @@ -112,6 +115,9 @@ def _normalize_one_file( *, ticker: str | None, source: str, + source_ref: str | None, + retrieved_at: str | None, + review_cutoff: str | None, as_of_date: str, explicit_mapping: dict[str, str], ) -> tuple[pd.DataFrame, dict[str, Any]]: @@ -186,8 +192,31 @@ def _normalize_one_file( if duplicate_rows: warnings.append(f"{path.name}: deduplicated {duplicate_rows} duplicate date+ticker row(s), keeping the last row.") output = output.drop_duplicates(subset=["date", "ticker"], keep="last") + normalized_retrieved_at = retrieved_at or "" + if retrieved_at: + temporal_failures: list[str] = [] + normalized_values: set[str] = set() + for row_number, observation_date in output["date"].items(): + temporal = review_daily_price_retrieval( + observation_date, + retrieved_at, + review_cutoff=review_cutoff, + ) + if temporal.blockers: + temporal_failures.append( + f"{path.name} row {row_number + 2}: " + ", ".join(temporal.blockers) + ) + else: + normalized_values.add(temporal.retrieved_at) + if temporal_failures: + raise ValueError("; ".join(temporal_failures)) + if len(normalized_values) != 1: + raise ValueError(f"{path.name}: retrieval normalization was not deterministic") + normalized_retrieved_at = normalized_values.pop() output["date"] = output["date"].dt.date.astype(str) output["source"] = source + output["source_ref"] = source_ref or "" + output["retrieved_at"] = normalized_retrieved_at output["as_of_date"] = as_of_date output["notes"] = f"Normalized from {path.name}" output = output.reindex(columns=STAGED_PRICE_COLUMNS) @@ -223,6 +252,9 @@ def normalize_price_imports( output_path: Path, ticker: str | None = None, source: str = "generic_manual", + source_ref: str | None = None, + retrieved_at: str | None = None, + review_cutoff: str | None = None, as_of_date: str | None = None, column_mapping: dict[str, str] | None = None, ) -> PriceNormalizationResult: @@ -242,6 +274,9 @@ def normalize_price_imports( path, ticker=ticker, source=source, + source_ref=source_ref, + retrieved_at=retrieved_at, + review_cutoff=review_cutoff, as_of_date=as_of_date, explicit_mapping=column_mapping, ) @@ -290,6 +325,9 @@ def main() -> None: parser.add_argument("--output", default="data/imports/prices.csv", help="Staged output CSV path.") parser.add_argument("--ticker", help="Ticker to use when the input file has no ticker column.") parser.add_argument("--source", default="generic_manual", help="Source label to write into the import CSV.") + parser.add_argument("--source-ref", help="Durable reviewed reference for the exact source payload or row batch.") + parser.add_argument("--retrieved-at", help="Explicit source retrieval timestamp; no default is generated.") + parser.add_argument("--review-cutoff", help="Explicit timezone-aware cutoff for retrieval review.") parser.add_argument("--as-of-date", help="As-of date metadata. Defaults to today.") parser.add_argument("--date-col") parser.add_argument("--ticker-col") @@ -319,6 +357,9 @@ def main() -> None: output_path=output_path, ticker=args.ticker, source=args.source, + source_ref=args.source_ref, + retrieved_at=args.retrieved_at, + review_cutoff=args.review_cutoff, as_of_date=args.as_of_date, column_mapping=_column_map_from_args(args), ) diff --git a/src/price_lineage_temporal.py b/src/price_lineage_temporal.py new file mode 100644 index 00000000..19ce2afd --- /dev/null +++ b/src/price_lineage_temporal.py @@ -0,0 +1,103 @@ +"""Shared fail-closed temporal review for daily price lineage.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date, datetime, time, timedelta, timezone +@dataclass(frozen=True) +class PriceTemporalReview: + status: str + observation_date: str + availability_at: str + retrieved_at: str + review_cutoff: str + blockers: tuple[str, ...] + + +def _observation_date(value: object) -> date | None: + if isinstance(value, datetime): + return value.date() + if isinstance(value, date): + return value + text = str(value or "").strip() + if not text: + return None + try: + return date.fromisoformat(text[:10]) + except ValueError: + return None + + +def _aware_timestamp(value: object, *, label: str) -> tuple[datetime | None, str | None]: + if value is None or not str(value).strip(): + return None, "missing_retrieved_at" if label == "retrieved_at" else f"{label}_required" + if isinstance(value, datetime): + parsed = value + else: + text = str(value).strip() + if text.endswith(("Z", "z")): + text = text[:-1] + "+00:00" + try: + parsed = datetime.fromisoformat(text) + except ValueError: + return None, f"invalid_{label}" + if parsed.tzinfo is None or parsed.utcoffset() is None: + return None, f"{label}_timezone_required" + return parsed.astimezone(timezone.utc), None + + +def review_daily_price_retrieval( + observation_date: object, + retrieved_at: object, + *, + review_cutoff: object, +) -> PriceTemporalReview: + """Review daily OHLCV availability without inferring provider publication time.""" + + observed = _observation_date(observation_date) + if observed is None: + return PriceTemporalReview( + status="temporal_review_required", + observation_date="", + availability_at="", + retrieved_at="", + review_cutoff="", + blockers=("invalid_observation_date",), + ) + + availability = datetime.combine(observed + timedelta(days=1), time.min, timezone.utc) + retrieved, retrieved_error = _aware_timestamp(retrieved_at, label="retrieved_at") + if retrieved_error: + return PriceTemporalReview( + status="temporal_review_required", + observation_date=observed.isoformat(), + availability_at=availability.isoformat(), + retrieved_at="", + review_cutoff="", + blockers=(retrieved_error,), + ) + + cutoff, cutoff_error = _aware_timestamp(review_cutoff, label="review_cutoff") + if cutoff_error: + return PriceTemporalReview( + status="temporal_review_required", + observation_date=observed.isoformat(), + availability_at=availability.isoformat(), + retrieved_at=retrieved.isoformat(), + review_cutoff="", + blockers=(cutoff_error,), + ) + + blockers: list[str] = [] + if retrieved < availability: + blockers.append("retrieved_before_observation_available") + if retrieved > cutoff: + blockers.append("retrieved_after_review_cutoff") + return PriceTemporalReview( + status="temporal_complete" if not blockers else "temporal_review_required", + observation_date=observed.isoformat(), + availability_at=availability.isoformat(), + retrieved_at=retrieved.isoformat(), + review_cutoff=cutoff.isoformat(), + blockers=tuple(blockers), + ) diff --git a/src/private_beta_readiness.py b/src/private_beta_readiness.py new file mode 100644 index 00000000..b5a56a10 --- /dev/null +++ b/src/private_beta_readiness.py @@ -0,0 +1,144 @@ +"""Read-only readiness classification for the unhosted private-beta boundary.""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass + + +@dataclass(frozen=True) +class PrivateBetaCheck: + area: str + status: str + detail: str + next_step: str + + +@dataclass(frozen=True) +class PrivateBetaReadiness: + classification: str + checks: tuple[PrivateBetaCheck, ...] + boundary: str + + +_EXTERNAL_ACCOUNT_AREAS = ( + ("authentication", "Configure and verify an external identity provider before admitting users."), + ("workspaces", "Create private workspaces through the hosted account-backed service."), + ("user_data_separation", "Verify per-user data isolation in the hosted persistence layer."), + ("audit", "Configure and review hosted audit logging before relying on audit records."), + ("retention", "Set and verify retention and deletion behavior with the hosted provider."), + ("entitlements", "Configure and test account-backed access entitlements."), + ("monitoring", "Configure hosted usage and error monitoring without recording sensitive user data."), + ("health_checks", "Configure and verify hosted health checks after deployment."), +) + +_EXTERNAL_OPERATIONS_AREAS = ( + ( + "incident_response", + "Assign an incident owner, escalation path, reviewer-access stop rule, and evidence-preserving response procedure.", + ), + ( + "rollback", + "Define and rehearse rollback to the previous verified revision in the actual hosted environment.", + ), + ( + "owner_capacity", + "Confirm named operating coverage for source failures, access incidents, reviewer support, and recovery.", + ), +) + + +def build_private_beta_readiness( + *, external_setup_declared: bool = False, unsafe_secret_detected: bool = False +) -> PrivateBetaReadiness: + """Describe local contract readiness without inspecting or configuring runtime systems.""" + secret_status = "unsafe_secret_blocked" if unsafe_secret_detected else "local_ready" + secret_detail = ( + "A tracked or otherwise unsafe secret condition was declared; readiness is blocked." + if unsafe_secret_detected + else "Repository guidance keeps real secrets outside Git and limits committed files to templates and names." + ) + secret_next_step = ( + "Remove the secret from tracked files, rotate it outside the repository, and re-run the review." + if unsafe_secret_detected + else "Use the external host's encrypted secret store only after an account exists." + ) + checks = [ + PrivateBetaCheck( + area="secrets", + status=secret_status, + detail=secret_detail, + next_step=secret_next_step, + ) + ] + external_status = ( + "manual_verification_required" + if external_setup_declared + else "external_account_required" + ) + checks.extend( + PrivateBetaCheck( + area=area, + status=external_status, + detail="This repository contains a readiness requirement, not an implemented runtime capability.", + next_step=next_step, + ) + for area, next_step in _EXTERNAL_ACCOUNT_AREAS + ) + operations_status = ( + "manual_verification_required" + if external_setup_declared + else "external_operations_required" + ) + checks.extend( + PrivateBetaCheck( + area=area, + status=operations_status, + detail="The repository can define this operating requirement, but only a staffed hosted rehearsal can prove it.", + next_step=next_step, + ) + for area, next_step in _EXTERNAL_OPERATIONS_AREAS + ) + return PrivateBetaReadiness( + classification=( + "unsafe_secret_blocked" + if unsafe_secret_detected + else "manual_verification_required" + if external_setup_declared + else "local_ready" + ), + checks=tuple(checks), + boundary=( + "Local readiness only; declared setup does not prove runtime authentication or " + "hosting; do not claim runtime authentication or hosting. It also does not prove " + "operated response capacity." + ), + ) + + +def render_private_beta_readiness(readiness: PrivateBetaReadiness) -> str: + lines = [ + "Private Beta Readiness", + "Read-only: this command does not configure accounts, inspect secrets, deploy, or claim hosted capabilities.", + f"classification: {readiness.classification}", + ] + lines.extend(f"- {check.area}: {check.status}; {check.next_step}" for check in readiness.checks) + lines.append(f"boundary: {readiness.boundary}") + return "\n".join(lines) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Print the read-only private-beta readiness contract.") + parser.add_argument("--external-setup-declared", action="store_true") + parser.add_argument("--unsafe-secret-detected", action="store_true") + args = parser.parse_args(argv) + readiness = build_private_beta_readiness( + external_setup_declared=args.external_setup_declared, + unsafe_secret_detected=args.unsafe_secret_detected, + ) + print(render_private_beta_readiness(readiness)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/project_status.py b/src/project_status.py index aa779f95..e1d22e46 100644 --- a/src/project_status.py +++ b/src/project_status.py @@ -5,12 +5,14 @@ import os import re import subprocess +from dataclasses import asdict from pathlib import Path from typing import Any import pandas as pd from src.artifact_freshness import generated_artifact_stale_warning +from src.continuation_gate import ContinuationGate, build_continuation_gate from src.data_onboarding import build_onboarding_payload from src.data_onboarding import write_onboarding_outputs from src.data_update import enrich_price_update_status_frame, refresh_price_update_status_output @@ -22,7 +24,7 @@ from src.paths import resolve_data_dir, resolve_outputs_dir, resolve_project_root from src.profile_context import build_profile_context, render_profile_context_text from src.price_history_proof_queue import _reviewed_non_actionable_price_tickers -from src.public_ux_review_checklist import public_ux_review_notes_status +from src.public_ux_review_checklist import SUCCESSFUL_REVIEW_CLASSIFICATIONS, public_ux_review_notes_status from src.purpose_evaluation import PURPOSE_EVALUATION_SUMMARY_CSV, write_purpose_evaluation_summary from src.readiness_ops import build_reviewed_batch_ledger_summaries from src.research_health import research_health_outputs_current @@ -501,7 +503,9 @@ def _public_ux_stage_from_status(status: dict[str, Any] | None) -> dict[str, str gate = str(status.get("share_review_gate") or "").strip() if gate == "share_review_ready": counts = status.get("classification_counts") if isinstance(status.get("classification_counts"), dict) else {} - resolved = int(counts.get("resolved") or 0) + resolved = sum( + int(counts.get(classification) or 0) for classification in SUCCESSFUL_REVIEW_CLASSIFICATIONS + ) total = int(status.get("expected_rows") or resolved) return { "State": "share_review_ready", @@ -1924,7 +1928,11 @@ def write_project_status_output( } -def _print_human(payload: dict[str, Any]) -> None: +def _print_human( + payload: dict[str, Any], + *, + continuation_gate: ContinuationGate | None = None, +) -> None: summary = payload["summary"] warnings = list(payload.get("warnings", [])) has_stale_snapshot_warning = any( @@ -1933,6 +1941,19 @@ def _print_human(payload: dict[str, Any]) -> None: ) print("Read-only project snapshot.") print("Commands below are copy-only local research helpers; this status view does not run them.") + suppress_execution = bool(continuation_gate and continuation_gate.suppress_execution) + if continuation_gate is not None: + print(f"Continuation gate: {continuation_gate.state}") + if continuation_gate.next_safe_command: + print(f"- Continuation-safe next action: {continuation_gate.next_safe_command}") + if continuation_gate.reason: + print(f"- Reason: {continuation_gate.reason}") + if continuation_gate.rebuild_command: + print( + f"- Rebuild boundary: {continuation_gate.rebuild_command} requires an intentional reviewed write." + ) + if continuation_gate.stop_rule: + print(f"- Stop rule: {continuation_gate.stop_rule}") if has_stale_snapshot_warning: print("Snapshot freshness: generated snapshot may be stale; refresh before relying on exact counts.") for warning in warnings: @@ -1964,10 +1985,19 @@ def _print_human(payload: dict[str, Any]) -> None: {"Step": f"Next {index}", "Command": command} for index, command in enumerate(payload.get("recommended_next_commands", []), start=1) ] + if suppress_execution: + command_rows = [ + { + "Step": "Inspect stale readiness impact", + "Command": continuation_gate.next_safe_command, + "Reason": "Compare saved and proposed stable readiness states in memory before any reviewed rebuild decision.", + "FreshnessContext": "Inspection only; this does not make saved readiness current.", + } + ] first_command = str(command_rows[0].get("Command") or "").strip() if command_rows else "" print("First read:") ready_label = "Ready now" - if has_stale_snapshot_warning: + if has_stale_snapshot_warning or suppress_execution: ready_label = "Ready in saved snapshot" print( f"- {ready_label}: {summary['tickers_with_prices']} with price rows, " @@ -1981,7 +2011,12 @@ def _print_human(payload: dict[str, Any]) -> None: "- Still blocked: trusted fundamentals, peer mappings, earnings, and analyst estimates " "stay locked where source-backed rows are missing." ) - if first_command == "make provider-setup-checklist": + if suppress_execution: + print( + f"- Best next proof: {continuation_gate.next_safe_command} for no-write readiness impact inspection; " + "source and coverage execution stays paused until readiness is current or a separate reviewed rebuild is authorized." + ) + elif first_command == "make provider-setup-checklist": print( "- Best next proof: make provider-setup-checklist for provider setup and source-boundary evidence; " "current source-proof queues have no unreviewed executable company candidates, so wait for new " @@ -2055,27 +2090,28 @@ def _print_human(payload: dict[str, Any]) -> None: print(f" evidence: {evidence}") if next_action: print(f" next: {next_action}") - print("Top locked inputs to review:") - price_complete = _price_coverage_complete(summary) - for row in payload["top_onboarding_actions"]: - ticker = f" {row['ticker']}" if row.get("ticker") else "" - raw_dataset_label = str(row.get("dataset") or "data") - dataset_label = raw_dataset_label - if dataset_label == "prices" and price_complete: - dataset_label = "price history" - print(f"- P{row['priority']} {dataset_label}{ticker}") - if row.get("focus_command"): - print(f" suggested check: {row['focus_command']}") - if row.get("recommended_action"): - print(f" guidance: {_friendly_cli_guidance(row['recommended_action'])}") - if row.get("example_command"): - example_label = "last manual fallback" if raw_dataset_label == "prices" else "trusted import/fallback" - print(f" {example_label}: {row['example_command']}") - if row.get("credential_required"): - present = "present" if bool(row.get("credential_present")) else "missing" - print(f" credential: {row['credential_required']} ({present})") - if row.get("manual_fallback_command"): - print(f" fallback: {row['manual_fallback_command']}") + if not suppress_execution: + print("Top locked inputs to review:") + price_complete = _price_coverage_complete(summary) + for row in payload["top_onboarding_actions"]: + ticker = f" {row['ticker']}" if row.get("ticker") else "" + raw_dataset_label = str(row.get("dataset") or "data") + dataset_label = raw_dataset_label + if dataset_label == "prices" and price_complete: + dataset_label = "price history" + print(f"- P{row['priority']} {dataset_label}{ticker}") + if row.get("focus_command"): + print(f" suggested check: {row['focus_command']}") + if row.get("recommended_action"): + print(f" guidance: {_friendly_cli_guidance(row['recommended_action'])}") + if row.get("example_command"): + example_label = "last manual fallback" if raw_dataset_label == "prices" else "trusted import/fallback" + print(f" {example_label}: {row['example_command']}") + if row.get("credential_required"): + present = "present" if bool(row.get("credential_present")) else "missing" + print(f" credential: {row['credential_required']} ({present})") + if row.get("manual_fallback_command"): + print(f" fallback: {row['manual_fallback_command']}") print("Recommended next local steps:") for row in command_rows: print(f"- {row.get('Step', 'Next')}: {row.get('Command', '')}") @@ -2161,21 +2197,20 @@ def main(argv: list[str] | None = None) -> None: tickers=explicit_tickers, ) + profile_context = build_profile_context( + project_root=root, + data_dir=data_path, + output_dir=output_path, + ) + continuation_gate = build_continuation_gate(profile_context) + payload["continuation_gate"] = asdict(continuation_gate) if args.json: print(json.dumps(payload, indent=2)) return - print( - render_profile_context_text( - build_profile_context( - project_root=root, - data_dir=data_path, - output_dir=output_path, - ) - ) - ) + print(render_profile_context_text(profile_context)) print(_format_operator_path_context(root, data_path, output_path)) - _print_human(payload) + _print_human(payload, continuation_gate=continuation_gate) if args.write_output: print("Wrote:") for path in payload.get("written_files", {}).values(): diff --git a/src/proof_readiness_reconciliation.py b/src/proof_readiness_reconciliation.py new file mode 100644 index 00000000..22e7b1a0 --- /dev/null +++ b/src/proof_readiness_reconciliation.py @@ -0,0 +1,838 @@ +"""Reconcile historical proof outcomes with current saved readiness. + +This module is read-only. It does not restore canonical data, promote readiness, +rewrite proof history, or infer source rights, provenance, or payload truth. +""" + +from __future__ import annotations + +import argparse +from collections import Counter +from dataclasses import asdict, dataclass +from datetime import date +import json +from pathlib import Path +import re +from typing import Sequence + +import pandas as pd + +from src.reviewed_batch_proof import ReviewedBatchProof, load_reviewed_batch_proofs + + +SUPPORTING_OUTCOMES = frozenset({"supported", "auto_supported", "human_reviewed_supported"}) +PLACEHOLDER_TICKER_VALUES = frozenset({"-", "none", "n/a", "na", "not available", "unknown"}) +CANONICAL_DCF_FIELDS = ( + "free_cash_flow", + "shares_outstanding", + "revenue", + "fcf_margin", + "price", +) +FUNDAMENTALS_FIELDS = CANONICAL_DCF_FIELDS[:-1] +HISTORICAL_EVIDENCE_LIMIT = ( + "Historical batch proof cannot distinguish payload removal, readiness-contract change, " + "source-rights change, field-scope change, or another historical cause." +) + +NEXT_SAFE_REVIEW = { + "current_canonical_row_missing": ( + "Obtain and review a permitted source payload for the exact ticker before any import or readiness rebuild." + ), + "current_required_fields_missing": ( + "Review the named current fields through the existing source-review and preview-first workflow." + ), + "current_price_missing": ( + "Inspect the exact ticker's current price evidence without inferring a provider." + ), + "current_peer_mapping_missing": ( + "Review a source-backed relationship through the existing peer evidence contract." + ), + "current_peer_valuation_inputs_missing": ( + "Review current peer valuation inputs independently from mapping readiness." + ), + "current_readiness_input_unavailable": ( + "Restore or inspect the current saved input before drawing a conclusion." + ), + "none": "No current blocker is reported for this lane.", +} + +LANE_MAPPINGS: dict[str, tuple[str, str, str]] = { + "fundamentals": ("fundamentals", "ticker", "fundamentals_ready"), + "fundamentals_dcf": ("dcf", "ticker", "dcf_ready"), + "share_count": ("share_count", "dcf", "has_shares_outstanding"), + "price": ("price", "ticker", "price_ready"), + "prices": ("price", "ticker", "price_ready"), + "price_coverage": ("price", "ticker", "price_ready"), + "price_history": ("price", "ticker", "price_ready"), + "peers": ("peer_mapping", "ticker", "peer_ready"), + "peer_mapping": ("peer_mapping", "ticker", "peer_ready"), + "peer_valuation_inputs": ("peer_valuation_inputs", "peer", "peer_valuation_ready"), +} + +CANONICAL_LANES: tuple[tuple[str, str, str], ...] = ( + ("fundamentals", "ticker", "fundamentals_ready"), + ("dcf", "ticker", "dcf_ready"), + ("share_count", "dcf", "has_shares_outstanding"), + ("price", "ticker", "price_ready"), + ("peer_mapping", "ticker", "peer_ready"), + ("peer_valuation_inputs", "peer", "peer_valuation_ready"), +) + +INPUT_REQUIRED_COLUMNS: dict[str, tuple[str, ...]] = { + "ticker": ("ticker", "fundamentals_ready", "dcf_ready", "price_ready", "peer_ready"), + "dcf": ("ticker", "has_shares_outstanding", "missing_dcf_fields"), + "peer": ("ticker", "peer_valuation_ready"), + "fundamentals": ("ticker",), +} + +INPUT_LABELS = { + "ticker": "ticker readiness", + "dcf": "DCF readiness", + "peer": "peer readiness", + "fundamentals": "canonical fundamentals", +} + +INPUT_READINESS_FIELDS: dict[str, tuple[str, ...]] = { + "ticker": ("fundamentals_ready", "dcf_ready", "price_ready", "peer_ready"), + "dcf": ("has_shares_outstanding",), + "peer": ("peer_valuation_ready",), +} + +STATE_PRIORITY = { + "historical_supported_currently_blocked": 0, + "current_ready_proof_not_supporting": 1, + "currently_blocked_with_non_supporting_history": 2, + "current_supported_with_matching_proof": 3, + "no_proof_record": 4, + "not_applicable": 5, +} + + +@dataclass(frozen=True) +class ProofReadinessReconciliationRow: + ticker: str + lane: str + current_field: str + current_ready: bool | None + latest_batch_id: str + latest_review_date: str + latest_outcome: str + review_date_valid: bool + state: str + reason: str + proof_applicability: str + current_blocker_code: str + current_blocker_fields: tuple[str, ...] + current_blocker_detail: str + next_safe_review: str + historical_payload_status: str + historical_evidence_limit: str + + +@dataclass(frozen=True) +class ProofReadinessReconciliationSummary: + rows: tuple[ProofReadinessReconciliationRow, ...] + status_counts: tuple[tuple[str, int], ...] + conflict_counts_by_lane: tuple[tuple[str, int], ...] + input_status: str + input_message: str + proof_applicability_counts: tuple[tuple[str, int], ...] + current_blocker_counts: tuple[tuple[str, int], ...] + + +@dataclass(frozen=True) +class _LatestProof: + proof: ReviewedBatchProof + review_date_valid: bool + sort_key: tuple[int, int, int] + + +@dataclass(frozen=True) +class _CurrentBlocker: + code: str + fields: tuple[str, ...] + detail: str + next_safe_review: str + + +@dataclass(frozen=True) +class _MissingDcfFields: + fields: tuple[str, ...] + unknown_tokens: tuple[str, ...] + valid: bool + + +def _normalized_columns(frame: pd.DataFrame | None) -> pd.DataFrame: + if frame is None or frame.empty: + return pd.DataFrame() + normalized = frame.copy() + normalized.columns = [str(column).strip().lower().replace(" ", "_") for column in normalized.columns] + return normalized + + +def _explicit_bool(value: object) -> bool | None: + text = "" if value is None or pd.isna(value) else str(value).strip().lower() + if text == "true": + return True + if text == "false": + return False + return None + + +def _readiness_lookup(frame: pd.DataFrame, field: str) -> dict[str, bool | None]: + if frame.empty or "ticker" not in frame.columns or field not in frame.columns: + return {} + return { + str(row["ticker"]).strip().upper(): _explicit_bool(row[field]) + for _, row in frame.iterrows() + if str(row["ticker"]).strip() + } + + +def _valid_tickers(frame: pd.DataFrame) -> tuple[str, ...]: + if frame.empty or "ticker" not in frame.columns: + return () + values = {str(value).strip().upper() for value in frame["ticker"] if str(value).strip()} + return tuple(sorted(values)) + + +def _ticker_tokens(value: object, valid_tickers: set[str]) -> tuple[str, ...]: + tokens = (token.strip().upper() for token in re.split(r"[,;]", str(value or ""))) + return tuple( + dict.fromkeys( + token + for token in tokens + if token + and token.lower() not in PLACEHOLDER_TICKER_VALUES + and token in valid_tickers + ) + ) + + +def _proof_applicability( + latest_proof: _LatestProof | None, + *, + ticker: str, + valid_tickers: set[str], +) -> str: + if latest_proof is None: + return "no_applicable_proof" + if not latest_proof.review_date_valid: + return "malformed_review_date" + outcome = str(latest_proof.proof.final_outcome or "").strip().lower() + if outcome not in SUPPORTING_OUTCOMES: + return "non_supporting_outcome" + changed = _ticker_tokens(latest_proof.proof.changed_tickers, valid_tickers) + if ticker in changed: + return "explicit_ticker_change" + if not changed: + return "missing_ticker_change_detail" + return "scope_only_not_supported" + + +def _frame_has_columns(frame: pd.DataFrame, *columns: str) -> bool: + return not frame.empty and all(column in frame.columns for column in columns) + + +def _missing_dcf_fields(value: object) -> _MissingDcfFields: + text = "" if value is None or pd.isna(value) else str(value) + tokens = tuple( + dict.fromkeys( + token.strip().lower() + for token in re.split(r"[,;]", text) + if token.strip() + ) + ) + token_set = set(tokens) + canonical = tuple(field for field in CANONICAL_DCF_FIELDS if field in token_set) + unknown = tuple(token for token in tokens if token not in CANONICAL_DCF_FIELDS) + return _MissingDcfFields( + fields=canonical, + unknown_tokens=unknown, + valid=bool(canonical), + ) + + +def _missing_dcf_lookup(frame: pd.DataFrame) -> dict[str, _MissingDcfFields]: + if not _frame_has_columns(frame, "ticker", "missing_dcf_fields"): + return {} + return { + str(row["ticker"]).strip().upper(): _missing_dcf_fields(row["missing_dcf_fields"]) + for _, row in frame.iterrows() + if str(row["ticker"]).strip() + } + + +def _schema_issues(frames: dict[str, pd.DataFrame]) -> tuple[list[str], dict[str, bool]]: + issues: list[str] = [] + schema_valid: dict[str, bool] = {} + for source, required_columns in INPUT_REQUIRED_COLUMNS.items(): + frame = frames[source] + label = INPUT_LABELS[source] + if frame.empty: + issues.append(f"{label} input is missing or empty") + schema_valid[source] = False + continue + missing = tuple(column for column in required_columns if column not in frame.columns) + if missing: + issues.append(f"{label} is missing required column(s): {', '.join(missing)}") + schema_valid[source] = False + continue + schema_valid[source] = True + return issues, schema_valid + + +def _readiness_value_issues( + frames: dict[str, pd.DataFrame], + schema_valid: dict[str, bool], +) -> list[str]: + issues: list[str] = [] + for source, readiness_fields in INPUT_READINESS_FIELDS.items(): + if not schema_valid[source]: + continue + frame = frames[source] + malformed = tuple( + field + for field in readiness_fields + if any(_explicit_bool(value) is None for value in frame[field]) + ) + if malformed: + issues.append( + f"{INPUT_LABELS[source]} has malformed readiness field(s): {', '.join(malformed)}" + ) + return issues + + +def _lookup_coverage_issues( + *, + valid_tickers: set[str], + current_lookups: dict[tuple[str, str], dict[str, bool | None]], + schema_valid: dict[str, bool], +) -> list[str]: + issues: list[str] = [] + for _, source, field in CANONICAL_LANES: + if not schema_valid[source]: + continue + lookup = current_lookups[(source, field)] + unavailable = sum(1 for ticker in valid_tickers if lookup.get(ticker) is None) + if unavailable: + issues.append( + f"{INPUT_LABELS[source]} has unavailable {field} value(s) for {unavailable} ticker(s)" + ) + return issues + + +def _missing_field_parse_issues( + *, + valid_tickers: set[str], + current_lookups: dict[tuple[str, str], dict[str, bool | None]], + missing_dcf: dict[str, _MissingDcfFields], + dcf_schema_valid: bool, +) -> list[str]: + if not dcf_schema_valid: + return [] + inconsistent = 0 + unknown = 0 + for ticker in valid_tickers: + parse = missing_dcf.get(ticker) + required_by_lane = ( + ( + "fundamentals", + current_lookups[("ticker", "fundamentals_ready")].get(ticker), + FUNDAMENTALS_FIELDS, + ), + ( + "dcf", + current_lookups[("ticker", "dcf_ready")].get(ticker), + CANONICAL_DCF_FIELDS, + ), + ( + "share_count", + current_lookups[("dcf", "has_shares_outstanding")].get(ticker), + ("shares_outstanding",), + ), + ) + for _, current_ready, required_fields in required_by_lane: + if current_ready is not False: + continue + if parse is None or not parse.valid or not any(field in parse.fields for field in required_fields): + inconsistent += 1 + if parse is not None and parse.unknown_tokens: + unknown += 1 + issues: list[str] = [] + if inconsistent: + issues.append( + "DCF readiness has unavailable or lane-inconsistent missing_dcf_fields " + f"for {inconsistent} ticker-lane value(s)" + ) + if unknown: + issues.append( + "DCF readiness has unrecognized missing_dcf_fields tokens in " + f"{unknown} blocked ticker-lane value(s)" + ) + return issues + + +def _blocker(code: str, fields: tuple[str, ...] = (), detail: str = "") -> _CurrentBlocker: + return _CurrentBlocker( + code=code, + fields=fields, + detail=detail, + next_safe_review=NEXT_SAFE_REVIEW[code], + ) + + +def _unknown_missing_field_detail(parse: _MissingDcfFields) -> str: + if not parse.unknown_tokens: + return "" + return ( + " Unrecognized missing-field token(s): " + f"{', '.join(parse.unknown_tokens)}; they are not reported as canonical blocker fields." + ) + + +def _current_blocker( + *, + ticker: str, + lane: str, + current_ready: bool | None, + missing_dcf: dict[str, _MissingDcfFields], + dcf_schema_valid: bool, + canonical_tickers: set[str], + fundamentals_schema_valid: bool, +) -> _CurrentBlocker: + if current_ready is None: + return _blocker( + "current_readiness_input_unavailable", + detail="The authoritative current readiness input is unavailable or malformed.", + ) + if current_ready: + return _blocker("none", detail="No current blocker is reported for this lane.") + if lane == "price": + return _blocker("current_price_missing", detail="Current saved price readiness is false.") + if lane == "peer_mapping": + return _blocker("current_peer_mapping_missing", detail="Current saved peer mapping readiness is false.") + if lane == "peer_valuation_inputs": + return _blocker( + "current_peer_valuation_inputs_missing", + detail="Current saved peer valuation-input readiness is false.", + ) + + if not dcf_schema_valid: + return _blocker( + "current_readiness_input_unavailable", + detail="The current DCF readiness input is unavailable or malformed.", + ) + missing = missing_dcf.get(ticker) + if missing is None: + return _blocker( + "current_readiness_input_unavailable", + detail="The current DCF readiness row for this ticker is unavailable.", + ) + unknown_detail = _unknown_missing_field_detail(missing) + if lane == "share_count": + if not missing.valid or "shares_outstanding" not in missing.fields: + return _blocker( + "current_readiness_input_unavailable", + detail=( + "Current saved share-count readiness is false, but missing_dcf_fields does not name " + f"shares_outstanding.{unknown_detail}" + ), + ) + return _blocker( + "current_required_fields_missing", + ("shares_outstanding",), + "Current DCF readiness reports missing required fields." + unknown_detail, + ) + + fields = missing.fields if lane == "dcf" else tuple( + field for field in missing.fields if field in FUNDAMENTALS_FIELDS + ) + if not missing.valid or not fields: + lane_label = "DCF" if lane == "dcf" else "fundamentals" + return _blocker( + "current_readiness_input_unavailable", + detail=( + f"Current saved {lane_label} readiness is false, but missing_dcf_fields has no " + f"recognized field for this lane.{unknown_detail}" + ), + ) + if not fundamentals_schema_valid: + return _blocker( + "current_readiness_input_unavailable", + detail="The current canonical fundamentals input is unavailable or malformed.", + ) + if ticker not in canonical_tickers: + return _blocker( + "current_canonical_row_missing", + fields, + "No current canonical fundamentals row is present." + unknown_detail, + ) + return _blocker( + "current_required_fields_missing", + fields, + "Current DCF readiness reports missing required fields." + unknown_detail, + ) + + +def _review_date_key(value: object, append_index: int) -> tuple[bool, tuple[int, int, int]]: + text = str(value or "").strip() + try: + parsed = date.fromisoformat(text) + except ValueError: + return False, (0, -1, append_index) + return True, (1, parsed.toordinal(), append_index) + + +def _reconciliation_state(*, current_ready: bool | None, proof_exists: bool, supporting: bool) -> str: + if current_ready is None: + return "not_applicable" + if current_ready and supporting: + return "current_supported_with_matching_proof" + if not current_ready and supporting: + return "historical_supported_currently_blocked" + if current_ready: + return "current_ready_proof_not_supporting" + if proof_exists: + return "currently_blocked_with_non_supporting_history" + return "no_proof_record" + + +def _state_reason(state: str) -> str: + reasons = { + "current_supported_with_matching_proof": ( + "Current saved readiness is true and the latest applicable dated proof outcome is explicitly supporting." + ), + "historical_supported_currently_blocked": ( + "Historical supporting proof conflicts with current saved readiness; current readiness remains authoritative." + ), + "current_ready_proof_not_supporting": ( + "Current saved readiness is true, but the latest applicable proof is absent, malformed, or non-supporting." + ), + "currently_blocked_with_non_supporting_history": ( + "Current saved readiness is blocked and the latest applicable proof is non-supporting." + ), + "no_proof_record": "Current saved readiness is blocked and no applicable proof row is recorded.", + "not_applicable": "The authoritative current readiness field is missing or malformed; no state is inferred.", + } + return reasons[state] + + +def build_proof_readiness_reconciliation( + *, + proofs: Sequence[ReviewedBatchProof], + ticker_readiness: pd.DataFrame, + dcf_readiness: pd.DataFrame, + peer_readiness: pd.DataFrame, + fundamentals: pd.DataFrame, +) -> ProofReadinessReconciliationSummary: + frames = { + "ticker": _normalized_columns(ticker_readiness), + "dcf": _normalized_columns(dcf_readiness), + "peer": _normalized_columns(peer_readiness), + "fundamentals": _normalized_columns(fundamentals), + } + tickers = _valid_tickers(frames["ticker"]) + if not tickers: + return ProofReadinessReconciliationSummary( + rows=(), + status_counts=(), + conflict_counts_by_lane=(), + input_status="unavailable", + input_message="Current ticker readiness is missing or has no valid ticker rows; reconciliation cannot infer state.", + proof_applicability_counts=(), + current_blocker_counts=(), + ) + + current_lookups = { + (source, field): _readiness_lookup(frames[source], field) + for _, source, field in CANONICAL_LANES + } + valid_set = set(tickers) + input_issues, schema_valid = _schema_issues(frames) + input_issues.extend(_readiness_value_issues(frames, schema_valid)) + input_issues.extend( + _lookup_coverage_issues( + valid_tickers=valid_set, + current_lookups=current_lookups, + schema_valid=schema_valid, + ) + ) + missing_dcf = _missing_dcf_lookup(frames["dcf"]) + input_issues.extend( + _missing_field_parse_issues( + valid_tickers=valid_set, + current_lookups=current_lookups, + missing_dcf=missing_dcf, + dcf_schema_valid=schema_valid["dcf"], + ) + ) + canonical_tickers = set(_valid_tickers(frames["fundamentals"])) + + latest: dict[tuple[str, str], _LatestProof] = {} + for append_index, proof in enumerate(proofs): + mapping = LANE_MAPPINGS.get(str(proof.lane or "").strip().lower()) + if mapping is None: + continue + canonical_lane, _, _ = mapping + review_date_valid, sort_key = _review_date_key(proof.review_date, append_index) + candidate = _LatestProof(proof=proof, review_date_valid=review_date_valid, sort_key=sort_key) + for ticker in _ticker_tokens(proof.tickers, valid_set): + key = (ticker, canonical_lane) + existing = latest.get(key) + if existing is None or candidate.sort_key > existing.sort_key: + latest[key] = candidate + + rows: list[ProofReadinessReconciliationRow] = [] + for ticker in tickers: + for lane, source, field in CANONICAL_LANES: + current_ready = current_lookups[(source, field)].get(ticker) + latest_proof = latest.get((ticker, lane)) + proof_exists = latest_proof is not None + latest_outcome = ( + str(latest_proof.proof.final_outcome or "").strip().lower() if latest_proof is not None else "" + ) + proof_applicability = _proof_applicability( + latest_proof, + ticker=ticker, + valid_tickers=valid_set, + ) + supporting = proof_applicability == "explicit_ticker_change" + state = _reconciliation_state( + current_ready=current_ready, + proof_exists=proof_exists, + supporting=supporting, + ) + blocker = _current_blocker( + ticker=ticker, + lane=lane, + current_ready=current_ready, + missing_dcf=missing_dcf, + dcf_schema_valid=schema_valid["dcf"], + canonical_tickers=canonical_tickers, + fundamentals_schema_valid=schema_valid["fundamentals"], + ) + next_safe_review = blocker.next_safe_review + if proof_applicability in {"scope_only_not_supported", "missing_ticker_change_detail"}: + next_safe_review = "Review the proof row; do not reuse it as ticker-level support. " + next_safe_review + rows.append( + ProofReadinessReconciliationRow( + ticker=ticker, + lane=lane, + current_field=field, + current_ready=current_ready, + latest_batch_id=latest_proof.proof.batch_id if latest_proof is not None else "", + latest_review_date=latest_proof.proof.review_date if latest_proof is not None else "", + latest_outcome=latest_outcome, + review_date_valid=latest_proof.review_date_valid if latest_proof is not None else False, + state=state, + reason=_state_reason(state), + proof_applicability=proof_applicability, + current_blocker_code=blocker.code, + current_blocker_fields=blocker.fields, + current_blocker_detail=blocker.detail, + next_safe_review=next_safe_review, + historical_payload_status=( + "structured_payload_not_recorded" if proof_exists else "" + ), + historical_evidence_limit=HISTORICAL_EVIDENCE_LIMIT if proof_exists else "", + ) + ) + + rows.sort(key=lambda row: (STATE_PRIORITY[row.state], row.lane, row.ticker)) + status_counts = Counter(row.state for row in rows) + conflict_counts = Counter( + row.lane for row in rows if row.state == "historical_supported_currently_blocked" + ) + proof_applicability_counts = Counter(row.proof_applicability for row in rows) + current_blocker_counts = Counter(row.current_blocker_code for row in rows) + input_status = "partial" if input_issues else "ready" + input_message = ( + f"Current input issue(s): {'; '.join(dict.fromkeys(input_issues))}. " + "Affected lane diagnoses are unavailable or limited to recognized canonical fields; " + "independent lanes are preserved." + if input_issues + else ( + "Current ticker, DCF, peer, and canonical fundamentals inputs are available for read-only " + "reconciliation." + ) + ) + return ProofReadinessReconciliationSummary( + rows=tuple(rows), + status_counts=tuple(sorted(status_counts.items())), + conflict_counts_by_lane=tuple(sorted(conflict_counts.items())), + proof_applicability_counts=tuple(sorted(proof_applicability_counts.items())), + current_blocker_counts=tuple(sorted(current_blocker_counts.items())), + input_status=input_status, + input_message=input_message, + ) + + +def _read_csv(path: Path) -> pd.DataFrame: + if not path.exists(): + return pd.DataFrame() + try: + return pd.read_csv(path, dtype=str, low_memory=False).fillna("") + except (OSError, pd.errors.EmptyDataError, pd.errors.ParserError): + return pd.DataFrame() + + +def load_proof_readiness_reconciliation( + *, + root: Path, + data_dir: Path | None = None, +) -> ProofReadinessReconciliationSummary: + resolved = Path(root).expanduser().resolve() + data = Path(data_dir).expanduser().resolve() if data_dir is not None else resolved / "data" + return build_proof_readiness_reconciliation( + proofs=load_reviewed_batch_proofs(data / "reviewed_batch_proofs.csv"), + ticker_readiness=_read_csv(data / "reports" / "ticker_readiness_report.csv"), + dcf_readiness=_read_csv(data / "reports" / "dcf_readiness_report.csv"), + peer_readiness=_read_csv(data / "reports" / "peer_readiness_report.csv"), + fundamentals=_read_csv(data / "fundamentals.csv"), + ) + + +def filter_reconciliation_rows( + summary: ProofReadinessReconciliationSummary, + *, + tickers: Sequence[str] = (), + top_n: int = 20, +) -> tuple[ProofReadinessReconciliationRow, ...]: + selected = {str(ticker).strip().upper() for ticker in tickers if str(ticker).strip()} + rows = tuple(row for row in summary.rows if not selected or row.ticker in selected) + return rows[: max(int(top_n), 0)] + + +def _parse_ticker_filter(value: str) -> tuple[str, ...]: + return tuple(dict.fromkeys(token.strip().upper() for token in str(value or "").split(",") if token.strip())) + + +def proof_readiness_reconciliation_payload( + summary: ProofReadinessReconciliationSummary, + *, + tickers: Sequence[str] = (), + top_n: int = 20, +) -> dict[str, object]: + return { + "input_status": summary.input_status, + "input_message": summary.input_message, + "total_rows": len(summary.rows), + "status_counts": dict(summary.status_counts), + "conflict_counts_by_lane": dict(summary.conflict_counts_by_lane), + "proof_applicability_counts": dict(summary.proof_applicability_counts), + "current_blocker_counts": dict(summary.current_blocker_counts), + "displayed_tickers": [str(ticker).strip().upper() for ticker in tickers if str(ticker).strip()], + "rows": [ + asdict(row) + for row in filter_reconciliation_rows(summary, tickers=tickers, top_n=top_n) + ], + "boundary": ( + "Current saved readiness remains authoritative; reconciliation does not restore data, promote readiness, " + "or rewrite proof history. Current blocker diagnosis describes observable saved inputs and does not establish " + "the historical cause." + ), + } + + +def render_proof_readiness_reconciliation( + summary: ProofReadinessReconciliationSummary, + *, + tickers: Sequence[str] = (), + top_n: int = 20, +) -> str: + rows = filter_reconciliation_rows(summary, tickers=tickers, top_n=top_n) + conflict_total = sum(dict(summary.conflict_counts_by_lane).values()) + lines = [ + "Proof-Readiness Reconciliation", + "Read-only: compares append-only historical proof with current saved readiness; it writes no files.", + "Research-only: this is evidence interpretation, not investment advice, a ranking, recommendation, or trade instruction.", + "Current saved readiness remains authoritative; reconciliation does not restore data, promote readiness, or rewrite proof history.", + "", + f"Input status: {summary.input_status}", + f"Input detail: {summary.input_message}", + f"Reconciliation rows: {len(summary.rows):,}", + f"Historical-support/current-readiness conflicts: {conflict_total:,}", + "", + "State counts:", + ] + for state, count in summary.status_counts: + lines.append(f"- {state}: {count:,}") + lines.extend(["", "Proof applicability counts:"]) + if summary.proof_applicability_counts: + for applicability, count in summary.proof_applicability_counts: + lines.append(f"- {applicability}: {count:,}") + else: + lines.append("- none") + lines.extend(["", "Current blocker counts:"]) + if summary.current_blocker_counts: + for blocker, count in summary.current_blocker_counts: + lines.append(f"- {blocker}: {count:,}") + else: + lines.append("- none") + lines.extend(["", "Conflict counts by lane:"]) + if summary.conflict_counts_by_lane: + for lane, count in summary.conflict_counts_by_lane: + lines.append(f"- {lane}: {count:,}") + else: + lines.append("- none") + lines.extend( + [ + "", + f"Rows shown: {len(rows):,}", + "Ticker | Lane | Current ready | Latest proof | Review date | Reconciliation state | Proof applicability | Current blocker | Next safe review", + "--- | --- | --- | --- | --- | --- | --- | --- | ---", + ] + ) + for row in rows: + current = "unavailable" if row.current_ready is None else str(row.current_ready).lower() + latest = f"{row.latest_batch_id}: {row.latest_outcome}" if row.latest_batch_id else "not recorded" + review_date = row.latest_review_date or "not recorded" + blocker = row.current_blocker_code + if row.current_blocker_fields: + blocker += f" ({', '.join(row.current_blocker_fields)})" + lines.append( + f"{row.ticker} | {row.lane} | {current} | {latest} | {review_date} | {row.state} | " + f"{row.proof_applicability} | {blocker} | {row.next_safe_review}" + ) + if not rows: + lines.append("No rows match the requested display filter.") + lines.extend( + [ + "", + "Next safe command: make proof-readiness-reconciliation TOP_N=20", + "Boundary: current blocker diagnosis describes observable saved inputs; it does not establish the historical cause, source rights, field scope, provenance, payload truth, or commercial use.", + ] + ) + return "\n".join(lines) + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Reconcile historical proof outcomes with current readiness.") + parser.add_argument("--root", default=".") + parser.add_argument("--top-n", type=int, default=20) + parser.add_argument("--tickers", default="") + parser.add_argument("--json", action="store_true") + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + args = _build_parser().parse_args(argv) + summary = load_proof_readiness_reconciliation(root=Path(args.root)) + tickers = _parse_ticker_filter(args.tickers) + if args.json: + print( + json.dumps( + proof_readiness_reconciliation_payload(summary, tickers=tickers, top_n=args.top_n), + indent=2, + sort_keys=True, + ) + ) + else: + print(render_proof_readiness_reconciliation(summary, tickers=tickers, top_n=args.top_n)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/prospective_field_proof.py b/src/prospective_field_proof.py new file mode 100644 index 00000000..f48d3a1f --- /dev/null +++ b/src/prospective_field_proof.py @@ -0,0 +1,1279 @@ +"""Strict, append-only records for prospective per-field review proof.""" + +from __future__ import annotations + +import argparse +import csv +import fcntl +import hashlib +import io +import json +import os +import re +import sys +from dataclasses import asdict, dataclass +from datetime import datetime, timedelta +from pathlib import Path +from typing import BinaryIO, Mapping, Sequence + +from src.commercial_source_rights import ( + SourceRights, + commercial_mode_enabled, + load_source_rights_registry, + review_commercial_field_scope, +) +from src.earnings_nowcast_contract import parse_utc_timestamp + + +SCHEMA_VERSION = "prospective-field-proof-v1" +DEFAULT_LEDGER_PATH = "data/prospective_field_proofs.csv" +FIELDS = ( + "schema_version", + "proof_id", + "ticker", + "field_key", + "readiness_contract_version", + "observed_at", + "retrieved_at", + "source_id", + "source_ref", + "source_status", + "rights_status", + "rights_decision_ref", + "payload_status", + "payload_sha256", + "reviewer_id", + "reviewer_decision", + "reviewed_at", + "supersedes_proof_id", +) + +_SOURCE_STATUSES = frozenset({"identified", "unavailable", "disputed"}) +_RIGHTS_STATUSES = frozenset({"approved", "unverified", "restricted", "not_applicable"}) +_PAYLOAD_STATUSES = frozenset({"reviewed", "unavailable", "rejected"}) +_REVIEWER_DECISIONS = frozenset({"accepted", "rejected", "needs_follow_up"}) +_SHA256 = re.compile(r"^[0-9a-f]{64}$") +_PLACEHOLDERS = frozenset({"-", "na", "n/a", "not available", "unknown", "none"}) +_BASE_REQUIRED_FIELDS = frozenset( + { + "schema_version", + "proof_id", + "ticker", + "field_key", + "readiness_contract_version", + "observed_at", + "retrieved_at", + "source_status", + "rights_status", + "payload_status", + "reviewer_decision", + "reviewed_at", + } +) +_OPTIONAL_EVIDENCE_FIELDS = ( + "source_id", + "source_ref", + "rights_decision_ref", + "payload_sha256", + "reviewer_id", +) + + +@dataclass(frozen=True) +class ProspectiveFieldProofRecord: + schema_version: str + proof_id: str + ticker: str + field_key: str + readiness_contract_version: str + observed_at: str + retrieved_at: str + source_id: str + source_ref: str + source_status: str + rights_status: str + rights_decision_ref: str + payload_status: str + payload_sha256: str + reviewer_id: str + reviewer_decision: str + reviewed_at: str + supersedes_proof_id: str + + +@dataclass(frozen=True) +class FieldProofPreview: + state: str + reason: str + technical_write_eligible: bool + proof_identity: str + rights_status: str + commercial_rights_approved: bool + required_supported_fields: tuple[str, ...] + missing_supported_fields: tuple[str, ...] + commercial_evidence_eligible: bool + technical_blockers: tuple[str, ...] + commercial_blockers: tuple[str, ...] + + +@dataclass(frozen=True) +class BatchFieldProofPreview: + mode: str + write_performed: bool + state: str + review_cutoff: str + commercial_mode: bool + ledger_digest: str + input_digest: str + source_rights_registry_digest: str + preview_receipt: str + row_count: int + reviewable_count: int + technical_write_eligible: bool + commercial_evidence_eligible: bool + technical_blockers: tuple[str, ...] + commercial_blockers: tuple[str, ...] + rows: tuple[FieldProofPreview, ...] + + +@dataclass(frozen=True) +class FieldProofAuditRow: + append_index: int + ticker: str + field_key: str + readiness_contract_version: str + revision_number: int + history_state: str + reviewer_decision: str + source_status: str + rights_status: str + payload_status: str + reviewed_at: str + proof_id: str + supersedes_proof_id: str + + +@dataclass(frozen=True) +class FieldProofLedgerAudit: + mode: str + write_performed: bool + state: str + valid: bool + ledger: str + record_count: int + scope_count: int + active_head_count: int + superseded_count: int + accepted_count: int + rejected_count: int + needs_follow_up_count: int + latest_reviewed_at: str + current_blockers: tuple[str, ...] + preview_receipt_persisted: bool + receipt_revalidation_required: bool + rows: tuple[FieldProofAuditRow, ...] + error: str = "" + + +def _text(value: object) -> str: + return str(value or "").strip() + + +def _parse_record_utc_timestamp(value: object, *, label: str) -> datetime: + parsed = parse_utc_timestamp(value, label=label) + cleaned = _text(value) + if cleaned.endswith("Z"): + cleaned = cleaned[:-1] + "+00:00" + if datetime.fromisoformat(cleaned).utcoffset() != timedelta(0): + raise ValueError(f"{label} must use UTC (Z or +00:00)") + return parsed + + +def _normalized_ticker(value: object) -> str: + return _text(value).upper() + + +def _normalized_field_key(value: object) -> str: + return _text(value).lower() + + +def _is_placeholder(value: object) -> bool: + text = _text(value) + lowered = text.lower() + return ( + not text + or lowered in _PLACEHOLDERS + or (lowered.startswith("<") and lowered.endswith(">")) + ) + + +def _canonical_value(record: ProspectiveFieldProofRecord, field: str) -> str: + value = getattr(record, field) + if field == "ticker": + return _normalized_ticker(value) + if field == "field_key": + return _normalized_field_key(value) + return _text(value) + + +def field_proof_identity(record: ProspectiveFieldProofRecord) -> str: + """Return the deterministic identity for the record's reviewed semantics.""" + + payload = { + field: _canonical_value(record, field) + for field in FIELDS + if field not in {"proof_id", "supersedes_proof_id"} + } + return hashlib.sha256( + json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + + +def _read_rows(path: Path | str, *, missing_ok: bool) -> tuple[ProspectiveFieldProofRecord, ...]: + source = Path(path) + if not source.exists(): + if missing_ok: + return () + raise ValueError(f"field proof input does not exist: {source}") + if not source.is_file(): + raise ValueError(f"field proof path is not a regular file: {source}") + with source.open(newline="", encoding="utf-8") as handle: + reader = csv.DictReader(handle, strict=True) + try: + if tuple(reader.fieldnames or ()) != FIELDS: + raise ValueError( + "Field proof ledger header does not match the append-only contract." + ) + records = [] + for row_number, row in enumerate(reader, start=2): + if row.get(None) is not None: + raise ValueError( + f"field proof row {row_number}: contains surplus cells" + ) + records.append( + ProspectiveFieldProofRecord( + **{field: _text(row.get(field)) for field in FIELDS} + ) + ) + except csv.Error as exc: + raise ValueError( + f"field proof CSV parse error at line {max(reader.line_num, 1)}: {exc}" + ) from exc + if missing_ok and not records: + raise ValueError("Field proof ledger must contain at least one data row.") + return tuple(records) + + +def load_field_proofs(path: Path | str) -> tuple[ProspectiveFieldProofRecord, ...]: + """Load a ledger, treating only a missing ledger as an empty state.""" + + records = _read_rows(path, missing_ok=True) + validate_field_proof_ledger(records) + return records + + +def load_proposed_field_proofs(path: Path | str) -> tuple[ProspectiveFieldProofRecord, ...]: + """Load rows proposed for review without assigning them ledger lineage.""" + + records = _read_rows(path, missing_ok=False) + for row_number, record in enumerate(records, start=2): + try: + _validate_record(record) + except ValueError as exc: + raise ValueError(f"input row {row_number}: {exc}") from exc + return records + + +def _validate_record(record: ProspectiveFieldProofRecord) -> None: + if _text(record.schema_version) != SCHEMA_VERSION: + raise ValueError(f"schema_version must be {SCHEMA_VERSION}") + for field in _BASE_REQUIRED_FIELDS: + if _is_placeholder(getattr(record, field)): + raise ValueError(f"{field} is required and cannot be a placeholder") + for field in _OPTIONAL_EVIDENCE_FIELDS: + value = _text(getattr(record, field)) + if value and _is_placeholder(value): + raise ValueError(f"{field} cannot be a placeholder when provided") + + if record.source_status not in _SOURCE_STATUSES: + raise ValueError("source_status is not an allowed value") + if record.rights_status not in _RIGHTS_STATUSES: + raise ValueError("rights_status is not an allowed value") + if record.payload_status not in _PAYLOAD_STATUSES: + raise ValueError("payload_status is not an allowed value") + if record.reviewer_decision not in _REVIEWER_DECISIONS: + raise ValueError("reviewer_decision is not an allowed value") + if record.source_status == "identified": + for field in ("source_id", "source_ref"): + if _is_placeholder(getattr(record, field)): + raise ValueError( + f"source_status=identified requires a non-placeholder {field}" + ) + if record.payload_status == "reviewed" and _is_placeholder(record.payload_sha256): + raise ValueError( + "payload_status=reviewed requires a lowercase SHA-256 payload_sha256" + ) + if _text(record.payload_sha256) and not _SHA256.fullmatch(record.payload_sha256): + raise ValueError("payload_sha256 must be a lowercase SHA-256 digest") + + observed_at = _parse_record_utc_timestamp(record.observed_at, label="observed_at") + retrieved_at = _parse_record_utc_timestamp(record.retrieved_at, label="retrieved_at") + reviewed_at = _parse_record_utc_timestamp(record.reviewed_at, label="reviewed_at") + if observed_at > retrieved_at: + raise ValueError("observed_at cannot be after retrieved_at") + if retrieved_at > reviewed_at: + raise ValueError("retrieved_at cannot be after reviewed_at") + + if record.reviewer_decision == "accepted": + if record.source_status != "identified": + raise ValueError("accepted records require source_status=identified") + if record.payload_status != "reviewed": + raise ValueError("accepted records require payload_status=reviewed") + if _is_placeholder(record.source_id): + raise ValueError("accepted records require a non-placeholder source_id") + if _is_placeholder(record.source_ref): + raise ValueError("accepted records require a non-placeholder source_ref") + if not _SHA256.fullmatch(record.payload_sha256): + raise ValueError("accepted records require a lowercase SHA-256 payload_sha256") + if _is_placeholder(record.reviewer_id): + raise ValueError("accepted records require a non-placeholder reviewer_id") + + if record.proof_id != field_proof_identity(record): + raise ValueError("proof_id must equal semantic identity") + + +def _scope(record: ProspectiveFieldProofRecord) -> tuple[str, str, str]: + return ( + _normalized_ticker(record.ticker), + _normalized_field_key(record.field_key), + _text(record.readiness_contract_version), + ) + + +def validate_field_proof_ledger(records: Sequence[ProspectiveFieldProofRecord]) -> None: + """Fail closed unless every normalized scope is one append-only revision chain.""" + + rows = tuple(records) + for index, record in enumerate(rows, start=2): + try: + _validate_record(record) + except ValueError as exc: + raise ValueError(f"ledger row {index}: {exc}") from exc + + by_id: dict[str, tuple[int, ProspectiveFieldProofRecord]] = {} + identities: dict[str, int] = {} + for index, record in enumerate(rows): + row_number = index + 2 + if record.proof_id in by_id: + prior = by_id[record.proof_id][0] + 2 + raise ValueError( + f"ledger row {row_number}: duplicate proof_id {record.proof_id} " + f"already appears at ledger row {prior}" + ) + identity = field_proof_identity(record) + if identity in identities: + prior = identities[identity] + 2 + raise ValueError( + f"ledger row {row_number}: duplicate proof identity already appears " + f"at ledger row {prior}" + ) + by_id[record.proof_id] = (index, record) + identities[identity] = index + + scopes: dict[tuple[str, str, str], list[tuple[int, ProspectiveFieldProofRecord]]] = {} + for index, record in enumerate(rows): + scopes.setdefault(_scope(record), []).append((index, record)) + + for scope, scoped_rows in scopes.items(): + scoped_ids = {record.proof_id for _, record in scoped_rows} + children: dict[str, list[str]] = {proof_id: [] for proof_id in scoped_ids} + roots: list[str] = [] + for index, record in scoped_rows: + parent_id = record.supersedes_proof_id + if not parent_id: + roots.append(record.proof_id) + continue + parent_entry = by_id.get(parent_id) + if parent_entry is None: + raise ValueError(f"ledger row {index + 2}: missing parent proof {parent_id}") + _, parent = parent_entry + if _scope(parent) != scope: + raise ValueError( + f"ledger row {index + 2}: revision parent must preserve normalized scope" + ) + children[parent_id].append(record.proof_id) + + if not roots: + raise ValueError( + f"ledger scope {' '.join(scope)} contains a revision cycle and has no root" + ) + if len(roots) != 1: + raise ValueError(f"ledger scope {' '.join(scope)} must contain exactly one root") + + for parent_id, child_ids in children.items(): + if len(child_ids) > 1: + raise ValueError( + f"ledger scope {' '.join(scope)} contains a revision fork at {parent_id}; " + "a revision must supersede the current leaf" + ) + + visited: set[str] = set() + current_id = roots[0] + while current_id: + if current_id in visited: + raise ValueError(f"ledger scope {' '.join(scope)} contains a revision cycle") + visited.add(current_id) + child_ids = children[current_id] + current_id = child_ids[0] if child_ids else "" + if visited != scoped_ids: + raise ValueError( + f"ledger scope {' '.join(scope)} contains a disconnected revision cycle" + ) + + for index, record in scoped_rows: + if not record.supersedes_proof_id: + continue + parent_index, parent = by_id[record.supersedes_proof_id] + if parent_index >= index: + raise ValueError( + f"ledger row {index + 2}: revision parent must appear earlier in append order" + ) + if parse_utc_timestamp(record.reviewed_at, label="reviewed_at") <= parse_utc_timestamp( + parent.reviewed_at, label="reviewed_at" + ): + raise ValueError( + f"ledger row {index + 2}: reviewed_at must be strictly later than parent" + ) + + +def _normalized_cutoff(as_of: str) -> str: + return parse_utc_timestamp(as_of, label="field proof review cutoff").isoformat() + + +def _records_digest(records: Sequence[ProspectiveFieldProofRecord]) -> str: + payload = [asdict(record) for record in records] + return hashlib.sha256( + json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + + +def _source_rights_registry_digest( + rights_registry: Mapping[str, SourceRights], +) -> str: + payload = [ + {"lookup_key": source_id, "rights": asdict(rights_registry[source_id])} + for source_id in sorted(rights_registry) + ] + return hashlib.sha256( + json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + + +def _preview_receipt( + *, + review_cutoff: str, + commercial_mode: bool, + ledger_digest: str, + input_digest: str, + source_rights_registry_digest: str, +) -> str: + payload = { + "schema_version": SCHEMA_VERSION, + "review_cutoff": review_cutoff, + "commercial_mode": commercial_mode, + "ledger_digest": ledger_digest, + "input_digest": input_digest, + "source_rights_registry_digest": source_rights_registry_digest, + } + return hashlib.sha256( + json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + + +def _record_is_at_or_before_cutoff( + record: ProspectiveFieldProofRecord, review_cutoff: str +) -> bool: + boundary = parse_utc_timestamp(review_cutoff, label="field proof review cutoff") + return all( + parse_utc_timestamp(getattr(record, field), label=field) <= boundary + for field in ("observed_at", "retrieved_at", "reviewed_at") + ) + + +def _commercial_preview( + proposed: ProspectiveFieldProofRecord, + rights_registry: Mapping[str, SourceRights], +) -> tuple[str, bool, tuple[str, ...], tuple[str, ...], tuple[str, ...]]: + required_fields = (_normalized_field_key(proposed.field_key),) + commercial_review = review_commercial_field_scope( + rights_registry, + _text(proposed.source_id), + required_fields, + ) + blockers: list[str] = [] + if proposed.reviewer_decision != "accepted": + blockers.append(f"reviewer_decision:{proposed.reviewer_decision}") + if proposed.source_status != "identified": + blockers.append(f"source_status:{proposed.source_status}") + if proposed.payload_status != "reviewed": + blockers.append(f"payload_status:{proposed.payload_status}") + if proposed.rights_status != "approved": + blockers.append(f"record_rights_status:{proposed.rights_status}") + if _is_placeholder(proposed.rights_decision_ref): + blockers.append("rights_decision_ref_required") + if not commercial_review.commercial_rights_approved: + blockers.append(f"commercial_rights:{commercial_review.rights_status}") + blockers.extend( + f"registered_field_scope_missing:{field}" + for field in commercial_review.missing_supported_fields + ) + return ( + commercial_review.rights_status, + commercial_review.commercial_rights_approved, + commercial_review.required_supported_fields, + commercial_review.missing_supported_fields, + tuple(blockers), + ) + + +def preview_field_proof_batch( + existing: Sequence[ProspectiveFieldProofRecord], + proposed: Sequence[ProspectiveFieldProofRecord], + *, + as_of: str, + commercial_mode: bool | None = None, + rights_registry: Mapping[str, SourceRights] | None = None, +) -> BatchFieldProofPreview: + """Preview technical recording and commercial eligibility without writing.""" + + current = tuple(existing) + validate_field_proof_ledger(current) + review_cutoff = _normalized_cutoff(as_of) + for row_number, record in enumerate(current, start=2): + if not _record_is_at_or_before_cutoff(record, review_cutoff): + raise ValueError( + f"ledger row {row_number}: proof timestamps must be at or before review cutoff" + ) + + resolved_registry = ( + load_source_rights_registry() if rights_registry is None else rights_registry + ) + resolved_commercial_mode = ( + commercial_mode_enabled() if commercial_mode is None else commercial_mode + ) + proposed_rows = tuple(proposed) + ledger_digest = _records_digest(current) + input_digest = _records_digest(proposed_rows) + registry_digest = _source_rights_registry_digest(resolved_registry) + preview_receipt = _preview_receipt( + review_cutoff=review_cutoff, + commercial_mode=resolved_commercial_mode, + ledger_digest=ledger_digest, + input_digest=input_digest, + source_rights_registry_digest=registry_digest, + ) + virtual_ledger = list(current) + row_previews: list[FieldProofPreview] = [] + technical_blockers: list[str] = [] + commercial_blockers: list[str] = [] + + for index, record in enumerate(proposed_rows, start=1): + row_technical_blockers: list[str] = [] + try: + validate_field_proof_ledger((*virtual_ledger, record)) + except ValueError as exc: + row_technical_blockers.append(str(exc)) + if not row_technical_blockers and not _record_is_at_or_before_cutoff( + record, review_cutoff + ): + row_technical_blockers.append( + "proof timestamps must be at or before review cutoff" + ) + + technical_write_eligible = not row_technical_blockers + if technical_write_eligible: + virtual_ledger.append(record) + state = "reviewable_revision" if record.supersedes_proof_id else "reviewable_new" + reason = "append-only field proof passes the technical contract" + else: + state = "rejected" + reason = row_technical_blockers[0] + + ( + rights_status, + commercial_rights_approved, + required_supported_fields, + missing_supported_fields, + row_commercial_blockers, + ) = _commercial_preview(record, resolved_registry) + row_preview = FieldProofPreview( + state=state, + reason=reason, + technical_write_eligible=technical_write_eligible, + proof_identity=( + field_proof_identity(record) if technical_write_eligible else "" + ), + rights_status=rights_status, + commercial_rights_approved=commercial_rights_approved, + required_supported_fields=required_supported_fields, + missing_supported_fields=missing_supported_fields, + commercial_evidence_eligible=not row_commercial_blockers, + technical_blockers=tuple(row_technical_blockers), + commercial_blockers=row_commercial_blockers, + ) + row_previews.append(row_preview) + technical_blockers.extend( + f"row_{index}:{blocker}" for blocker in row_technical_blockers + ) + commercial_blockers.extend( + f"row_{index}:{blocker}" for blocker in row_commercial_blockers + ) + + technical_write_eligible = bool(row_previews) and not technical_blockers + commercial_evidence_eligible = bool(row_previews) and not commercial_blockers + if not row_previews: + technical_blockers.append("batch:empty_input") + commercial_blockers.append("batch:empty_input") + + return BatchFieldProofPreview( + mode="preview_only", + write_performed=False, + state=( + "empty_batch" + if not row_previews + else "reviewable_batch" + if technical_write_eligible + else "rejected_batch" + ), + review_cutoff=review_cutoff, + commercial_mode=resolved_commercial_mode, + ledger_digest=ledger_digest, + input_digest=input_digest, + source_rights_registry_digest=registry_digest, + preview_receipt=preview_receipt, + row_count=len(row_previews), + reviewable_count=sum(row.technical_write_eligible for row in row_previews), + technical_write_eligible=technical_write_eligible, + commercial_evidence_eligible=commercial_evidence_eligible, + technical_blockers=tuple(technical_blockers), + commercial_blockers=tuple(commercial_blockers), + rows=tuple(row_previews), + ) + + +def _validate_append_preview( + existing: Sequence[ProspectiveFieldProofRecord], + proposed: Sequence[ProspectiveFieldProofRecord], + *, + review_cutoff: str, + commercial_mode: bool, + rights_registry: Mapping[str, SourceRights], + expected_receipt: str, +) -> None: + preview = preview_field_proof_batch( + existing, + proposed, + as_of=review_cutoff, + commercial_mode=commercial_mode, + rights_registry=rights_registry, + ) + if preview.preview_receipt != expected_receipt: + raise ValueError( + "preview receipt mismatch: input, cutoff, ledger, commercial mode, " + "or source-rights registry changed" + ) + if not preview.technical_write_eligible: + raise ValueError(f"{preview.state}: " + "; ".join(preview.technical_blockers)) + if commercial_mode and not preview.commercial_evidence_eligible: + raise ValueError( + "batch_commercial_evidence_review_required: " + + "; ".join(preview.commercial_blockers) + ) + + +def _encode_append_payload( + records: Sequence[ProspectiveFieldProofRecord], + *, + include_header: bool, + leading_newline: bool, +) -> bytes: + buffer = io.StringIO(newline="") + if leading_newline: + buffer.write("\n") + writer = csv.DictWriter(buffer, fieldnames=FIELDS) + if include_header: + writer.writeheader() + writer.writerows(asdict(record) for record in records) + return buffer.getvalue().encode("utf-8") + + +def _open_new_ledger_exclusive(destination: Path) -> BinaryIO: + return destination.open("x+b", buffering=0) + + +def _remove_exclusively_created_ledger( + destination: Path, *, created_stat: os.stat_result +) -> None: + try: + current_stat = os.stat(destination, follow_symlinks=False) + except OSError: + return + if (current_stat.st_dev, current_stat.st_ino) != ( + created_stat.st_dev, + created_stat.st_ino, + ): + return + try: + destination.unlink() + except OSError: + pass + + +def _write_append_payload(handle: BinaryIO, payload: bytes) -> None: + written = handle.write(payload) + if written != len(payload): + raise OSError( + f"short field proof ledger write: wrote {written} of {len(payload)} bytes" + ) + + +def _flush_and_fsync(handle: BinaryIO) -> None: + handle.flush() + os.fsync(handle.fileno()) + + +def _append_payload_with_rollback( + handle: BinaryIO, payload: bytes, *, original_size: int +) -> None: + handle.seek(original_size) + try: + _write_append_payload(handle, payload) + _flush_and_fsync(handle) + except BaseException: + handle.seek(original_size) + handle.truncate() + _flush_and_fsync(handle) + raise + + +def _existing_ledger_needs_delimiter(handle: BinaryIO, *, original_size: int) -> bool: + if original_size == 0: + return False + handle.seek(-1, os.SEEK_END) + return handle.read(1) not in {b"\n", b"\r"} + + +def append_reviewed_field_proof_batch( + path: Path | str, + records: Sequence[ProspectiveFieldProofRecord], + *, + confirm_reviewed: bool, + commercial_mode: bool | None = None, + rights_registry: Mapping[str, SourceRights] | None = None, + review_cutoff: str | None = None, + preview_receipt: str | None = None, +) -> Path: + """Revalidate an exact reviewed preview and append the whole batch once.""" + + if not confirm_reviewed: + raise ValueError("confirm_reviewed is required before append") + if not review_cutoff: + raise ValueError("review_cutoff is required and must match the reviewed preview") + if not preview_receipt: + raise ValueError("preview_receipt is required and must match the reviewed preview") + + destination = Path(path) + proposed = tuple(records) + resolved_registry = ( + load_source_rights_registry() if rights_registry is None else rights_registry + ) + resolved_commercial_mode = ( + commercial_mode_enabled() if commercial_mode is None else commercial_mode + ) + if destination.exists(): + if not destination.is_file(): + raise ValueError(f"field proof path is not a regular file: {destination}") + with destination.open("r+b", buffering=0) as handle: + fcntl.flock(handle.fileno(), fcntl.LOCK_EX) + try: + existing = load_field_proofs(destination) + _validate_append_preview( + existing, + proposed, + review_cutoff=review_cutoff, + commercial_mode=resolved_commercial_mode, + rights_registry=resolved_registry, + expected_receipt=preview_receipt, + ) + original_size = handle.seek(0, os.SEEK_END) + payload = _encode_append_payload( + proposed, + include_header=False, + leading_newline=_existing_ledger_needs_delimiter( + handle, original_size=original_size + ), + ) + _append_payload_with_rollback( + handle, payload, original_size=original_size + ) + finally: + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + return destination + + _validate_append_preview( + (), + proposed, + review_cutoff=review_cutoff, + commercial_mode=resolved_commercial_mode, + rights_registry=resolved_registry, + expected_receipt=preview_receipt, + ) + payload = _encode_append_payload( + proposed, + include_header=True, + leading_newline=False, + ) + destination.parent.mkdir(parents=True, exist_ok=True) + try: + handle = _open_new_ledger_exclusive(destination) + except FileExistsError as exc: + raise ValueError( + "preview receipt mismatch: field proof ledger was created concurrently; " + "a new preview is required" + ) from exc + created_stat = os.fstat(handle.fileno()) + try: + fcntl.flock(handle.fileno(), fcntl.LOCK_EX) + try: + _append_payload_with_rollback(handle, payload, original_size=0) + except BaseException: + try: + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + except OSError: + pass + raise + else: + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + except BaseException: + try: + handle.close() + except OSError: + pass + _remove_exclusively_created_ledger(destination, created_stat=created_stat) + raise + else: + handle.close() + return destination + + +def field_proof_ledger_status(path: Path | str) -> dict[str, object]: + """Describe ledger integrity without creating, repairing, or rewriting it.""" + + source = Path(path) + if not source.exists(): + return { + "empty": True, + "ledger": str(source), + "ledger_present": False, + "mode": "status_read_only", + "record_count": 0, + "state": "absent", + "valid": True, + "write_performed": False, + } + + try: + rows = load_field_proofs(source) + except (OSError, ValueError) as exc: + error = str(exc) + is_empty = False + if source.is_file(): + try: + is_empty = source.stat().st_size == 0 + except OSError: + pass + if "at least one data row" in error: + is_empty = True + return { + "empty": is_empty, + "error": error, + "ledger": str(source), + "ledger_present": True, + "mode": "status_read_only", + "record_count": 0, + "state": "invalid", + "valid": False, + "write_performed": False, + } + + return { + "empty": False, + "ledger": str(source), + "ledger_present": True, + "mode": "status_read_only", + "record_count": len(rows), + "state": "valid", + "valid": True, + "write_performed": False, + } + + +def audit_field_proof_ledger( + path: Path | str, + *, + rights_registry: Mapping[str, SourceRights] | None = None, +) -> FieldProofLedgerAudit: + """Explain append history and current active-head blockers without writing.""" + + source = Path(path) + if not source.exists(): + return FieldProofLedgerAudit( + mode="audit_read_only", + write_performed=False, + state="absent", + valid=True, + ledger=str(source), + record_count=0, + scope_count=0, + active_head_count=0, + superseded_count=0, + accepted_count=0, + rejected_count=0, + needs_follow_up_count=0, + latest_reviewed_at="", + current_blockers=(), + preview_receipt_persisted=False, + receipt_revalidation_required=True, + rows=(), + ) + + try: + records = load_field_proofs(source) + except (OSError, ValueError) as exc: + return FieldProofLedgerAudit( + mode="audit_read_only", + write_performed=False, + state="invalid", + valid=False, + ledger=str(source), + record_count=0, + scope_count=0, + active_head_count=0, + superseded_count=0, + accepted_count=0, + rejected_count=0, + needs_follow_up_count=0, + latest_reviewed_at="", + current_blockers=(), + preview_receipt_persisted=False, + receipt_revalidation_required=True, + rows=(), + error=str(exc), + ) + + superseded_ids = { + record.supersedes_proof_id + for record in records + if record.supersedes_proof_id + } + revision_counts: dict[tuple[str, str, str], int] = {} + audit_rows: list[FieldProofAuditRow] = [] + for append_index, record in enumerate(records, start=1): + scope = _scope(record) + revision_counts[scope] = revision_counts.get(scope, 0) + 1 + audit_rows.append( + FieldProofAuditRow( + append_index=append_index, + ticker=scope[0], + field_key=scope[1], + readiness_contract_version=scope[2], + revision_number=revision_counts[scope], + history_state=( + "superseded" if record.proof_id in superseded_ids else "current" + ), + reviewer_decision=record.reviewer_decision, + source_status=record.source_status, + rights_status=record.rights_status, + payload_status=record.payload_status, + reviewed_at=record.reviewed_at, + proof_id=record.proof_id, + supersedes_proof_id=record.supersedes_proof_id, + ) + ) + + resolved_registry = ( + load_source_rights_registry() if rights_registry is None else rights_registry + ) + current_blockers: list[str] = [] + for record in records: + if record.proof_id in superseded_ids: + continue + *_, blockers = _commercial_preview(record, resolved_registry) + scope_label = "/".join(_scope(record)) + current_blockers.extend(f"{scope_label}:{blocker}" for blocker in blockers) + + return FieldProofLedgerAudit( + mode="audit_read_only", + write_performed=False, + state="valid", + valid=True, + ledger=str(source), + record_count=len(records), + scope_count=len(revision_counts), + active_head_count=len(records) - len(superseded_ids), + superseded_count=len(superseded_ids), + accepted_count=sum(record.reviewer_decision == "accepted" for record in records), + rejected_count=sum(record.reviewer_decision == "rejected" for record in records), + needs_follow_up_count=sum( + record.reviewer_decision == "needs_follow_up" for record in records + ), + latest_reviewed_at=max(record.reviewed_at for record in records), + current_blockers=tuple(current_blockers), + preview_receipt_persisted=False, + receipt_revalidation_required=True, + rows=tuple(audit_rows), + ) + + +def render_field_proof_audit(audit: FieldProofLedgerAudit) -> str: + """Render a read-only operator answer for ledger history and current heads.""" + + lines = [ + "Prospective Field Proof Ledger Audit", + "Read-only: this command does not create, repair, record, or change any file.", + f"ledger: {audit.ledger}", + f"state: {audit.state}", + f"valid: {str(audit.valid).lower()}", + f"record_count: {audit.record_count}", + f"scope_count: {audit.scope_count}", + f"active_head_count: {audit.active_head_count}", + f"superseded_count: {audit.superseded_count}", + f"latest_reviewed_at: {audit.latest_reviewed_at or 'none'}", + "preview_receipt_persisted: false", + "receipt_revalidation_required: true", + "current_blockers: " + ("; ".join(audit.current_blockers) or "none"), + "write_performed: false", + ] + if audit.error: + lines.append(f"error: {audit.error}") + for row in audit.rows: + lines.append( + f"row {row.append_index}: {row.ticker}/{row.field_key}/" + f"{row.readiness_contract_version} revision={row.revision_number} " + f"state={row.history_state} reviewer={row.reviewer_decision} " + f"source={row.source_status} payload={row.payload_status} rights={row.rights_status}" + ) + return "\n".join(lines) + + +def render_field_proof_status(status: Mapping[str, object]) -> str: + """Render a human-readable status with its read-only boundary.""" + + lines = [ + "Prospective Field Proof Ledger Status", + "Read-only: this command does not create, repair, or change any file.", + f"ledger: {status['ledger']}", + f"state: {status['state']}", + f"valid: {str(status['valid']).lower()}", + f"empty: {str(status['empty']).lower()}", + f"record_count: {status['record_count']}", + "write_performed: false", + ] + if status.get("error"): + lines.append(f"error: {status['error']}") + return "\n".join(lines) + + +def render_field_proof_preview(preview: BatchFieldProofPreview) -> str: + """Render an exact preview without implying that a write occurred.""" + + lines = [ + "Prospective Field Proof Read-only Preview", + "Read-only preview: no ledger, input, readiness, canonical, legacy proof, output, or generated file was changed.", + f"state: {preview.state}", + f"write_performed: {str(preview.write_performed).lower()}", + f"row_count: {preview.row_count}", + f"reviewable_count: {preview.reviewable_count}", + f"technical_write_eligible: {str(preview.technical_write_eligible).lower()}", + f"commercial_evidence_eligible: {str(preview.commercial_evidence_eligible).lower()}", + f"review_cutoff: {preview.review_cutoff}", + f"preview_receipt: {preview.preview_receipt}", + "receipt binds: ledger, input, review cutoff, commercial mode, and source-rights registry", + "preview receipt persisted: false", + "technical_blockers: " + + ("; ".join(preview.technical_blockers) or "none"), + "commercial_blockers: " + + ("; ".join(preview.commercial_blockers) or "none"), + ] + for index, row in enumerate(preview.rows, start=1): + lines.append( + f"row {index}: {row.state}; technical={str(row.technical_write_eligible).lower()}; " + f"commercial={str(row.commercial_evidence_eligible).lower()}; reason={row.reason}; " + "technical_blockers=" + + ("|".join(row.technical_blockers) or "none") + + "; commercial_blockers=" + + ("|".join(row.commercial_blockers) or "none") + ) + return "\n".join(lines) + + +def _render_record_result(payload: Mapping[str, object]) -> str: + return "\n".join( + [ + "Prospective Field Proof Explicit Record Append", + "Explicit append: the exact reviewed preview was revalidated before writing.", + f"state: {payload['state']}", + f"ledger: {payload['ledger']}", + f"recorded_count: {payload['recorded_count']}", + f"preview_receipt: {payload['preview_receipt']}", + "write_performed: true", + ] + ) + + +def _print_payload(payload: object, *, json_output: bool, text: str) -> None: + if json_output: + print(json.dumps(payload, indent=2, sort_keys=True)) + else: + print(text) + + +def _cli_error(command: str, exc: Exception, *, json_output: bool) -> int: + message = str(exc) + if json_output: + print( + json.dumps( + { + "command": command, + "error": message, + "state": "invalid", + "write_performed": False, + }, + sort_keys=True, + ), + file=sys.stderr, + ) + else: + print(f"error: {message}", file=sys.stderr) + return 2 + + +def main(argv: list[str] | None = None) -> int: + """Run read-only status/preview or an explicitly confirmed record append.""" + + parser = argparse.ArgumentParser( + description="Inspect or explicitly append prospective per-field proof." + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + status_parser = subparsers.add_parser( + "status", help="Report ledger integrity without writing." + ) + status_parser.add_argument("--ledger", default=DEFAULT_LEDGER_PATH) + status_parser.add_argument("--json", action="store_true") + + audit_parser = subparsers.add_parser( + "audit", help="Explain append history and current blockers without writing." + ) + audit_parser.add_argument("--ledger", default=DEFAULT_LEDGER_PATH) + audit_parser.add_argument("--json", action="store_true") + + preview_parser = subparsers.add_parser( + "preview", help="Validate an exact batch and emit a receipt without writing." + ) + preview_parser.add_argument("--input", required=True) + preview_parser.add_argument("--ledger", default=DEFAULT_LEDGER_PATH) + preview_parser.add_argument("--as-of", required=True) + preview_parser.add_argument("--json", action="store_true") + + record_parser = subparsers.add_parser( + "record", help="Explicitly append a fully revalidated reviewed batch." + ) + record_parser.add_argument("--input", required=True) + record_parser.add_argument("--ledger", default=DEFAULT_LEDGER_PATH) + record_parser.add_argument("--as-of", required=True) + record_parser.add_argument("--preview-receipt", required=True) + record_parser.add_argument("--confirm-reviewed", action="store_true") + record_parser.add_argument("--json", action="store_true") + + args = parser.parse_args(argv) + if args.command == "status": + status = field_proof_ledger_status(args.ledger) + _print_payload( + status, + json_output=args.json, + text=render_field_proof_status(status), + ) + return 0 if status["valid"] else 2 + + if args.command == "audit": + audit = audit_field_proof_ledger(args.ledger) + _print_payload( + asdict(audit), + json_output=args.json, + text=render_field_proof_audit(audit), + ) + return 0 if audit.valid else 2 + + if args.command == "record" and not args.confirm_reviewed: + return _cli_error( + "record", + ValueError( + "record requires --confirm-reviewed after reviewing the exact preview" + ), + json_output=args.json, + ) + + try: + if not _text(args.input): + raise ValueError("input path is required") + if not _text(args.as_of): + raise ValueError("as_of is required and must match the reviewed preview") + existing = load_field_proofs(args.ledger) + proposed = load_proposed_field_proofs(args.input) + if args.command == "preview": + preview = preview_field_proof_batch( + existing, + proposed, + as_of=args.as_of, + commercial_mode=commercial_mode_enabled(), + ) + _print_payload( + asdict(preview), + json_output=args.json, + text=render_field_proof_preview(preview), + ) + return 0 + + if not _text(args.preview_receipt): + raise ValueError( + "preview_receipt is required and must match the reviewed preview" + ) + append_reviewed_field_proof_batch( + args.ledger, + proposed, + confirm_reviewed=True, + review_cutoff=args.as_of, + preview_receipt=args.preview_receipt, + ) + except (OSError, ValueError) as exc: + return _cli_error(args.command, exc, json_output=args.json) + + result = { + "ledger": str(args.ledger), + "mode": "explicit_record_append", + "preview_receipt": args.preview_receipt, + "recorded_count": len(proposed), + "state": "recorded", + "write_performed": True, + } + _print_payload( + result, + json_output=args.json, + text=_render_record_result(result), + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/providers/local_market_data.py b/src/providers/local_market_data.py index 2fb82d25..2feb48e0 100644 --- a/src/providers/local_market_data.py +++ b/src/providers/local_market_data.py @@ -5,6 +5,7 @@ import pandas as pd +from src.peer_evidence_quality import assess_peer_evidence, is_valuation_anchor_eligible from src.providers.local_data_catalog import LocalDataCatalog from src.providers.market_data import ( AnalystEstimateSummary, @@ -316,6 +317,7 @@ def get_peer_summary(self, ticker: str) -> dict[str, Any]: for _, relationship in peer_rows.iterrows(): peer_ticker = str(relationship.get("peer_ticker") or "").strip().upper() peer_result = self.get_earnings(peer_ticker).to_dict() if peer_ticker else {} + evidence_quality = assess_peer_evidence(relationship.to_dict()) trusted_relationships.append( { key: _peer_evidence_value(relationship.get(key)) @@ -325,13 +327,24 @@ def get_peer_summary(self, ticker: str) -> dict[str, Any]: "peer_group", "sector", "industry", + "peer_role", "relationship_rationale", + "comparability_basis", + "valuation_anchor_eligible", "source", "as_of_date", ) if key in relationship.index and pd.notna(relationship.get(key)) } - | {"peer_result": peer_result} + | { + "peer_result": peer_result, + "peer_role": evidence_quality.peer_role, + "relationship_evidence_state": evidence_quality.relationship_state, + "role_state": evidence_quality.role_state, + "comparability_state": evidence_quality.comparability_state, + "valuation_anchor_state": evidence_quality.valuation_anchor_state, + "evidence_quality_blockers": list(evidence_quality.blockers), + } ) candidate_relationships = [ { @@ -418,6 +431,8 @@ def get_peer_valuation_inputs(self, ticker: str) -> list[dict[str, Any]]: return peer_inputs for peer_ticker in peer_rows["peer_ticker"].dropna().astype(str).str.upper().str.strip().tolist(): peer_row = peer_rows.loc[peer_rows["peer_ticker"] == peer_ticker].iloc[-1] + if not is_valuation_anchor_eligible(peer_row.to_dict()): + continue financials = self.get_financials(peer_ticker) try: quote = self.get_quote(peer_ticker) @@ -441,6 +456,10 @@ def get_peer_valuation_inputs(self, ticker: str) -> list[dict[str, Any]]: "peer_group": self._string_value(peer_row, "peer_group"), "sector": self._string_value(peer_row, "sector"), "industry": self._string_value(peer_row, "industry"), + "peer_role": self._string_value(peer_row, "peer_role"), + "relationship_rationale": self._string_value(peer_row, "relationship_rationale"), + "comparability_basis": self._string_value(peer_row, "comparability_basis"), + "valuation_anchor_eligible": self._string_value(peer_row, "valuation_anchor_eligible"), "mapping_source": self._string_value(peer_row, "source"), "mapping_as_of_date": self._string_value(peer_row, "as_of_date"), "source_metadata": [ diff --git a/src/providers/local_schemas.py b/src/providers/local_schemas.py index 419ea9bf..2d48c28b 100644 --- a/src/providers/local_schemas.py +++ b/src/providers/local_schemas.py @@ -164,7 +164,17 @@ class LocalDatasetSchema: "peers": LocalDatasetSchema( dataset_name="peers", required_columns=("ticker", "peer_ticker"), - optional_columns=("peer_group", "sector", "industry", "source", "as_of_date"), + optional_columns=( + "peer_group", + "sector", + "industry", + "peer_role", + "relationship_rationale", + "comparability_basis", + "valuation_anchor_eligible", + "source", + "as_of_date", + ), date_columns=("as_of_date",), ticker_columns=("ticker", "peer_ticker"), ), diff --git a/src/providers/sec_submissions.py b/src/providers/sec_submissions.py index 0ad3045f..5e7711fa 100644 --- a/src/providers/sec_submissions.py +++ b/src/providers/sec_submissions.py @@ -4,14 +4,18 @@ import os import re import time +from dataclasses import dataclass +from html.parser import HTMLParser from pathlib import Path from typing import Any, Callable, Iterable +from urllib.parse import urljoin from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen SEC_SUBMISSIONS_URL_TEMPLATE = "https://data.sec.gov/submissions/CIK{cik}.json" SEC_FILING_DOCUMENT_URL_TEMPLATE = "https://www.sec.gov/Archives/edgar/data/{cik}/{accession}/{primary_document}" +SEC_FILING_INDEX_URL_TEMPLATE = "https://www.sec.gov/Archives/edgar/data/{cik}/{accession_path}/{accession}-index.html" SEC_SUBMISSIONS_METADATA_BOUNDARY = ( "SEC submissions metadata supports ticker/entity/SIC/filing-recency evidence only; " "it does not unlock fundamentals, share count, DCF, valuation, earnings, or analyst estimates." @@ -23,6 +27,16 @@ ) SHARE_COUNT_FACT_NAME = "dei:EntityCommonStockSharesOutstanding" SHARE_COUNT_FILING_FORMS = ("10-K", "10-K/A", "10-Q", "10-Q/A", "20-F", "20-F/A", "40-F", "40-F/A") +Q4_EARNINGS_EXHIBIT_TYPES = frozenset(("EX-99", "EX-99.1", "EX-99.2")) + + +@dataclass(frozen=True) +class FiledExhibit: + document_type: str + document_name: str + source_ref: str + cik: str = "" + accession: str = "" def normalize_cik(cik: str | int) -> str: @@ -54,6 +68,82 @@ def sec_filing_document_url(cik: str | int, accession: str, primary_document: st ) +def sec_filing_index_url(cik: str | int, accession: str) -> str: + normalized_cik = normalize_cik(cik).lstrip("0") + accession_text = _accession_no_dashes(accession) + accession_display = str(accession or "").strip() + if not accession_text or not accession_display: + raise ValueError("SEC filing index URL requires an accession.") + return SEC_FILING_INDEX_URL_TEMPLATE.format( + cik=normalized_cik, + accession_path=accession_text, + accession=accession_display, + ) + + +class _FilingIndexParser(HTMLParser): + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.rows: list[list[tuple[str, str]]] = [] + self._row: list[tuple[str, str]] | None = None + self._cell_text: list[str] | None = None + self._cell_href = "" + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + if tag.lower() == "tr": + self._row = [] + elif tag.lower() in {"td", "th"} and self._row is not None: + self._cell_text = [] + self._cell_href = "" + elif tag.lower() == "a" and self._cell_text is not None: + self._cell_href = dict(attrs).get("href") or "" + + def handle_data(self, data: str) -> None: + if self._cell_text is not None: + self._cell_text.append(data) + + def handle_endtag(self, tag: str) -> None: + lowered = tag.lower() + if lowered in {"td", "th"} and self._row is not None and self._cell_text is not None: + self._row.append((" ".join(self._cell_text).strip(), self._cell_href)) + self._cell_text = None + self._cell_href = "" + elif lowered == "tr" and self._row is not None: + if self._row: + self.rows.append(self._row) + self._row = None + + +def extract_filing_exhibits(index_html: str, *, cik: str, accession: str) -> tuple[FiledExhibit, ...]: + parser = _FilingIndexParser() + parser.feed(index_html or "") + base_url = sec_filing_index_url(cik, accession) + exhibits: list[FiledExhibit] = [] + seen: set[tuple[str, str]] = set() + for row in parser.rows: + document_type = next((text.upper() for text, _href in row if text.upper() in Q4_EARNINGS_EXHIBIT_TYPES), "") + if not document_type: + continue + document_name = next((href or text for text, href in row if href or text.lower().endswith((".htm", ".html"))), "") + if not document_name: + continue + source_ref = urljoin(base_url, document_name) + key = (document_type, source_ref) + if key in seen: + continue + seen.add(key) + exhibits.append( + FiledExhibit( + document_type=document_type, + document_name=document_name, + source_ref=source_ref, + cik=normalize_cik(cik), + accession=str(accession).strip(), + ) + ) + return tuple(exhibits) + + def _require_user_agent(user_agent: str | None = None) -> str: resolved = (user_agent or os.environ.get("SEC_USER_AGENT", "")).strip() if not resolved: @@ -76,6 +166,12 @@ def _filing_document_cache_path(cache_dir: Path, cik: str, accession: str, prima return path / str(primary_document).strip() +def _filing_index_cache_path(cache_dir: Path, cik: str, accession: str) -> Path: + path = cache_dir / "filing_indexes" / f"CIK{normalize_cik(cik)}" / _accession_no_dashes(accession) + path.mkdir(parents=True, exist_ok=True) + return path / "index.html" + + def read_cached_sec_submission(cik: str | int, *, cache_dir: str | Path = "data/cache/sec") -> dict[str, Any] | None: cache_path = _submissions_cache_path(Path(cache_dir), normalize_cik(cik)) if not cache_path.exists(): @@ -308,6 +404,28 @@ def fetch_sec_filing_document( return text +def fetch_sec_filing_index( + cik: str | int, + accession: str, + user_agent: str | None = None, + *, + cache: bool = True, + refresh: bool = False, + cache_dir: str | Path = "data/cache/sec", + sleep_seconds: float = 0.2, + fetcher: Callable[[str, str, float], str] | None = None, +) -> str: + resolved_user_agent = _require_user_agent(user_agent) + normalized_cik = normalize_cik(cik) + cache_path = _filing_index_cache_path(Path(cache_dir), normalized_cik, accession) + if cache and cache_path.exists() and not refresh: + return cache_path.read_text(encoding="utf-8", errors="replace") + text = (fetcher or _fetch_text)(sec_filing_index_url(normalized_cik, accession), resolved_user_agent, sleep_seconds) + if cache: + cache_path.write_text(text, encoding="utf-8") + return text + + _IX_NON_FRACTION_RE = re.compile( r"[^>]*)>(?P.*?)", flags=re.IGNORECASE | re.DOTALL, diff --git a/src/providers/yfinance_provider.py b/src/providers/yfinance_provider.py index 734d8fb1..778fe65f 100644 --- a/src/providers/yfinance_provider.py +++ b/src/providers/yfinance_provider.py @@ -7,6 +7,7 @@ import pandas as pd +from src.commercial_source_rights import enforce_commercial_source_rights from src.providers.market_data import ( AnalystEstimateSummary, EarningsSummary, @@ -27,6 +28,7 @@ class YFinanceProvider(MarketDataProvider): """ def __init__(self) -> None: + enforce_commercial_source_rights("yfinance") try: import yfinance as yf except ImportError as exc: # pragma: no cover - environment-dependent diff --git a/src/public_performance_gate.py b/src/public_performance_gate.py index 56630a75..46485abf 100644 --- a/src/public_performance_gate.py +++ b/src/public_performance_gate.py @@ -110,6 +110,69 @@ class PerformanceGateResult: ) +RESEARCH_ROUTE_SPECS: tuple[PublicRouteSpec, ...] = ( + PublicRouteSpec( + "Research Desk", + "/?mode=research&page=research-desk", + "Weekly research summary", + ( + "Weekly research summary", + "What should I review next?", + "Open Discover", + "Advanced Evidence", + "Research-only", + ), + True, + ), + PublicRouteSpec( + "Discover", + "/?mode=research&page=discover", + "Which stock can I review?", + ( + "Discover", + "Search this review queue", + "Advanced: cohort readiness context", + "Research-only", + ), + True, + ), + PublicRouteSpec( + "Company Workbench", + "/?mode=research&page=company-workbench&ticker=NVDA&open=1", + "USE NOW", + ( + "Company Workbench", + "Advanced: selected-company lane coverage", + "What Changed", + "Research Decision Lab", + "Business Trend", + "Valuation", + "Forward View", + "What Remains Withheld", + "Research Conclusion", + "Next Research Task", + "Research-only", + ), + True, + ), + PublicRouteSpec( + "Monitor", + "/?mode=research&page=monitor", + "WEEKLY RESEARCH SUMMARY", + ( + "WEEKLY RESEARCH SUMMARY", + "Research Discipline Review", + "Research change monitor", + "No unresolved evidence change is queued.", + "Open Discover", + "Advanced: five-company Earnings Nowcast readiness", + "Research-only", + ), + True, + ), +) + + DEFAULT_CHROME_CANDIDATES: tuple[Path, ...] = ( Path("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"), Path("/Applications/Chromium.app/Contents/MacOS/Chromium"), @@ -158,12 +221,16 @@ def summarize_route_timings(samples: Iterable[RouteTimingSample]) -> list[dict[s first_useful = _timing_values(group, run_kind=None, field="first_useful_seconds") warm_full = _timing_values(group, run_kind="warm", field="full_settle_seconds") cold_full = _timing_values(group, run_kind="cold", field="full_settle_seconds") + warm_run_count = sum(sample.success and sample.run_kind == "warm" for sample in group) + cold_run_count = sum(sample.success and sample.run_kind == "cold" for sample in group) rows.append( { "route": route, "viewport": viewport, "run_count": len(group), "failure_count": len(failures), + "warm_run_count": warm_run_count, + "cold_run_count": cold_run_count, "success": not failures, "shell_p90_seconds": nearest_rank_percentile(shell, 90) if shell else None, "first_useful_p90_seconds": nearest_rank_percentile(first_useful, 90) if first_useful else None, @@ -180,6 +247,8 @@ def evaluate_performance_gate( *, critical_routes: set[str], thresholds: PerformanceThresholds, + min_warm_runs: int = 0, + min_cold_runs: int = 0, ) -> PerformanceGateResult: failures: list[str] = [] for row in summary: @@ -189,6 +258,12 @@ def evaluate_performance_gate( failure_count = int(row.get("failure_count", 0)) if failure_count: failures.append(f"{route}: {failure_count} failed timing run(s)") + warm_run_count = int(row.get("warm_run_count", 0)) + cold_run_count = int(row.get("cold_run_count", 0)) + if warm_run_count < min_warm_runs: + failures.append(f"{route}: warm sample count {warm_run_count} is below {min_warm_runs}") + if cold_run_count < min_cold_runs: + failures.append(f"{route}: cold sample count {cold_run_count} is below {min_cold_runs}") checks = ( ("shell_p90_seconds", thresholds.shell_seconds, "shell p90"), ("first_useful_p90_seconds", thresholds.first_useful_seconds, "first-useful p90"), @@ -240,17 +315,27 @@ def performance_result_payload( environment: str, critical_routes: set[str] | None = None, thresholds: PerformanceThresholds | None = None, + workflow: str = "public", + min_warm_runs: int = 0, + min_cold_runs: int = 0, ) -> dict[str, object]: sample_rows = list(samples) limits = thresholds or PerformanceThresholds() summary = summarize_route_timings(sample_rows) critical = critical_routes or {route.name for route in DEFAULT_ROUTE_SPECS if route.critical} if sample_rows: - gate = evaluate_performance_gate(summary, critical_routes=critical, thresholds=limits) + gate = evaluate_performance_gate( + summary, + critical_routes=critical, + thresholds=limits, + min_warm_runs=min_warm_runs, + min_cold_runs=min_cold_runs, + ) else: gate = PerformanceGateResult("failed", ("No browser timing samples were recorded",)) return { "mode": "browser", + "workflow": workflow, "verdict": gate.verdict, "generated_at": datetime.now(timezone.utc).isoformat(), "commit": commit, @@ -276,13 +361,20 @@ def performance_progress_line(sample: RouteTimingSample, *, index: int, total: i return f"{prefix}: passed; first={first}; full={full}" -def performance_contract_payload(base_dir: Path | str | None = None) -> dict[str, object]: +def performance_contract_payload( + base_dir: Path | str | None = None, + *, + route_specs: Iterable[PublicRouteSpec] = DEFAULT_ROUTE_SPECS, + workflow: str = "public", +) -> dict[str, object]: thresholds = PerformanceThresholds() + routes = tuple(route_specs) return { "mode": "contract_only", + "workflow": workflow, "browser_requirement": "playwright plus a local chrome-compatible executable", "demo_snapshot": demo_snapshot_identity(base_dir), - "routes": [asdict(route) for route in DEFAULT_ROUTE_SPECS], + "routes": [asdict(route) for route in routes], "viewports": [asdict(viewport) for viewport in DEFAULT_VIEWPORTS], "thresholds": asdict(thresholds), "boundary": ( @@ -292,14 +384,23 @@ def performance_contract_payload(base_dir: Path | str | None = None) -> dict[str } -def _browser_unavailable_payload(base_dir: Path | str | None = None) -> dict[str, object]: - payload = performance_contract_payload(base_dir) +def _browser_unavailable_payload( + base_dir: Path | str | None = None, + *, + route_specs: Iterable[PublicRouteSpec] = DEFAULT_ROUTE_SPECS, + workflow: str = "public", +) -> dict[str, object]: + payload = performance_contract_payload(base_dir, route_specs=route_specs, workflow=workflow) payload.update( { "mode": "browser", "verdict": "environment_limited", "detail": "The optional Playwright dependency is unavailable; no browser timings were recorded.", - "next": "Install the development dependency, then rerun make public-performance-gate.", + "next": ( + "Install the development dependency, then rerun make commercial-beta-performance-gate." + if workflow == "research" + else "Install the development dependency, then rerun make public-performance-gate." + ), } ) return payload @@ -408,6 +509,13 @@ def _wait_for_visible_text(page, marker: str, *, timeout_seconds: float) -> None raise TimeoutError(f"visible marker not found before timeout: {marker}") from exc +def _horizontal_overflow_pixels(page) -> int: + value = page.evaluate( + "Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth)" + ) + return max(0, int(value or 0)) + + def _measure_route( browser, *, @@ -439,6 +547,9 @@ def _measure_route( for marker in route.full_markers: _wait_for_visible_text(page, marker, timeout_seconds=timeout_seconds) _wait_for_dom_stability(page, timeout_seconds=timeout_seconds) + overflow_pixels = _horizontal_overflow_pixels(page) + if overflow_pixels > 1: + raise RuntimeError(f"horizontal overflow: {overflow_pixels}px") full_settle_seconds = time.perf_counter() - started return RouteTimingSample( route.name, @@ -474,21 +585,24 @@ def run_browser_performance_gate( base_url: str = "", chrome_executable: Path | None = None, progress: Callable[[str], None] | None = None, + route_specs: Iterable[PublicRouteSpec] = DEFAULT_ROUTE_SPECS, + workflow: str = "public", ) -> dict[str, object]: root = resolve_project_root(base_dir) + routes = tuple(route_specs) chrome = chrome_executable or find_chrome_executable() if chrome is None: - payload = _browser_unavailable_payload(root) + payload = _browser_unavailable_payload(root, route_specs=routes, workflow=workflow) payload["detail"] = "No executable Chrome-compatible browser was found; no timings were recorded." return payload try: from playwright.sync_api import sync_playwright except ImportError: - return _browser_unavailable_payload(root) + return _browser_unavailable_payload(root, route_specs=routes, workflow=workflow) samples: list[RouteTimingSample] = [] - total_samples = len(DEFAULT_ROUTE_SPECS) * len(DEFAULT_VIEWPORTS) * (cold_runs + warm_runs) + total_samples = len(routes) * len(DEFAULT_VIEWPORTS) * (cold_runs + warm_runs) def record(sample: RouteTimingSample) -> None: samples.append(sample) @@ -499,7 +613,7 @@ def record(sample: RouteTimingSample) -> None: browser = playwright.chromium.launch(executable_path=str(chrome), headless=True) try: if cold_runs: - for route in DEFAULT_ROUTE_SPECS: + for route in routes: for viewport in DEFAULT_VIEWPORTS: for _ in range(cold_runs): if base_url: @@ -532,7 +646,7 @@ def record(sample: RouteTimingSample) -> None: timeout_seconds=timeout_seconds, ) with server_context as active_url: - for route in DEFAULT_ROUTE_SPECS: + for route in routes: for viewport in DEFAULT_VIEWPORTS: _measure_route( browser, @@ -561,6 +675,10 @@ def record(sample: RouteTimingSample) -> None: samples, commit=_git_commit(root), environment=f"{platform.system()} {platform.machine()} | Chrome: {chrome}", + critical_routes={route.name for route in routes if route.critical}, + workflow=workflow, + min_warm_runs=warm_runs, + min_cold_runs=cold_runs, ) @@ -570,6 +688,12 @@ def main() -> int: mode.add_argument("--contract", action="store_true", help="Print the read-only route and threshold contract.") mode.add_argument("--browser", action="store_true", help="Run the optional real-browser performance gate.") parser.add_argument("--root", default=".", help="Project root containing data/demo/manifest.json.") + parser.add_argument( + "--workflow", + choices=("public", "research"), + default="public", + help="Route workflow to measure.", + ) parser.add_argument("--json", action="store_true", help="Print machine-readable output.") parser.add_argument("--warm-runs", type=int, default=5, help="Recorded warm runs per route and viewport.") parser.add_argument("--cold-runs", type=int, default=1, help="Recorded cold runs per route and viewport.") @@ -584,6 +708,7 @@ def main() -> int: args = parser.parse_args() root = Path(args.root) + route_specs = RESEARCH_ROUTE_SPECS if args.workflow == "research" else DEFAULT_ROUTE_SPECS if args.browser: payload = run_browser_performance_gate( root, @@ -593,6 +718,8 @@ def main() -> int: base_url=args.base_url, chrome_executable=Path(args.chrome) if args.chrome else None, progress=lambda line: print(line, file=sys.stderr, flush=True), + route_specs=route_specs, + workflow=args.workflow, ) output_path = Path(args.output) output_path.parent.mkdir(parents=True, exist_ok=True) @@ -604,14 +731,15 @@ def main() -> int: return 2 return 1 - payload = performance_contract_payload(root) + payload = performance_contract_payload(root, route_specs=route_specs, workflow=args.workflow) if args.json: print(json.dumps(payload, indent=2, sort_keys=True)) else: - print("Public Performance Contract") + title = "Commercial Beta Performance Contract" if args.workflow == "research" else "Public Performance Contract" + print(title) print(payload["boundary"]) print(f"Demo snapshot: {payload['demo_snapshot']['sha256']}") - for route in DEFAULT_ROUTE_SPECS: + for route in route_specs: critical = "critical" if route.critical else "regression" print(f"- {route.name}: {critical} | {route.route} | first useful: {route.first_useful_marker}") print("Thresholds:") diff --git a/src/public_ux_review_checklist.py b/src/public_ux_review_checklist.py index fdcdafd3..c73f6097 100644 --- a/src/public_ux_review_checklist.py +++ b/src/public_ux_review_checklist.py @@ -94,6 +94,8 @@ "git_boundary": "local audit notes only; do not stage unless intentionally reviewed", } +SUCCESSFUL_REVIEW_CLASSIFICATIONS = frozenset({"resolved", "resolved_post_fix"}) + LIVE_REVIEW_PROTOCOL = [ "Create the suggested local audit folder before opening routes.", "Record one note row per page/viewport before changing code or screenshots.", @@ -131,7 +133,7 @@ def public_ux_review_payload() -> dict[str, object]: "First answer visible: yes/no", "Primary next action visible: yes/no", "Advanced/raw details collapsed: yes/no", - "Issue classification: resolved, intentionally_deferred, environment_limited, skipped, or blocked_with_evidence", + "Issue classification: resolved, resolved_post_fix (fresh verified recapture after a fix), intentionally_deferred, environment_limited, skipped, or blocked_with_evidence", ], "stop_before_sharing": STOP_BEFORE_SHARING, "next_safe_commands": NEXT_SAFE_COMMANDS, @@ -411,7 +413,7 @@ def public_ux_review_notes_status(notes_path: str | Path | None = None) -> dict[ for row in rows: classification = row.get("Issue classification", "").strip() or "pending" classification_counts[classification] = classification_counts.get(classification, 0) + 1 - if classification not in {"pending", "resolved"}: + if classification != "pending" and classification not in SUCCESSFUL_REVIEW_CLASSIFICATIONS: problem_rows.append( { "page": row.get("Page", ""), diff --git a/src/quarterly_business_trend.py b/src/quarterly_business_trend.py new file mode 100644 index 00000000..5f6ce83d --- /dev/null +++ b/src/quarterly_business_trend.py @@ -0,0 +1,582 @@ +"""Fail-closed quarterly business trend packets for Personal Research Mode.""" + +from __future__ import annotations + +import csv +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable + +from src.earnings_nowcast_contract import ( + QuarterlyActual, + eps_split_basis_verified, + parse_utc_timestamp, +) +from src.quarterly_cash_generation import ( + QuarterlyBusinessMetricPoint, + QuarterlyBusinessObservation, + derive_quarterly_business_metrics, +) + + +@dataclass(frozen=True) +class QuarterlyMetricTrend: + metric: str + status: str + latest_value: float | None + latest_fiscal_period: str + latest_source_ref: str + sequential_change_pct: float | None + year_over_year_change_pct: float | None + available_periods: tuple[str, ...] + missing_comparisons: tuple[str, ...] + withheld_reason: str + + +@dataclass(frozen=True) +class QuarterlyTrendPacket: + ticker: str + status: str + latest_fiscal_period: str + available_periods: tuple[str, ...] + revenue: QuarterlyMetricTrend + eps: QuarterlyMetricTrend + operating_margin: QuarterlyMetricTrend + free_cash_flow: QuarterlyMetricTrend + fcf_margin: QuarterlyMetricTrend + withheld_metrics: tuple[str, ...] + ambiguous_periods: tuple[str, ...] + revision_count: int + source_confidence: str + q4_policy: str + message: str + canonical_rejected_rows: tuple[dict[str, object], ...] + + +@dataclass(frozen=True) +class QuarterlyActualLoadResult: + actuals: tuple[QuarterlyActual, ...] + accepted_count: int + rejected_count: int + rejected_rows: tuple[dict[str, object], ...] + + +def _optional_float(value: object) -> float | None: + cleaned = str(value or "").strip() + return float(cleaned) if cleaned else None + + +def load_quarterly_actuals_csv(path: Path | str) -> QuarterlyActualLoadResult: + """Load explicit quarterly actual rows without modifying or repairing the source.""" + + source = Path(path) + if not source.is_file(): + return QuarterlyActualLoadResult((), 0, 0, ()) + accepted: list[QuarterlyActual] = [] + rejected: list[dict[str, object]] = [] + with source.open(newline="", encoding="utf-8") as handle: + for row_number, row in enumerate(csv.DictReader(handle), start=2): + try: + accepted.append( + QuarterlyActual( + ticker=row.get("ticker", ""), + fiscal_period=row.get("fiscal_period", ""), + period_end_date=row.get("period_end_date", ""), + reported_at=row.get("reported_at", ""), + revenue_actual=_optional_float(row.get("revenue_actual")), + eps_actual=_optional_float(row.get("eps_actual")), + source=row.get("source", ""), + source_ref=row.get("source_ref", ""), + retrieved_at=row.get("retrieved_at", ""), + revenue_currency=row.get("revenue_currency", ""), + revenue_unit_scale=_optional_float(row.get("revenue_unit_scale")), + revenue_basis=row.get("revenue_basis", ""), + eps_currency=row.get("eps_currency", ""), + eps_basis=row.get("eps_basis", ""), + eps_share_basis=row.get("eps_share_basis", ""), + eps_operations_basis=row.get("eps_operations_basis", ""), + split_adjustment_basis=row.get("split_adjustment_basis", ""), + supersedes_source_ref=row.get("supersedes_source_ref") or None, + ) + ) + except (TypeError, ValueError) as exc: + rejected.append({"row_number": row_number, "reason": str(exc)}) + return QuarterlyActualLoadResult( + actuals=tuple(accepted), + accepted_count=len(accepted), + rejected_count=len(rejected), + rejected_rows=tuple(rejected), + ) + + +def _period_key(period: str) -> tuple[int, int]: + return int(period[:4]), int(period[-1]) + + +def _previous_period(period: str) -> str: + year, quarter = _period_key(period) + return f"{year - 1}-Q4" if quarter == 1 else f"{year}-Q{quarter - 1}" + + +def _prior_year_period(period: str) -> str: + year, quarter = _period_key(period) + return f"{year - 1}-Q{quarter}" + + +def _latest_timestamp(rows: Iterable[QuarterlyActual]) -> QuarterlyActual: + return max(rows, key=lambda row: parse_utc_timestamp(row.reported_at).timestamp()) + + +def _same_observation(left: QuarterlyActual, right: QuarterlyActual) -> bool: + fields = ( + "revenue_actual", + "eps_actual", + "revenue_currency", + "revenue_unit_scale", + "revenue_basis", + "eps_currency", + "eps_basis", + "eps_share_basis", + "eps_operations_basis", + "split_adjustment_basis", + ) + return all(getattr(left, field) == getattr(right, field) for field in fields) + + +def _resolve_period(rows: list[QuarterlyActual]) -> tuple[QuarterlyActual | None, int]: + unique_by_ref = {row.source_ref: row for row in rows} + unique = list(unique_by_ref.values()) + if len(unique) == 1: + return unique[0], 0 + if all(_same_observation(unique[0], row) for row in unique[1:]): + return _latest_timestamp(unique), sum(bool(row.supersedes_source_ref) for row in unique) + superseded_refs = {row.supersedes_source_ref for row in unique if row.supersedes_source_ref} + leaves = [row for row in unique if row.source_ref not in superseded_refs] + if len(leaves) == 1 and leaves[0].supersedes_source_ref: + return leaves[0], sum(bool(row.supersedes_source_ref) for row in unique) + return None, 0 + + +def _revenue_definition(row: QuarterlyActual) -> tuple[object, ...]: + return row.revenue_currency, row.revenue_unit_scale, row.revenue_basis + + +def _eps_definition(row: QuarterlyActual) -> tuple[object, ...]: + return ( + row.eps_currency, + row.eps_basis, + row.eps_share_basis, + row.eps_operations_basis, + row.split_adjustment_basis, + ) + + +def _pct_change(current: float | None, prior: float | None) -> float | None: + if current is None or prior is None or prior == 0: + return None + return round((current - prior) / abs(prior) * 100.0, 6) + + +def _blocked_metric(metric: str, reason: str) -> QuarterlyMetricTrend: + return QuarterlyMetricTrend(metric, "blocked", None, "", "", None, None, (), (), reason) + + +def _withheld_metric(metric: str) -> QuarterlyMetricTrend: + return QuarterlyMetricTrend( + metric, + "withheld", + None, + "", + "", + None, + None, + (), + (), + ( + "A reviewed explicit versioned quarterly source contract and source adapter " + "are required before this metric can be shown." + ), + ) + + +def _metric_trend( + metric: str, + rows_by_period: dict[str, QuarterlyActual], + *, + value_field: str, + definition, +) -> QuarterlyMetricTrend: + unverified_eps_periods = tuple( + sorted( + ( + period + for period, row in rows_by_period.items() + if metric == "eps" + and row.eps_actual is not None + and not eps_split_basis_verified(row.split_adjustment_basis) + ), + key=_period_key, + ) + ) + available = { + period: row + for period, row in rows_by_period.items() + if getattr(row, value_field) is not None + and not ( + metric == "eps" + and not eps_split_basis_verified(row.split_adjustment_basis) + ) + } + if not available: + if unverified_eps_periods: + return _blocked_metric( + metric, + "Quarterly EPS split basis is unverified; explicit primary-source proof is required.", + ) + return _blocked_metric(metric, f"No explicit source-backed quarterly {metric} observation is available.") + periods = tuple(sorted(available, key=_period_key)) + latest_period = periods[-1] + latest = available[latest_period] + latest_value = getattr(latest, value_field) + missing: list[str] = [] + withheld: list[str] = ( + [f"unverified EPS split basis withheld for {', '.join(unverified_eps_periods)}"] + if unverified_eps_periods + else [] + ) + + previous_period = _previous_period(latest_period) + previous = available.get(previous_period) + sequential: float | None = None + if previous is None: + missing.append("previous quarter unavailable") + elif definition(previous) != definition(latest): + withheld.append("sequential comparison uses incompatible metric definitions") + else: + sequential = _pct_change(latest_value, getattr(previous, value_field)) + if sequential is None: + withheld.append("sequential comparison denominator is unavailable or zero") + + prior_year_period = _prior_year_period(latest_period) + prior_year = available.get(prior_year_period) + year_over_year: float | None = None + if prior_year is None: + missing.append("prior-year quarter unavailable") + elif definition(prior_year) != definition(latest): + withheld.append("year-over-year comparison uses incompatible metric definitions") + else: + year_over_year = _pct_change(latest_value, getattr(prior_year, value_field)) + if year_over_year is None: + withheld.append("year-over-year comparison denominator is unavailable or zero") + + status = ( + "ready" + if sequential is not None and year_over_year is not None and not unverified_eps_periods + else "partial" + ) + return QuarterlyMetricTrend( + metric=metric, + status=status, + latest_value=latest_value, + latest_fiscal_period=latest_period, + latest_source_ref=latest.source_ref, + sequential_change_pct=sequential, + year_over_year_change_pct=year_over_year, + available_periods=periods, + missing_comparisons=tuple(missing), + withheld_reason="; ".join(withheld), + ) + + +def _point_metric_trend( + metric: str, + points: Iterable[QuarterlyBusinessMetricPoint], + *, + blockers: tuple[str, ...], +) -> QuarterlyMetricTrend: + available = {point.fiscal_period: point for point in points if point.metric == metric} + if not available: + tokens = { + "operating_margin": ("operating_income", "operating_margin"), + "free_cash_flow": ("cash_from_operations", "capital_expenditures", "free_cash_flow"), + "fcf_margin": ( + "cash_from_operations", + "capital_expenditures", + "free_cash_flow", + "fcf_margin", + ), + }[metric] + matching = tuple( + blocker.replace("_", " ") + for blocker in blockers + if any(token in blocker for token in tokens) + ) + reason = "; ".join(matching) or "Required quarterly components are unavailable or incompatible." + return _blocked_metric(metric, reason) + + periods = tuple(sorted(available, key=_period_key)) + latest_period = periods[-1] + latest = available[latest_period] + missing: list[str] = [] + withheld: list[str] = [] + + previous_period = _previous_period(latest_period) + previous = available.get(previous_period) + sequential: float | None = None + if previous is None: + missing.append("previous quarter unavailable") + elif previous.definition != latest.definition: + withheld.append("sequential comparison uses incompatible metric definitions") + else: + sequential = _pct_change(latest.value, previous.value) + if sequential is None: + withheld.append("sequential comparison denominator is unavailable or zero") + + prior_year_period = _prior_year_period(latest_period) + prior_year = available.get(prior_year_period) + year_over_year: float | None = None + if prior_year is None: + missing.append("prior-year quarter unavailable") + elif prior_year.definition != latest.definition: + withheld.append("year-over-year comparison uses incompatible metric definitions") + else: + year_over_year = _pct_change(latest.value, prior_year.value) + if year_over_year is None: + withheld.append("year-over-year comparison denominator is unavailable or zero") + + return QuarterlyMetricTrend( + metric=metric, + status="ready" if sequential is not None and year_over_year is not None else "partial", + latest_value=latest.value, + latest_fiscal_period=latest_period, + latest_source_ref=";".join(latest.source_refs), + sequential_change_pct=sequential, + year_over_year_change_pct=year_over_year, + available_periods=periods, + missing_comparisons=tuple(missing), + withheld_reason="; ".join(withheld), + ) + + +def _supplemental_trends( + ticker: str, + observations: tuple[QuarterlyBusinessObservation, ...], + revenues: Iterable[QuarterlyActual], + *, + as_of: str | None, +) -> tuple[QuarterlyMetricTrend, QuarterlyMetricTrend, QuarterlyMetricTrend, int]: + if not observations: + return ( + _withheld_metric("operating_margin"), + _withheld_metric("free_cash_flow"), + _withheld_metric("fcf_margin"), + 0, + ) + derived = derive_quarterly_business_metrics( + ticker, + observations, + revenues, + as_of=as_of, + ) + return ( + _point_metric_trend("operating_margin", derived.points, blockers=derived.blockers), + _point_metric_trend("free_cash_flow", derived.points, blockers=derived.blockers), + _point_metric_trend("fcf_margin", derived.points, blockers=derived.blockers), + derived.revision_count, + ) + + +def _withheld_metric_names(*trends: QuarterlyMetricTrend) -> tuple[str, ...]: + return tuple(trend.metric for trend in trends if trend.status in {"blocked", "withheld"}) + + +def build_quarterly_trend_packet( + ticker: str, + actuals: Iterable[QuarterlyActual], + *, + as_of: str | None = None, + business_observations: Iterable[QuarterlyBusinessObservation] = (), +) -> QuarterlyTrendPacket: + symbol = str(ticker or "").strip().upper() + cutoff = parse_utc_timestamp(as_of).timestamp() if as_of else None + matching = [ + row + for row in actuals + if row.ticker == symbol + and (cutoff is None or parse_utc_timestamp(row.reported_at).timestamp() <= cutoff) + ] + grouped: dict[str, list[QuarterlyActual]] = {} + for row in matching: + grouped.setdefault(row.fiscal_period, []).append(row) + + resolved: dict[str, QuarterlyActual] = {} + ambiguous: list[str] = [] + revision_count = 0 + for period, rows in grouped.items(): + chosen, revisions = _resolve_period(rows) + revision_count += revisions + if chosen is None: + ambiguous.append(period) + else: + resolved[period] = chosen + + supplemental_input = tuple(business_observations) + operating_margin, free_cash_flow, fcf_margin, supplemental_revisions = _supplemental_trends( + symbol, + supplemental_input, + resolved.values(), + as_of=as_of, + ) + revision_count += supplemental_revisions + + if not resolved: + reason = ( + "Quarterly observations are ambiguous because explicit revision lineage is missing." + if ambiguous + else "No source-backed quarterly actual is available by the review cutoff." + ) + metric_reason = "Quarterly actual is ambiguous; explicit revision lineage is required." if ambiguous else reason + return QuarterlyTrendPacket( + ticker=symbol, + status="blocked", + latest_fiscal_period="", + available_periods=(), + revenue=_blocked_metric("revenue", metric_reason), + eps=_blocked_metric("eps", metric_reason), + operating_margin=operating_margin, + free_cash_flow=free_cash_flow, + fcf_margin=fcf_margin, + withheld_metrics=_withheld_metric_names(operating_margin, free_cash_flow, fcf_margin), + ambiguous_periods=tuple(sorted(ambiguous, key=_period_key)), + revision_count=revision_count, + source_confidence=( + "source_backed" + if any( + trend.latest_value is not None + for trend in (operating_margin, free_cash_flow, fcf_margin) + ) + else "withheld" + ), + q4_policy="explicit_filed_quarter_only", + message=( + f"{reason} Independent quarterly cash-generation evidence remains separately reviewable." + if any( + trend.latest_value is not None + for trend in (operating_margin, free_cash_flow, fcf_margin) + ) + else reason + ), + canonical_rejected_rows=(), + ) + + periods = tuple(sorted(resolved, key=_period_key)) + revenue = _metric_trend("revenue", resolved, value_field="revenue_actual", definition=_revenue_definition) + eps = _metric_trend("eps", resolved, value_field="eps_actual", definition=_eps_definition) + status = "ready" if revenue.status == "ready" and eps.status == "ready" else "partial" + return QuarterlyTrendPacket( + ticker=symbol, + status=status, + latest_fiscal_period=periods[-1], + available_periods=periods, + revenue=revenue, + eps=eps, + operating_margin=operating_margin, + free_cash_flow=free_cash_flow, + fcf_margin=fcf_margin, + withheld_metrics=_withheld_metric_names(operating_margin, free_cash_flow, fcf_margin), + ambiguous_periods=tuple(sorted(ambiguous, key=_period_key)), + revision_count=revision_count, + source_confidence="source_backed", + q4_policy="explicit_filed_quarter_only", + message=( + "Comparable quarterly Revenue and EPS trends are available." + if status == "ready" + else "Quarterly evidence is partial; unavailable comparisons remain withheld." + ), + canonical_rejected_rows=(), + ) + + +def build_quarterly_trend_packet_from_load( + ticker: str, + loaded: QuarterlyActualLoadResult, + *, + as_of: str | None = None, + business_observations: Iterable[QuarterlyBusinessObservation] = (), +) -> QuarterlyTrendPacket: + """Build only when the complete canonical ledger passes row validation.""" + + if loaded.rejected_count: + reason = ( + "The canonical quarterly ledger is partially rejected; correct and " + "review every invalid row before using any quarterly trend." + ) + return QuarterlyTrendPacket( + ticker=str(ticker or "").strip().upper(), + status="blocked", + latest_fiscal_period="", + available_periods=(), + revenue=_blocked_metric("revenue", reason), + eps=_blocked_metric("eps", reason), + operating_margin=_blocked_metric("operating_margin", reason), + free_cash_flow=_blocked_metric("free_cash_flow", reason), + fcf_margin=_blocked_metric("fcf_margin", reason), + withheld_metrics=( + "revenue", + "eps", + "operating_margin", + "free_cash_flow", + "fcf_margin", + ), + ambiguous_periods=(), + revision_count=0, + source_confidence="withheld", + q4_policy="explicit_filed_quarter_only", + message=reason, + canonical_rejected_rows=loaded.rejected_rows, + ) + return build_quarterly_trend_packet( + ticker, + loaded.actuals, + as_of=as_of, + business_observations=business_observations, + ) + + +def _display_change(value: float | None) -> str: + return f"{value:+.1f}%" if value is not None else "withheld" + + +def _display_value(trend: QuarterlyMetricTrend) -> object: + if trend.latest_value is None: + return "withheld" + if trend.metric in {"operating_margin", "fcf_margin"}: + return f"{trend.latest_value * 100.0:.1f}%" + return trend.latest_value + + +def quarterly_trend_rows(packet: QuarterlyTrendPacket) -> list[dict[str, object]]: + rows: list[dict[str, object]] = [] + for label, trend in ( + ("Revenue", packet.revenue), + ("EPS", packet.eps), + ("Operating margin", packet.operating_margin), + ("Free cash flow", packet.free_cash_flow), + ("FCF margin", packet.fcf_margin), + ): + boundary_parts = list(trend.missing_comparisons) + if trend.withheld_reason: + boundary_parts.append(trend.withheld_reason) + rows.append( + { + "Metric": label, + "State": trend.status, + "Latest period": trend.latest_fiscal_period or "unavailable", + "Latest value": _display_value(trend), + "Sequential": _display_change(trend.sequential_change_pct), + "Year over year": _display_change(trend.year_over_year_change_pct), + "Source reference": trend.latest_source_ref or "unavailable", + "Boundary": "; ".join(boundary_parts) or "Comparable source-backed periods available.", + } + ) + return rows diff --git a/src/quarterly_cash_generation.py b/src/quarterly_cash_generation.py new file mode 100644 index 00000000..367fd630 --- /dev/null +++ b/src/quarterly_cash_generation.py @@ -0,0 +1,333 @@ +"""In-memory, fail-closed quarterly cash-generation evidence contracts.""" + +from __future__ import annotations + +import math +import re +from dataclasses import dataclass +from datetime import date +from typing import Iterable + +from src.earnings_nowcast_contract import QuarterlyActual, parse_utc_timestamp + + +SUPPORTED_COMPONENT_METRICS = frozenset( + {"operating_income", "cash_from_operations", "capital_expenditures"} +) +_FISCAL_PERIOD_PATTERN = re.compile(r"^\d{4}-Q[1-4]$") + + +def _required_text(value: object, *, label: str) -> str: + cleaned = str(value or "").strip() + if not cleaned: + raise ValueError(f"{label} is required") + return cleaned + + +def _normalized_code(value: object, *, label: str) -> str: + return _required_text(value, label=label).lower() + + +def _finite(value: object, *, label: str) -> float: + if isinstance(value, bool): + raise ValueError(f"{label} must be finite") + try: + number = float(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"{label} must be finite") from exc + if not math.isfinite(number): + raise ValueError(f"{label} must be finite") + return number + + +def _positive(value: object, *, label: str) -> float: + number = _finite(value, label=label) + if number <= 0: + raise ValueError(f"{label} must be positive") + return number + + +@dataclass(frozen=True) +class QuarterlyBusinessObservation: + ticker: str + fiscal_period: str + period_end_date: str + metric: str + value: float + currency: str + unit_scale: float + accounting_basis: str + duration_basis: str + source: str + source_ref: str + published_at: str + retrieved_at: str + q4_evidence_state: str = "not_q4" + supersedes_source_ref: str | None = None + + def __post_init__(self) -> None: + ticker = _required_text(self.ticker, label="ticker").upper() + fiscal_period = _required_text(self.fiscal_period, label="fiscal_period").upper() + if not _FISCAL_PERIOD_PATTERN.fullmatch(fiscal_period): + raise ValueError("fiscal_period must use YYYY-Q[1-4]") + try: + period_end_date = date.fromisoformat( + _required_text(self.period_end_date, label="period_end_date") + ).isoformat() + except ValueError as exc: + raise ValueError("period_end_date must use YYYY-MM-DD") from exc + metric = _normalized_code(self.metric, label="metric") + if metric not in SUPPORTED_COMPONENT_METRICS: + raise ValueError("unsupported quarterly business metric") + q4_state = _normalized_code(self.q4_evidence_state, label="q4_evidence_state") + if fiscal_period.endswith("-Q4") and q4_state != "explicit_filed_quarter": + raise ValueError("Q4 requires explicit filed-quarter evidence") + if not fiscal_period.endswith("-Q4") and q4_state != "not_q4": + raise ValueError("non-Q4 evidence must use not_q4") + + object.__setattr__(self, "ticker", ticker) + object.__setattr__(self, "fiscal_period", fiscal_period) + object.__setattr__(self, "period_end_date", period_end_date) + object.__setattr__(self, "metric", metric) + object.__setattr__(self, "value", _finite(self.value, label="value")) + object.__setattr__(self, "currency", _required_text(self.currency, label="currency").upper()) + object.__setattr__(self, "unit_scale", _positive(self.unit_scale, label="unit_scale")) + object.__setattr__( + self, + "accounting_basis", + _normalized_code(self.accounting_basis, label="accounting_basis"), + ) + object.__setattr__( + self, + "duration_basis", + _normalized_code(self.duration_basis, label="duration_basis"), + ) + object.__setattr__(self, "source", _required_text(self.source, label="source")) + object.__setattr__(self, "source_ref", _required_text(self.source_ref, label="source_ref")) + object.__setattr__( + self, + "published_at", + parse_utc_timestamp(self.published_at, label="published_at").isoformat(), + ) + object.__setattr__( + self, + "retrieved_at", + parse_utc_timestamp(self.retrieved_at, label="retrieved_at").isoformat(), + ) + object.__setattr__(self, "q4_evidence_state", q4_state) + supersedes = str(self.supersedes_source_ref or "").strip() or None + object.__setattr__(self, "supersedes_source_ref", supersedes) + + +@dataclass(frozen=True) +class QuarterlyBusinessMetricPoint: + metric: str + fiscal_period: str + period_end_date: str + value: float + definition: tuple[object, ...] + source_refs: tuple[str, ...] + + +@dataclass(frozen=True) +class QuarterlyBusinessDerivation: + points: tuple[QuarterlyBusinessMetricPoint, ...] + blockers: tuple[str, ...] + revision_count: int + supplied_observation_count: int + + +def _component_definition(row: QuarterlyBusinessObservation) -> tuple[object, ...]: + return ( + row.currency, + row.unit_scale, + row.accounting_basis, + row.duration_basis, + row.period_end_date, + ) + + +def _comparison_definition(row: QuarterlyBusinessObservation) -> tuple[object, ...]: + """Return the accounting definition without period-specific identity.""" + + return ( + row.currency, + row.unit_scale, + row.accounting_basis, + row.duration_basis, + ) + + +def _same_component( + left: QuarterlyBusinessObservation, + right: QuarterlyBusinessObservation, +) -> bool: + return left.value == right.value and _component_definition(left) == _component_definition(right) + + +def _resolve_component( + rows: list[QuarterlyBusinessObservation], +) -> tuple[QuarterlyBusinessObservation | None, int]: + unique = list({row.source_ref: row for row in rows}.values()) + if len(unique) == 1: + return unique[0], 0 + if all(_same_component(unique[0], row) for row in unique[1:]): + return max(unique, key=lambda row: parse_utc_timestamp(row.published_at)), sum( + bool(row.supersedes_source_ref) for row in unique + ) + superseded = {row.supersedes_source_ref for row in unique if row.supersedes_source_ref} + leaves = [row for row in unique if row.source_ref not in superseded] + if len(leaves) == 1 and leaves[0].supersedes_source_ref: + return leaves[0], sum(bool(row.supersedes_source_ref) for row in unique) + return None, 0 + + +def _revenue_definition(row: QuarterlyActual) -> tuple[object, ...]: + return ( + row.revenue_currency, + row.revenue_unit_scale, + row.revenue_basis, + "three_months", + row.period_end_date, + ) + + +def _point( + *, + metric: str, + fiscal_period: str, + period_end_date: str, + value: float, + definition: tuple[object, ...], + source_refs: tuple[str, ...], +) -> QuarterlyBusinessMetricPoint: + return QuarterlyBusinessMetricPoint( + metric=metric, + fiscal_period=fiscal_period, + period_end_date=period_end_date, + value=round(value, 12), + definition=definition, + source_refs=source_refs, + ) + + +def derive_quarterly_business_metrics( + ticker: str, + observations: Iterable[QuarterlyBusinessObservation], + revenue_actuals: Iterable[QuarterlyActual], + *, + as_of: str | None = None, +) -> QuarterlyBusinessDerivation: + """Derive descriptive quarterly metrics without persistence or readiness promotion.""" + + symbol = str(ticker or "").strip().upper() + cutoff = parse_utc_timestamp(as_of).timestamp() if as_of else None + supplied = [row for row in observations if row.ticker == symbol] + blockers: list[str] = [] + eligible: list[QuarterlyBusinessObservation] = [] + for row in supplied: + if cutoff is not None and parse_utc_timestamp(row.published_at).timestamp() > cutoff: + blockers.append(f"{row.fiscal_period}:{row.metric}:post_cutoff") + else: + eligible.append(row) + + grouped: dict[tuple[str, str], list[QuarterlyBusinessObservation]] = {} + for row in eligible: + grouped.setdefault((row.fiscal_period, row.metric), []).append(row) + + resolved: dict[tuple[str, str], QuarterlyBusinessObservation] = {} + revision_count = 0 + for key, rows in grouped.items(): + chosen, revisions = _resolve_component(rows) + revision_count += revisions + if chosen is None: + blockers.append(f"{key[0]}:{key[1]}:ambiguous_revision") + else: + resolved[key] = chosen + + revenues: dict[str, QuarterlyActual] = {} + for row in revenue_actuals: + if row.ticker != symbol or row.revenue_actual is None: + continue + if cutoff is not None and parse_utc_timestamp(row.reported_at).timestamp() > cutoff: + continue + existing = revenues.get(row.fiscal_period) + if existing is None or parse_utc_timestamp(row.reported_at) > parse_utc_timestamp(existing.reported_at): + revenues[row.fiscal_period] = row + + periods = sorted({period for period, _metric in resolved}) + points: list[QuarterlyBusinessMetricPoint] = [] + for period in periods: + operating_income = resolved.get((period, "operating_income")) + cash_from_operations = resolved.get((period, "cash_from_operations")) + capital_expenditures = resolved.get((period, "capital_expenditures")) + revenue = revenues.get(period) + + if operating_income is not None: + if ( + revenue is None + or revenue.revenue_actual in (None, 0) + or _component_definition(operating_income) != _revenue_definition(revenue) + ): + blockers.append(f"{period}:operating_margin:incompatible_revenue") + else: + points.append( + _point( + metric="operating_margin", + fiscal_period=period, + period_end_date=operating_income.period_end_date, + value=operating_income.value / revenue.revenue_actual, + definition=_comparison_definition(operating_income), + source_refs=(operating_income.source_ref, revenue.source_ref), + ) + ) + + free_cash_flow_value: float | None = None + free_cash_flow_definition: tuple[object, ...] | None = None + free_cash_flow_refs: tuple[str, ...] = () + if cash_from_operations is not None and capital_expenditures is not None: + if _component_definition(cash_from_operations) != _component_definition(capital_expenditures): + blockers.append(f"{period}:free_cash_flow:incompatible_components") + else: + free_cash_flow_value = cash_from_operations.value + capital_expenditures.value + free_cash_flow_definition = _component_definition(cash_from_operations) + free_cash_flow_refs = ( + cash_from_operations.source_ref, + capital_expenditures.source_ref, + ) + points.append( + _point( + metric="free_cash_flow", + fiscal_period=period, + period_end_date=cash_from_operations.period_end_date, + value=free_cash_flow_value, + definition=_comparison_definition(cash_from_operations), + source_refs=free_cash_flow_refs, + ) + ) + + if free_cash_flow_value is not None and free_cash_flow_definition is not None: + if ( + revenue is None + or revenue.revenue_actual in (None, 0) + or free_cash_flow_definition != _revenue_definition(revenue) + ): + blockers.append(f"{period}:fcf_margin:incompatible_revenue") + else: + points.append( + _point( + metric="fcf_margin", + fiscal_period=period, + period_end_date=str(free_cash_flow_definition[-1]), + value=free_cash_flow_value / revenue.revenue_actual, + definition=_comparison_definition(cash_from_operations), + source_refs=(*free_cash_flow_refs, revenue.source_ref), + ) + ) + + return QuarterlyBusinessDerivation( + points=tuple(sorted(points, key=lambda point: (point.fiscal_period, point.metric))), + blockers=tuple(dict.fromkeys(blockers)), + revision_count=revision_count, + supplied_observation_count=len(supplied), + ) diff --git a/src/quarterly_cash_generation_adapter.py b/src/quarterly_cash_generation_adapter.py new file mode 100644 index 00000000..2924aab8 --- /dev/null +++ b/src/quarterly_cash_generation_adapter.py @@ -0,0 +1,128 @@ +"""Read-only acceptance contract for one-company cash-generation adapters.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Iterable, Mapping + +from src.commercial_source_rights import SourceRights, commercial_eligibility +from src.earnings_nowcast_contract import QuarterlyActual +from src.quarterly_cash_generation import ( + QuarterlyBusinessObservation, + derive_quarterly_business_metrics, +) + + +REQUIRED_SOURCE_FIELDS = frozenset( + {"operating_income", "cash_from_operations", "capital_expenditures"} +) +REQUIRED_DERIVED_METRICS = frozenset( + {"operating_margin", "free_cash_flow", "fcf_margin"} +) + + +@dataclass(frozen=True) +class QuarterlyAdapterAcceptance: + ticker: str + source_id: str + status: str + blockers: tuple[str, ...] + accepted_observation_count: int + reviewed_metrics: tuple[str, ...] + derived_point_count: int + explicit_q4_periods: tuple[str, ...] + rights_status: str + production_activation: bool = False + readiness_promotions: tuple[str, ...] = () + + +def assess_quarterly_cash_generation_adapter( + ticker: str, + source_id: str, + observations: Iterable[QuarterlyBusinessObservation], + revenue_actuals: Iterable[QuarterlyActual], + *, + rights_registry: Mapping[str, SourceRights], + as_of: str | None = None, +) -> QuarterlyAdapterAcceptance: + """Assess an in-memory candidate batch without activating or persisting it.""" + + symbol = str(ticker or "").strip().upper() + normalized_source = str(source_id or "").strip() + supplied = tuple(observations) + revenues = tuple(revenue_actuals) + blockers: list[str] = [] + if not symbol: + blockers.append("ticker_required") + if not normalized_source: + blockers.append("source_id_required") + if not supplied: + blockers.append("observations_required") + for other in sorted({row.ticker for row in supplied if row.ticker != symbol}): + blockers.append(f"mixed_ticker:{other}") + for other in sorted({row.source for row in supplied if row.source != normalized_source}): + blockers.append(f"source_mismatch:{other}") + + decision = commercial_eligibility(rights_registry, normalized_source) + if not decision.allowed: + blockers.append(f"source_rights:{decision.status}") + rights = rights_registry.get(normalized_source) + if rights is not None: + missing_fields = sorted(REQUIRED_SOURCE_FIELDS - set(rights.supported_fields)) + if missing_fields: + blockers.append(f"source_fields_missing:{','.join(missing_fields)}") + + candidate_rows = tuple( + row + for row in supplied + if row.ticker == symbol and row.source == normalized_source + ) + metrics_by_period: dict[str, set[str]] = {} + for row in candidate_rows: + metrics_by_period.setdefault(row.fiscal_period, set()).add(row.metric) + for period in sorted(metrics_by_period): + for metric in sorted(REQUIRED_SOURCE_FIELDS - metrics_by_period[period]): + blockers.append(f"{period}:missing_component:{metric}") + + derivation = derive_quarterly_business_metrics( + symbol, + candidate_rows, + revenues, + as_of=as_of, + ) + blockers.extend(derivation.blockers) + points_by_period: dict[str, set[str]] = {} + for point in derivation.points: + points_by_period.setdefault(point.fiscal_period, set()).add(point.metric) + complete_periods = tuple( + sorted( + period + for period, metrics in points_by_period.items() + if REQUIRED_DERIVED_METRICS.issubset(metrics) + ) + ) + if not complete_periods: + blockers.append("complete_derived_period_required") + + stable_blockers = tuple(dict.fromkeys(blockers)) + reviewed_metrics = tuple(sorted({row.metric for row in candidate_rows})) + q4_periods = tuple( + sorted( + { + row.fiscal_period + for row in candidate_rows + if row.fiscal_period.endswith("-Q4") + } + ) + ) + return QuarterlyAdapterAcceptance( + ticker=symbol, + source_id=normalized_source, + status="blocked" if stable_blockers else "accepted_for_review", + blockers=stable_blockers, + accepted_observation_count=0 if stable_blockers else len(candidate_rows), + reviewed_metrics=reviewed_metrics, + derived_point_count=len(derivation.points), + explicit_q4_periods=q4_periods, + rights_status=decision.status, + ) diff --git a/src/readiness_engine.py b/src/readiness_engine.py index f663b881..972bbdea 100644 --- a/src/readiness_engine.py +++ b/src/readiness_engine.py @@ -16,6 +16,7 @@ from src.company_analysis_scope import excludes_company_dcf_for_inputs as company_dcf_excluded from src.loader import normalize_columns from src.paths import format_path_context, resolve_data_dir, resolve_outputs_dir, resolve_project_root +from src.peer_evidence_quality import assess_peer_evidence from src.universe_model import ASSET_TYPES, build_universe_coverage_report, ensure_universe_files, infer_asset_type ALLOWED_CANDIDATE_STATES = {"candidate", "fallback_context", "research_only"} @@ -113,6 +114,8 @@ "peer_momentum_ready_count", "peer_fundamentals_ready_count", "peer_valuation_ready_count", + "peer_valuation_anchor_eligible_count", + "peer_valuation_anchor_blockers", "ready_peer_count", "peer_missing_price_tickers", "peer_missing_momentum_tickers", @@ -506,6 +509,8 @@ def build_peer_readiness_report(root: Path, data_path: Path, master: pd.DataFram "peer_momentum_ready_count": 0, "peer_fundamentals_ready_count": 0, "peer_valuation_ready_count": 0, + "peer_valuation_anchor_eligible_count": 0, + "peer_valuation_anchor_blockers": "", "ready_peer_count": 0, "peer_missing_price_tickers": "", "peer_missing_momentum_tickers": "", @@ -557,15 +562,26 @@ def build_peer_readiness_report(root: Path, data_path: Path, master: pd.DataFram momentum_ready_peers = [] fundamentals_ready_peers = [] valuation_ready_peers = [] + valuation_anchor_eligible_peers: list[str] = [] + valuation_anchor_blockers: list[str] = [] + peer_rows_by_ticker = { + str(row.get("peer_ticker") or "").strip().upper(): row + for row in ticker_peers.to_dict("records") + } for peer in valid_peers: fundamental_row = _select_row(fundamentals, peer) + quality = assess_peer_evidence(peer_rows_by_ticker.get(peer, {})) + if quality.valuation_anchor_state == "eligible": + valuation_anchor_eligible_peers.append(peer) + else: + valuation_anchor_blockers.append(f"{peer}: {', '.join(quality.blockers)}") if peer_price_ready_by_ticker.get(peer, False): price_ready_peers.append(peer) if peer_momentum_ready_by_ticker.get(peer, False): momentum_ready_peers.append(peer) if _has_meaningful_fundamentals(fundamental_row): fundamentals_ready_peers.append(peer) - if _has_dcf_fundamentals(fundamental_row): + if quality.valuation_anchor_state == "eligible" and _has_dcf_fundamentals(fundamental_row): valuation_ready_peers.append(peer) missing_price_peers = sorted(set(valid_peers) - set(price_ready_peers)) missing_momentum_peers = sorted(set(valid_peers) - set(momentum_ready_peers)) @@ -574,7 +590,11 @@ def build_peer_readiness_report(root: Path, data_path: Path, master: pd.DataFram peer_price_ready = len(valid_peers) >= min_peers and len(price_ready_peers) >= min_peers peer_momentum_ready = len(valid_peers) >= min_peers and len(momentum_ready_peers) >= min_peers peer_fundamentals_ready = len(valid_peers) >= min_peers and len(fundamentals_ready_peers) >= min_peers - peer_valuation_ready = len(valid_peers) >= min_peers and len(valuation_ready_peers) >= min_peers + peer_valuation_ready = ( + len(valid_peers) >= min_peers + and len(valuation_anchor_eligible_peers) >= min_peers + and len(valuation_ready_peers) >= min_peers + ) peer_ready = peer_momentum_ready mapping_status = "mapped" if len(valid_peers) >= min_peers else "missing_mapping" if not valid_peers else "insufficient_mapping" blocker_type = "" @@ -586,6 +606,8 @@ def build_peer_readiness_report(root: Path, data_path: Path, master: pd.DataFram blocker_type = "peer_momentum_missing" elif not peer_fundamentals_ready: blocker_type = "peer_fundamentals_missing" + elif len(valuation_anchor_eligible_peers) < min_peers: + blocker_type = "peer_comparability_unreviewed" elif not peer_valuation_ready: blocker_type = "peer_valuation_blocked" group = "" @@ -639,6 +661,11 @@ def build_peer_readiness_report(root: Path, data_path: Path, master: pd.DataFram next_peer_action = f"Add enough local price history for mapped peers: {', '.join(missing_momentum_peers[:5])}." elif blocker_type == "peer_fundamentals_missing": next_peer_action = f"Import trusted fundamentals for mapped peers: {', '.join(missing_fundamentals_peers[:5])}." + elif blocker_type == "peer_comparability_unreviewed": + next_peer_action = ( + "Review peer role, relationship rationale, economic comparability basis, and explicit valuation-anchor " + f"eligibility for mapped peers: {', '.join(sorted(set(valid_peers) - set(valuation_anchor_eligible_peers))[:5])}." + ) elif blocker_type == "peer_valuation_blocked": next_peer_action = f"Import DCF-ready fundamentals for mapped peers: {', '.join(missing_valuation_peers[:5])}." else: @@ -667,6 +694,8 @@ def build_peer_readiness_report(root: Path, data_path: Path, master: pd.DataFram "peer_momentum_ready_count": len(momentum_ready_peers), "peer_fundamentals_ready_count": len(fundamentals_ready_peers), "peer_valuation_ready_count": len(valuation_ready_peers), + "peer_valuation_anchor_eligible_count": len(valuation_anchor_eligible_peers), + "peer_valuation_anchor_blockers": "; ".join(valuation_anchor_blockers), "ready_peer_count": len(momentum_ready_peers), "peer_missing_price_tickers": ", ".join(missing_price_peers[:10]), "peer_missing_momentum_tickers": ", ".join(missing_momentum_peers[:10]), @@ -961,15 +990,14 @@ def build_ticker_readiness_report( *, data_dir: Path | str | None = None, output_dir: Path | str | None = None, + write_outputs: bool = True, ) -> dict[str, pd.DataFrame]: root = resolve_project_root(base_dir) data_path = resolve_data_dir(data_dir, root) outputs_path = resolve_outputs_dir(output_dir, root) reports_path = data_path / "reports" - ensure_universe_files(root, data_dir=data_path) + master, active = ensure_universe_files(root, data_dir=data_path, write_outputs=write_outputs) thresholds = _load_thresholds(root) - master = _read_csv(data_path / "universe_master.csv") - active = _read_csv(data_path / "universe_active.csv") legacy = _read_csv(data_path / "universe.csv") holdings = _read_csv(data_path / "holdings.csv") prices = _read_csv(data_path / "prices.csv") @@ -977,7 +1005,7 @@ def build_ticker_readiness_report( earnings = _read_csv(data_path / "earnings.csv") estimates = _read_csv(data_path / "analyst_estimates.csv") - universe_report = build_universe_coverage_report(root, data_dir=data_path) + universe_report = build_universe_coverage_report(root, data_dir=data_path, write_output=write_outputs) price_report = build_price_coverage_report(root, data_path, master, active, thresholds) fundamentals_report = build_fundamentals_coverage_report(root, data_path, master) peer_report = build_peer_readiness_report(root, data_path, master, thresholds) @@ -1132,16 +1160,17 @@ def build_ticker_readiness_report( "peer_unlock_worklist": peer_unlock_worklist, "data_source_status": source_status, } - for name, frame in reports.items(): - _write(frame, reports_path / f"{name}.csv") - # Compatibility copies for existing dashboard/helpers. - _write(price_report, data_path / "price_coverage_report.csv") - _write(dcf_report, data_path / "dcf_readiness.csv") - _write(earnings_report, data_path / "earnings_readiness.csv") - _write(estimates_report, data_path / "analyst_estimates_readiness.csv") - outputs_path.mkdir(parents=True, exist_ok=True) - _write(feature_summary, outputs_path / "feature_readiness_summary.csv") - _write(peer_unlock_worklist, outputs_path / "peer_unlock_worklist.csv") + if write_outputs: + for name, frame in reports.items(): + _write(frame, reports_path / f"{name}.csv") + # Compatibility copies for existing dashboard/helpers. + _write(price_report, data_path / "price_coverage_report.csv") + _write(dcf_report, data_path / "dcf_readiness.csv") + _write(earnings_report, data_path / "earnings_readiness.csv") + _write(estimates_report, data_path / "analyst_estimates_readiness.csv") + outputs_path.mkdir(parents=True, exist_ok=True) + _write(feature_summary, outputs_path / "feature_readiness_summary.csv") + _write(peer_unlock_worklist, outputs_path / "peer_unlock_worklist.csv") return reports diff --git a/src/readiness_ops.py b/src/readiness_ops.py index e86e8cf0..13eb99c7 100644 --- a/src/readiness_ops.py +++ b/src/readiness_ops.py @@ -9,6 +9,7 @@ from pathlib import Path from typing import Iterable +from src.continuation_gate import ContinuationGate, build_continuation_gate from src.dcf_input_proof_queue import DcfInputProofRow, build_dcf_input_proof_queue_from_files, summarize_missing_input_families from src.paths import resolve_data_dir, resolve_outputs_dir, resolve_project_root from src.profile_context import build_profile_context, render_profile_context_text @@ -1574,13 +1575,29 @@ def render_readiness_ops_center(lanes: list[ReadinessLane]) -> str: return "\n".join(lines) -def render_coverage_frontier(frontier: list[CoverageFrontierOpportunity]) -> str: +def render_coverage_frontier( + frontier: list[CoverageFrontierOpportunity], + *, + continuation_gate: ContinuationGate | None = None, +) -> str: lines = [ "Coverage Frontier Planner", "Read-only: ranks batch data-readiness opportunities by unlock impact. It does not imply data is available.", "Research-only: this is an operations queue, not investment advice or trade instruction.", "", ] + if continuation_gate is not None and continuation_gate.suppress_execution: + lines.extend( + [ + f"Stale readiness continuation gate: {continuation_gate.state}", + f"- Next safe preview: {continuation_gate.next_safe_command}", + f"- Reason: {continuation_gate.reason}", + "- Ranked rows below are planning context only; do not execute their source or coverage commands from stale readiness.", + f"- Rebuild boundary: {continuation_gate.rebuild_command} is a separate intentional reviewed write.", + f"- Stop rule: {continuation_gate.stop_rule}", + "", + ] + ) if not frontier: lines.append("No coverage frontier rows are available. Run make readiness first if saved reports are missing.") return "\n".join(lines) @@ -1670,7 +1687,7 @@ def main(argv: list[str] | None = None) -> int: elif args.coverage_frontier: lanes = build_readiness_ops_lanes(root, data_dir=data_path, output_dir=output_path) frontier = build_coverage_frontier(lanes, top_n=args.top_n) - print(render_coverage_frontier(frontier)) + print(render_coverage_frontier(frontier, continuation_gate=build_continuation_gate(context))) else: lanes = build_readiness_ops_lanes(root, data_dir=data_path, output_dir=output_path) print(render_readiness_ops_center(lanes)) diff --git a/src/readiness_preview.py b/src/readiness_preview.py new file mode 100644 index 00000000..e5906b65 --- /dev/null +++ b/src/readiness_preview.py @@ -0,0 +1,712 @@ +"""Read-only saved-versus-proposed readiness impact preview.""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass, replace +from pathlib import Path +from typing import Mapping + +import pandas as pd + +from src.commercial_source_rights import ( + SourceRights, + commercial_eligibility, + load_source_rights_registry, +) +from src.company_analysis_scope import company_dcf_exclusion_reasons +from src.dcf_price_lineage import DcfPriceLineageReview, review_dcf_price_lineage +from src.loader import normalize_columns +from src.paths import resolve_data_dir, resolve_project_root +from src.readiness_engine import build_ticker_readiness_report + + +STABLE_READINESS_FIELDS = ( + "overall_readiness_state", + "price_ready", + "momentum_ready", + "fundamentals_ready", + "dcf_ready", + "peer_ready", + "earnings_ready", + "analyst_estimates_ready", + "ready_features", + "partial_features", + "blocked_features", + "excluded_features", +) + +BOOLEAN_READINESS_FIELDS = ( + "price_ready", + "momentum_ready", + "fundamentals_ready", + "dcf_ready", + "peer_ready", + "earnings_ready", + "analyst_estimates_ready", +) + +OVERALL_STATES = ("ready", "partial", "blocked", "excluded") +PROMOTION_FIELDS = ("fundamentals_ready", "dcf_ready") +REQUIRED_FUNDAMENTALS_FIELDS = ( + "revenue", + "free_cash_flow", + "fcf_margin", + "shares_outstanding", +) + + +@dataclass(frozen=True) +class ReadinessTickerChange: + ticker: str + fields: tuple[str, ...] + + +@dataclass(frozen=True) +class ReadinessPromotionEvidence: + ticker: str + promoted_fields: tuple[str, ...] + source_id: str + as_of_date: str + source_reference: str + rights_status: str + missing_provenance_fields: tuple[str, ...] + missing_supported_fields: tuple[str, ...] + blockers: tuple[str, ...] + + +@dataclass(frozen=True) +class ReadinessPromotionReview: + status: str + promotion_count: int + fundamentals_promotion_count: int + dcf_promotion_count: int + rights_approved_count: int + rights_review_required_count: int + provenance_complete_count: int + provenance_review_required_count: int + field_scope_complete_count: int + field_scope_review_required_count: int + source_counts: tuple[tuple[str, int], ...] + rights_status_counts: tuple[tuple[str, int], ...] + evidence_rows: tuple[ReadinessPromotionEvidence, ...] + top_n: int + + +@dataclass(frozen=True) +class ReadinessChangeReview: + status: str + added_ticker_count: int + removed_ticker_count: int + newly_ready_counts: tuple[tuple[str, int], ...] + newly_partial_counts: tuple[tuple[str, int], ...] + newly_excluded_counts: tuple[tuple[str, int], ...] + dcf_exclusion_reason_counts: tuple[tuple[str, int], ...] + unexplained_dcf_exclusion_count: int + unexplained_dcf_exclusion_tickers: tuple[str, ...] + + +@dataclass(frozen=True) +class ReadinessImpactPreview: + status: str + saved_ticker_count: int + proposed_ticker_count: int + saved_counts: tuple[tuple[str, int], ...] + proposed_counts: tuple[tuple[str, int], ...] + changed_ticker_count: int + changed_tickers: tuple[ReadinessTickerChange, ...] + top_n: int + saved_path: str + promotion_review: ReadinessPromotionReview | None = None + change_review: ReadinessChangeReview | None = None + dcf_price_lineage_review: DcfPriceLineageReview | None = None + + +def _truthy(value: object) -> bool: + if isinstance(value, bool): + return value + if pd.isna(value): + return False + return str(value).strip().lower() in {"true", "1", "yes", "y"} + + +def _text(value: object) -> str: + if pd.isna(value): + return "" + return str(value).strip() + + +def _stable_value(row: pd.Series, field: str) -> bool | str: + if field in BOOLEAN_READINESS_FIELDS: + return _truthy(row.get(field)) + return _text(row.get(field)) + + +def _index_readiness(frame: pd.DataFrame) -> dict[str, pd.Series]: + if frame.empty or "ticker" not in frame.columns: + return {} + rows: dict[str, pd.Series] = {} + for _, row in frame.iterrows(): + ticker = _text(row.get("ticker")).upper() + if ticker: + rows[ticker] = row + return rows + + +def _count_summary(frame: pd.DataFrame) -> tuple[tuple[str, int], ...]: + indexed = _index_readiness(frame) + rows = list(indexed.values()) + counts: list[tuple[str, int]] = [] + for state in OVERALL_STATES: + counts.append( + ( + f"overall_{state}", + sum(_text(row.get("overall_readiness_state")).lower() == state for row in rows), + ) + ) + for field in BOOLEAN_READINESS_FIELDS: + counts.append((field, sum(_truthy(row.get(field)) for row in rows))) + return tuple(counts) + + +def _promotion_map(saved: pd.DataFrame, proposed: pd.DataFrame) -> dict[str, tuple[str, ...]]: + saved_rows = _index_readiness(saved) + proposed_rows = _index_readiness(proposed) + promotions: dict[str, tuple[str, ...]] = {} + for ticker in sorted(set(proposed_rows)): + proposed_row = proposed_rows[ticker] + saved_row = saved_rows.get(ticker, pd.Series(dtype=object)) + fields = tuple( + field + for field in PROMOTION_FIELDS + if not _truthy(saved_row.get(field)) and _truthy(proposed_row.get(field)) + ) + if fields: + promotions[ticker] = fields + return promotions + + +def _fundamentals_rows_by_ticker(frame: pd.DataFrame) -> dict[str, list[pd.Series]]: + if frame.empty or "ticker" not in frame.columns: + return {} + rows: dict[str, list[pd.Series]] = {} + for _, row in frame.iterrows(): + ticker = _text(row.get("ticker")).upper() + if ticker: + rows.setdefault(ticker, []).append(row) + return rows + + +def _count_values(values: list[str]) -> tuple[tuple[str, int], ...]: + if not values: + return () + counts = pd.Series(values, dtype="string").value_counts() + ordered = sorted( + ((str(name), int(value)) for name, value in counts.items()), + key=lambda item: (-item[1], item[0]), + ) + return tuple(ordered) + + +def _feature_set(value: object) -> set[str]: + text = _text(value) + return {part.strip() for part in text.split(",") if part.strip()} + + +def review_readiness_changes( + saved: pd.DataFrame, + proposed: pd.DataFrame, + fundamentals: pd.DataFrame, +) -> ReadinessChangeReview: + """Summarize semantic feature transitions without changing readiness decisions.""" + + saved_rows = _index_readiness(saved) + proposed_rows = _index_readiness(proposed) + fundamentals_rows = _fundamentals_rows_by_ticker(fundamentals) + newly_ready: list[str] = [] + newly_partial: list[str] = [] + newly_excluded: list[str] = [] + dcf_reasons: list[str] = [] + unexplained_dcf: list[str] = [] + + for ticker in sorted(set(saved_rows) & set(proposed_rows)): + saved_row = saved_rows[ticker] + proposed_row = proposed_rows[ticker] + newly_ready.extend(_feature_set(proposed_row.get("ready_features")) - _feature_set(saved_row.get("ready_features"))) + newly_partial.extend( + _feature_set(proposed_row.get("partial_features")) - _feature_set(saved_row.get("partial_features")) + ) + ticker_newly_excluded = _feature_set(proposed_row.get("excluded_features")) - _feature_set( + saved_row.get("excluded_features") + ) + newly_excluded.extend(ticker_newly_excluded) + if "dcf" not in ticker_newly_excluded: + continue + candidates = fundamentals_rows.get(ticker, []) + fundamentals_row = candidates[0] if len(candidates) == 1 else pd.Series(dtype=object) + reasons = company_dcf_exclusion_reasons( + proposed_row.get("asset_type"), + proposed_row, + fundamentals_row, + ) + if reasons: + dcf_reasons.append(reasons[0]) + else: + unexplained_dcf.append(ticker) + + has_changes = bool( + set(saved_rows) ^ set(proposed_rows) + or newly_ready + or newly_partial + or newly_excluded + ) + status = "unexplained_changes" if unexplained_dcf else "changes_explained" if has_changes else "no_changes" + return ReadinessChangeReview( + status=status, + added_ticker_count=len(set(proposed_rows) - set(saved_rows)), + removed_ticker_count=len(set(saved_rows) - set(proposed_rows)), + newly_ready_counts=_count_values(newly_ready), + newly_partial_counts=_count_values(newly_partial), + newly_excluded_counts=_count_values(newly_excluded), + dcf_exclusion_reason_counts=_count_values(dcf_reasons), + unexplained_dcf_exclusion_count=len(unexplained_dcf), + unexplained_dcf_exclusion_tickers=tuple(unexplained_dcf), + ) + + +def review_readiness_promotions( + saved: pd.DataFrame, + proposed: pd.DataFrame, + fundamentals: pd.DataFrame, + *, + rights_registry: Mapping[str, SourceRights], + top_n: int = 20, +) -> ReadinessPromotionReview: + """Review proposed technical promotions without changing either readiness frame.""" + + if top_n < 1: + raise ValueError("top_n must be at least 1") + promotions = _promotion_map(saved, proposed) + if not promotions: + return ReadinessPromotionReview( + status="no_promotions", + promotion_count=0, + fundamentals_promotion_count=0, + dcf_promotion_count=0, + rights_approved_count=0, + rights_review_required_count=0, + provenance_complete_count=0, + provenance_review_required_count=0, + field_scope_complete_count=0, + field_scope_review_required_count=0, + source_counts=(), + rights_status_counts=(), + evidence_rows=(), + top_n=top_n, + ) + + fundamentals_rows = _fundamentals_rows_by_ticker(fundamentals) + evidence: list[ReadinessPromotionEvidence] = [] + for ticker, promoted_fields in promotions.items(): + candidates = fundamentals_rows.get(ticker, []) + if len(candidates) != 1: + blocker = "missing_fundamentals_row" if not candidates else "duplicate_fundamentals_rows" + evidence.append( + ReadinessPromotionEvidence( + ticker=ticker, + promoted_fields=promoted_fields, + source_id="" if not candidates else "", + as_of_date="", + source_reference="", + rights_status="not_evaluated_missing_evidence" if not candidates else "not_evaluated_ambiguous_evidence", + missing_provenance_fields=("source", "as_of_date", "source_reference"), + missing_supported_fields=REQUIRED_FUNDAMENTALS_FIELDS, + blockers=(blocker,), + ) + ) + continue + + row = candidates[0] + source_id = _text(row.get("source")) + as_of_date = _text(row.get("as_of_date")) + source_reference = _text(row.get("source_ref")) or _text(row.get("sec_accession")) + missing_provenance = tuple( + field + for field, value in ( + ("source", source_id), + ("as_of_date", as_of_date), + ("source_reference", source_reference), + ) + if not value + ) + rights = commercial_eligibility(rights_registry, source_id) + rights_record = rights_registry.get(source_id) + supported = set(rights_record.supported_fields) if rights_record is not None else set() + missing_supported = tuple(field for field in REQUIRED_FUNDAMENTALS_FIELDS if field not in supported) + blockers: list[str] = [] + blockers.extend(f"missing_provenance:{field}" for field in missing_provenance) + if not rights.allowed: + blockers.append(f"commercial_rights:{rights.status}") + if missing_supported: + blockers.append("registered_field_scope_incomplete") + evidence.append( + ReadinessPromotionEvidence( + ticker=ticker, + promoted_fields=promoted_fields, + source_id=source_id or "", + as_of_date=as_of_date, + source_reference=source_reference, + rights_status=rights.status, + missing_provenance_fields=missing_provenance, + missing_supported_fields=missing_supported, + blockers=tuple(blockers), + ) + ) + + rights_approved = sum(item.rights_status == "approved" for item in evidence) + provenance_complete = sum(not item.missing_provenance_fields for item in evidence) + field_scope_complete = sum(not item.missing_supported_fields for item in evidence) + review_complete = all( + item.rights_status == "approved" + and not item.missing_provenance_fields + and not item.missing_supported_fields + for item in evidence + ) + return ReadinessPromotionReview( + status="evidence_review_complete" if review_complete else "evidence_review_required", + promotion_count=len(evidence), + fundamentals_promotion_count=sum("fundamentals_ready" in fields for fields in promotions.values()), + dcf_promotion_count=sum("dcf_ready" in fields for fields in promotions.values()), + rights_approved_count=rights_approved, + rights_review_required_count=len(evidence) - rights_approved, + provenance_complete_count=provenance_complete, + provenance_review_required_count=len(evidence) - provenance_complete, + field_scope_complete_count=field_scope_complete, + field_scope_review_required_count=len(evidence) - field_scope_complete, + source_counts=_count_values([item.source_id for item in evidence]), + rights_status_counts=_count_values([item.rights_status for item in evidence]), + evidence_rows=tuple(evidence[:top_n]), + top_n=top_n, + ) + + +def compare_readiness_frames( + saved: pd.DataFrame, + proposed: pd.DataFrame, + *, + top_n: int = 20, + saved_path: str = "data/reports/ticker_readiness_report.csv", +) -> ReadinessImpactPreview: + if top_n < 1: + raise ValueError("top_n must be at least 1") + saved_rows = _index_readiness(saved) + proposed_rows = _index_readiness(proposed) + changes: list[ReadinessTickerChange] = [] + for ticker in sorted(set(saved_rows) | set(proposed_rows)): + if ticker not in saved_rows or ticker not in proposed_rows: + fields = ("row_presence",) + else: + fields = tuple( + field + for field in STABLE_READINESS_FIELDS + if _stable_value(saved_rows[ticker], field) != _stable_value(proposed_rows[ticker], field) + ) + if fields: + changes.append(ReadinessTickerChange(ticker=ticker, fields=fields)) + return ReadinessImpactPreview( + status="changes_detected" if changes else "no_readiness_changes", + saved_ticker_count=len(saved_rows), + proposed_ticker_count=len(proposed_rows), + saved_counts=_count_summary(saved), + proposed_counts=_count_summary(proposed), + changed_ticker_count=len(changes), + changed_tickers=tuple(changes[:top_n]), + top_n=top_n, + saved_path=saved_path, + ) + + +def build_readiness_impact_preview( + root: Path | str, + *, + data_dir: Path | str | None = None, + top_n: int = 20, + rights_registry: Mapping[str, SourceRights] | None = None, + review_cutoff: str | None = None, +) -> ReadinessImpactPreview: + project_root = resolve_project_root(root) + data_path = resolve_data_dir(data_dir, project_root) + saved_path = data_path / "reports" / "ticker_readiness_report.csv" + if not saved_path.exists(): + return ReadinessImpactPreview( + status="missing_saved_snapshot", + saved_ticker_count=0, + proposed_ticker_count=0, + saved_counts=(), + proposed_counts=(), + changed_ticker_count=0, + changed_tickers=(), + top_n=top_n, + saved_path=str(saved_path), + ) + saved = pd.read_csv(saved_path) + reports = build_ticker_readiness_report( + project_root, + data_dir=data_path, + write_outputs=False, + ) + proposed = reports["ticker_readiness_report"] + preview = compare_readiness_frames( + saved, + proposed, + top_n=top_n, + saved_path=str(saved_path), + ) + fundamentals_path = data_path / "fundamentals.csv" + fundamentals = pd.read_csv(fundamentals_path) if fundamentals_path.exists() else pd.DataFrame() + if not fundamentals.empty: + fundamentals.columns = normalize_columns(list(fundamentals.columns)) + registry = ( + rights_registry + if rights_registry is not None + else load_source_rights_registry(project_root / "config" / "source_rights.yml") + ) + promotion_review = review_readiness_promotions( + saved, + proposed, + fundamentals, + rights_registry=registry, + top_n=top_n, + ) + change_review = review_readiness_changes(saved, proposed, fundamentals) + prices_path = data_path / "prices.csv" + prices = pd.read_csv(prices_path) if prices_path.exists() else pd.DataFrame() + if not prices.empty: + prices.columns = normalize_columns(list(prices.columns)) + dcf_price_lineage_review = review_dcf_price_lineage( + saved, + proposed, + prices, + rights_registry=registry, + review_cutoff=review_cutoff, + top_n=top_n, + ) + return replace( + preview, + promotion_review=promotion_review, + change_review=change_review, + dcf_price_lineage_review=dcf_price_lineage_review, + ) + + +def _format_counts(counts: tuple[tuple[str, int], ...]) -> str: + return ", ".join(f"{name}={value}" for name, value in counts) or "unavailable" + + +def render_readiness_impact_preview(preview: ReadinessImpactPreview) -> str: + lines = ["Readiness Impact Preview", ""] + if preview.status == "missing_saved_snapshot": + lines.extend( + [ + "Status: missing_saved_snapshot", + f"Saved snapshot: {preview.saved_path}", + "Comparison is unavailable because the saved readiness snapshot is missing.", + ] + ) + else: + lines.extend( + [ + f"Status: {preview.status}", + f"Ticker rows: saved={preview.saved_ticker_count}, proposed={preview.proposed_ticker_count}", + f"Saved counts: {_format_counts(preview.saved_counts)}", + f"Proposed counts: {_format_counts(preview.proposed_counts)}", + f"Changed tickers: {preview.changed_ticker_count}", + ] + ) + for change in preview.changed_tickers: + lines.append(f"- {change.ticker}: {', '.join(change.fields)}") + hidden = preview.changed_ticker_count - len(preview.changed_tickers) + if hidden > 0: + lines.append(f"- ... {hidden} additional changed ticker(s) hidden by TOP_N={preview.top_n}") + change_review = preview.change_review + if change_review is not None: + lines.extend( + [ + "", + "Readiness Change Cause Review", + f"Status: {change_review.status}", + ( + "Ticker rows: " + f"added={change_review.added_ticker_count}, removed={change_review.removed_ticker_count}" + ), + f"Newly ready features: {_format_counts(change_review.newly_ready_counts)}", + f"Newly partial features: {_format_counts(change_review.newly_partial_counts)}", + f"Newly excluded features: {_format_counts(change_review.newly_excluded_counts)}", + f"Primary DCF exclusion reasons: {_format_counts(change_review.dcf_exclusion_reason_counts)}", + ( + "Unexplained new DCF exclusions: " + f"{change_review.unexplained_dcf_exclusion_count}" + ), + "Transition counts are independent and can overlap for one ticker; they are not current readiness totals.", + "A DCF exclusion is a method-fit state, not a negative company signal or investment conclusion.", + ] + ) + if change_review.unexplained_dcf_exclusion_tickers: + lines.append( + "- Unexplained DCF exclusions: " + + ", ".join(change_review.unexplained_dcf_exclusion_tickers[: preview.top_n]) + ) + review = preview.promotion_review + if review is not None: + lines.extend( + [ + "", + "Promotion Evidence Review", + f"Status: {review.status}", + ( + "Technical promotions: " + f"unique={review.promotion_count}, fundamentals={review.fundamentals_promotion_count}, " + f"DCF={review.dcf_promotion_count}" + ), + ( + "Commercial rights: " + f"approved={review.rights_approved_count}, review_required={review.rights_review_required_count}" + ), + ( + "Provenance: " + f"complete={review.provenance_complete_count}, " + f"review_required={review.provenance_review_required_count}" + ), + ( + "Registered field scope: " + f"complete={review.field_scope_complete_count}, " + f"review_required={review.field_scope_review_required_count}" + ), + f"Exact source values: {_format_counts(review.source_counts)}", + f"Rights statuses: {_format_counts(review.rights_status_counts)}", + ] + ) + for item in review.evidence_rows: + missing_provenance = ",".join(item.missing_provenance_fields) or "none" + missing_scope = ",".join(item.missing_supported_fields) or "none" + lines.append( + f"- {item.ticker}: promotes={','.join(item.promoted_fields)}; source={item.source_id!r}; " + f"rights={item.rights_status}; missing_provenance={missing_provenance}; " + f"missing_registered_fields={missing_scope}" + ) + hidden_evidence = review.promotion_count - len(review.evidence_rows) + if hidden_evidence > 0: + lines.append( + f"- ... {hidden_evidence} additional promotion evidence row(s) hidden by TOP_N={review.top_n}" + ) + lines.extend( + [ + "Technical readiness movement is not source-rights or provenance approval.", + "DCF price-source provenance is outside this fundamentals review; see the independent price-lineage review.", + "Even a complete promotion evidence review would not authorize the separate readiness rebuild.", + ] + ) + price_review = preview.dcf_price_lineage_review + if price_review is not None: + lines.extend( + [ + "", + "DCF Price Lineage Review", + f"Status: {price_review.status}", + f"Technical DCF promotions: {price_review.promotion_count}", + ( + "Latest price rows: " + f"usable={price_review.usable_latest_row_count}, " + f"missing={price_review.missing_latest_row_count}, " + f"ambiguous={price_review.ambiguous_latest_row_count}" + ), + ( + "Price lineage: " + f"complete={price_review.lineage_complete_count}, " + f"review_required={price_review.lineage_review_required_count}" + ), + ( + "Price retrieval timing: " + f"complete={price_review.temporal_complete_count}, " + f"review_required={price_review.temporal_review_required_count}" + ), + ( + "Commercial price rights: " + f"approved={price_review.rights_approved_count}, " + f"review_required={price_review.rights_review_required_count}" + ), + ( + "Registered price scope: " + f"complete={price_review.field_scope_complete_count}, " + f"review_required={price_review.field_scope_review_required_count}" + ), + f"Exact price source values: {_format_counts(price_review.source_counts)}", + f"Price rights statuses: {_format_counts(price_review.rights_status_counts)}", + ] + ) + for item in price_review.evidence_rows: + missing_provenance = ",".join(item.missing_provenance_fields) or "none" + missing_scope = ",".join(item.missing_supported_fields) or "none" + blockers = ",".join(item.blockers) or "none" + lines.append( + f"- {item.ticker}: observation_date={item.observation_date or ''}; " + f"valid_rows={item.valid_row_count}; latest_rows={item.latest_row_count}; " + f"source={item.source_id!r}; rights={item.rights_status}; " + f"temporal={item.temporal_status}; " + f"missing_provenance={missing_provenance}; " + f"missing_registered_fields={missing_scope}; blockers={blockers}" + ) + hidden_price_evidence = price_review.promotion_count - len(price_review.evidence_rows) + if hidden_price_evidence > 0: + lines.append( + f"- ... {hidden_price_evidence} additional DCF price evidence row(s) hidden by " + f"TOP_N={price_review.top_n}" + ) + lines.extend( + [ + "File origin, observation date, and adapter availability are not provider provenance.", + "Missing or composite source identifiers are not split, inferred, or granted borrowed rights.", + "This price review changes no readiness state and does not authorize the separate rebuild.", + ] + ) + lines.extend( + [ + "", + "Read-only: no files were created, modified, or deleted.", + "This preview does not make saved readiness current.", + "An intentional reviewed make readiness run remains the separate rebuild boundary.", + "Research workflow evidence only; not investment advice or a recommendation.", + ] + ) + return "\n".join(lines) + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Preview stable readiness impact without writing files.") + parser.add_argument("--project-root", default=".", help="Project root. Defaults to the current directory.") + parser.add_argument("--data-dir", help="Optional data directory. Relative paths resolve from project root.") + parser.add_argument("--top-n", type=int, default=20, help="Maximum changed ticker details to print.") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = _parser().parse_args(argv) + if args.top_n < 1: + print("Readiness preview failed: --top-n must be at least 1.") + return 1 + try: + preview = build_readiness_impact_preview( + Path(args.project_root), + data_dir=args.data_dir, + top_n=args.top_n, + ) + except (KeyError, OSError, ValueError) as exc: + print(f"Readiness preview failed: {exc}") + print("Read-only: no readiness output was written.") + return 1 + print(render_readiness_impact_preview(preview)) + return 2 if preview.status == "missing_saved_snapshot" else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/refresh_operations.py b/src/refresh_operations.py new file mode 100644 index 00000000..161b868b --- /dev/null +++ b/src/refresh_operations.py @@ -0,0 +1,198 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +REFRESH_STAGES = ( + "fetch", + "normalize", + "validate", + "quarantine", + "preview", + "publish_snapshot", + "rebuild_readiness", + "detect_changes", +) + + +@dataclass(frozen=True) +class RefreshStage: + name: str + state: str + + +@dataclass(frozen=True) +class ProviderAttempt: + provider: str + session_id: str + outcome: str + + +@dataclass(frozen=True) +class RefreshOperationRequest: + lane: str + provider_order: tuple[str, ...] + available_providers: tuple[str, ...] + batch_limit: int + freshness_policy: str + retry_cap: int = 1 + session_id: str = "local-session" + attempts: tuple[ProviderAttempt, ...] = () + + +@dataclass(frozen=True) +class RefreshOperationPlan: + lane: str + status: str + selected_provider: str | None + skipped_providers: tuple[str, ...] + failure_reason: str | None + batch_limit: int + freshness_policy: str + automatic_apply_enabled: bool + stages: tuple[RefreshStage, ...] + + +@dataclass(frozen=True) +class RefreshBatch: + provider: str + row_count: int + expected_schema_identity: str + received_schema_identity: str + provenance_complete: bool + duplicate_rows: int + stale_rows: int + partial_batch: bool + + +@dataclass(frozen=True) +class RefreshBatchDecision: + status: str + failure_reasons: tuple[str, ...] + is_partial: bool + is_invalid: bool + preview_allowed: bool + publish_snapshot: bool + rebuild_readiness: bool + detect_changes: bool + + +def build_refresh_operation_plan(request: RefreshOperationRequest) -> RefreshOperationPlan: + if not request.lane.strip(): + raise ValueError("lane is required") + if not request.provider_order: + raise ValueError("provider_order is required") + if request.batch_limit < 1: + raise ValueError("batch_limit must be positive") + if request.retry_cap < 1: + raise ValueError("retry_cap must be positive") + + available = set(request.available_providers) + skipped: list[str] = [] + failure_reasons: list[str] = [] + for provider in request.provider_order: + provider_attempts = tuple(attempt for attempt in request.attempts if attempt.provider == provider) + failed_this_session = any( + attempt.session_id == request.session_id and attempt.outcome == "failed" + for attempt in provider_attempts + ) + failed_attempts = sum(attempt.outcome == "failed" for attempt in provider_attempts) + + if provider not in available: + skipped.append(provider) + failure_reasons.append("provider_unavailable") + continue + if failed_this_session: + skipped.append(provider) + failure_reasons.append("identical_provider_attempt") + continue + if failed_attempts >= request.retry_cap: + skipped.append(provider) + failure_reasons.append("retry_cap_reached") + continue + + return RefreshOperationPlan( + lane=request.lane, + status="planned", + selected_provider=provider, + skipped_providers=tuple(skipped), + failure_reason=None, + batch_limit=request.batch_limit, + freshness_policy=request.freshness_policy, + automatic_apply_enabled=False, + stages=tuple(RefreshStage(name=stage, state="pending") for stage in REFRESH_STAGES), + ) + + if "retry_cap_reached" in failure_reasons: + failure_reason = "retry_cap_reached" + elif "identical_provider_attempt" in failure_reasons: + failure_reason = "identical_provider_attempt" + else: + failure_reason = "provider_unavailable" + return RefreshOperationPlan( + lane=request.lane, + status="blocked", + selected_provider=None, + skipped_providers=tuple(skipped), + failure_reason=failure_reason, + batch_limit=request.batch_limit, + freshness_policy=request.freshness_policy, + automatic_apply_enabled=False, + stages=tuple(RefreshStage(name=stage, state="blocked") for stage in REFRESH_STAGES), + ) + + +def evaluate_refresh_batch(batch: RefreshBatch) -> RefreshBatchDecision: + if batch.row_count < 0 or batch.duplicate_rows < 0 or batch.stale_rows < 0: + raise ValueError("row counts cannot be negative") + + reasons: list[str] = [] + expected_schema = batch.expected_schema_identity.strip() + received_schema = batch.received_schema_identity.strip() + if not expected_schema or not received_schema: + reasons.append("schema_identity_missing") + elif expected_schema != received_schema: + reasons.append("schema_changed") + if not batch.provenance_complete: + reasons.append("provenance_missing") + if batch.duplicate_rows: + reasons.append("duplicate_rows") + if batch.stale_rows: + reasons.append("stale_rows") + + if batch.partial_batch: + reasons.append("partial_batch") + + invalid = any(reason != "partial_batch" for reason in reasons) + if invalid: + return RefreshBatchDecision( + status="partial_invalid" if batch.partial_batch else "quarantine", + failure_reasons=tuple(reasons), + is_partial=batch.partial_batch, + is_invalid=True, + preview_allowed=False, + publish_snapshot=False, + rebuild_readiness=False, + detect_changes=False, + ) + if batch.partial_batch: + return RefreshBatchDecision( + status="partial", + failure_reasons=("partial_batch",), + is_partial=True, + is_invalid=False, + preview_allowed=False, + publish_snapshot=False, + rebuild_readiness=False, + detect_changes=False, + ) + return RefreshBatchDecision( + status="ready_for_preview", + failure_reasons=(), + is_partial=False, + is_invalid=False, + preview_allowed=True, + publish_snapshot=False, + rebuild_readiness=False, + detect_changes=False, + ) diff --git a/src/research_decision_lab.py b/src/research_decision_lab.py new file mode 100644 index 00000000..68612797 --- /dev/null +++ b/src/research_decision_lab.py @@ -0,0 +1,525 @@ +"""Read-only composition of saved research-process evidence.""" + +from __future__ import annotations + +import hashlib +import json +from dataclasses import asdict, dataclass +from typing import Iterable, Mapping + +from src.decision_process_scorecard import DecisionProcessScorecard, ProcessCheck +from src.research_outcome_review import OutcomeStatus +from src.research_thesis_journal import JournalState + + +LANE_ORDER = ( + ("plan", "Plan"), + ("evidence", "Evidence"), + ("invalidation", "Invalidation"), + ("scenario", "Scenario"), + ("review_trigger", "Review trigger"), + ("learning", "Learning"), +) + +BOUNDARY = ( + "Research-process documentation only; no company grade, expected-return score, " + "performance claim, recommendation, or transaction action is produced." +) + + +@dataclass(frozen=True) +class DecisionLabLane: + key: str + label: str + state: str + answer: str + evidence: str + next_step: str + + +@dataclass(frozen=True) +class ResearchDecisionLabState: + profile_key: str + ticker: str + status: str + lanes: tuple[DecisionLabLane, ...] + next_process_step: str + boundary: str + identity: str + + +@dataclass(frozen=True) +class ResearchDisciplineRow: + cohort_order: int + ticker: str + status: str + due_lanes: tuple[str, ...] + next_process_step: str + identity: str + + +def _text(value: object) -> str: + text = str(value or "").strip() + return "" if text.lower() in {"nan", "none", "null", ""} else text + + +def _check_map(scorecard: DecisionProcessScorecard) -> dict[str, ProcessCheck]: + return {check.key: check for check in scorecard.checks} + + +def _lane( + key: str, + label: str, + state: str, + answer: str, + evidence: str, + next_step: str, +) -> DecisionLabLane: + return DecisionLabLane(key, label, state, answer, evidence, next_step) + + +def _identity( + *, + profile_key: str, + ticker: str, + status: str, + lanes: tuple[DecisionLabLane, ...], + next_process_step: str, +) -> str: + payload = { + "profile_key": profile_key, + "ticker": ticker, + "status": status, + "lanes": [asdict(lane) for lane in lanes], + "next_process_step": next_process_step, + } + return hashlib.sha256(json.dumps(payload, sort_keys=True).encode("utf-8")).hexdigest() + + +def _matching_open_change_count(review_items: Iterable[object], ticker: str) -> int: + return sum( + 1 + for item in review_items + if _text(getattr(getattr(item, "event", None), "ticker", "")).upper() == ticker + and _text(getattr(item, "review_status", "open")).lower() not in {"resolved", "closed"} + ) + + +def _plan_lane(journal_state: JournalState, checks: Mapping[str, ProcessCheck]) -> DecisionLabLane: + check = checks["thesis_documented"] + if journal_state.current_thesis is None: + return _lane( + "plan", + "Plan", + "not_started", + "No current reviewer-authored thesis", + check.evidence, + "Record a current reviewer-authored thesis and a review date.", + ) + return _lane( + "plan", + "Plan", + "documented", + "Current reviewer-authored thesis is documented", + check.evidence, + "Revisit the thesis when reviewed evidence changes.", + ) + + +def _evidence_lane(journal_state: JournalState, checks: Mapping[str, ProcessCheck]) -> DecisionLabLane: + recorded = checks["evidence_recorded"] + conflict = checks["conflicting_evidence_reviewed"] + if recorded.state != "complete": + return _lane( + "evidence", + "Evidence", + "not_started", + "No reviewed research evidence is recorded", + recorded.evidence, + "Record source-backed evidence before relying on the thesis.", + ) + if conflict.state == "action_needed": + return _lane( + "evidence", + "Evidence", + "conflict_review_needed", + "Recorded conflicting evidence needs a later review", + conflict.evidence, + conflict.next_action, + ) + return _lane( + "evidence", + "Evidence", + "current", + "Reviewed research evidence is recorded", + recorded.evidence, + "Revisit this lane when new source-backed evidence arrives.", + ) + + +def _invalidation_lane(journal_state: JournalState, checks: Mapping[str, ProcessCheck]) -> DecisionLabLane: + check = checks["invalidation_documented"] + if journal_state.current_thesis is None: + return _lane( + "invalidation", + "Invalidation", + "not_started", + "No current thesis exists to invalidate", + "No invalidation condition is synthesized from risks or price movement.", + "Record the thesis before documenting its invalidation condition.", + ) + if check.state != "complete": + return _lane( + "invalidation", + "Invalidation", + "missing", + "No source-backed invalidation condition is documented", + check.evidence, + "Record what source-backed evidence would invalidate the thesis.", + ) + return _lane( + "invalidation", + "Invalidation", + "documented", + "Source-backed invalidation condition is documented", + check.evidence, + "Revisit the condition when reviewed evidence changes.", + ) + + +def _scenario_lane(checks: Mapping[str, ProcessCheck]) -> DecisionLabLane: + check = checks["dcf_assumptions_visible"] + if check.state == "complete": + return _lane( + "scenario", + "Scenario", + "reviewable", + "Scenario assumptions are reviewable", + check.evidence, + "Review visible assumptions and sensitivity before interpreting scenario math.", + ) + if check.state == "blocked": + return _lane( + "scenario", + "Scenario", + "blocked", + "Scenario review is blocked by valuation readiness", + check.evidence, + "Resolve trusted DCF inputs before reviewing scenario math.", + ) + if check.state == "not_applicable": + return _lane( + "scenario", + "Scenario", + "excluded", + "Operating-company scenario review is excluded for this asset", + check.evidence, + "Use the existing market-context research method for this asset type.", + ) + return _lane( + "scenario", + "Scenario", + "unavailable", + "Scenario assumptions are unavailable", + check.evidence, + "Restore explicit assumptions before interpreting scenario math.", + ) + + +def _review_trigger_lane( + journal_state: JournalState, + checks: Mapping[str, ProcessCheck], + *, + open_change_count: int, +) -> DecisionLabLane: + review = checks["review_current"] + if journal_state.current_thesis is None: + return _lane( + "review_trigger", + "Review trigger", + "not_started", + "No reviewer-authored review trigger is scheduled", + "An empty journal cannot create a review date or evidence trigger.", + "Record a thesis and its next evidence-review date.", + ) + if open_change_count: + return _lane( + "review_trigger", + "Review trigger", + "evidence_change_due", + "Source-backed evidence change needs review", + f"{open_change_count} unresolved evidence-change item(s) match this ticker.", + "Review or explicitly defer each source-backed evidence change.", + ) + if journal_state.overdue: + return _lane( + "review_trigger", + "Review trigger", + "overdue", + "The reviewer-authored thesis review is overdue", + review.evidence, + "Review the thesis and newer evidence now.", + ) + if journal_state.review_due_date: + return _lane( + "review_trigger", + "Review trigger", + "scheduled", + f"Next evidence review is scheduled for {journal_state.review_due_date}", + review.evidence, + "Revisit on the due date or when material source evidence changes.", + ) + return _lane( + "review_trigger", + "Review trigger", + "unscheduled", + "No next evidence review is scheduled", + review.evidence, + "Schedule the next evidence review.", + ) + + +def _learning_lane(outcome_status: OutcomeStatus) -> DecisionLabLane: + if outcome_status.state == "reviewed": + return _lane( + "learning", + "Learning", + "reviewed", + "A prior research outcome and learning were reviewed", + f"{outcome_status.review_count} reviewed learning record(s) are available.", + outcome_status.next_action, + ) + if outcome_status.state == "commercial_evidence_blocked": + return _lane( + "learning", + "Learning", + "commercial_evidence_blocked", + "Outcome learning is blocked for commercial use", + f"{outcome_status.commercial_blocker_count} exact-source blocker(s) remain.", + outcome_status.next_action, + ) + return _lane( + "learning", + "Learning", + "not_started", + "No prior research outcome learning is recorded", + "No learning is synthesized from technical history or price movement.", + outcome_status.next_action, + ) + + +def _next_process_step( + lanes: Mapping[str, DecisionLabLane], + checks: Mapping[str, ProcessCheck], +) -> str: + if lanes["evidence"].state == "conflict_review_needed": + return "Review recorded conflicting evidence before relying on the thesis." + if lanes["review_trigger"].state == "overdue": + return "Review the overdue thesis and newer evidence." + if lanes["plan"].state == "not_started": + return "Record a current reviewer-authored thesis." + if lanes["invalidation"].state == "missing": + return "Record a source-backed invalidation condition." + if lanes["evidence"].state == "not_started": + return "Record source-backed research evidence." + if lanes["review_trigger"].state == "unscheduled": + return "Schedule the next evidence review." + if checks["dcf_assumptions_visible"].state == "action_needed": + return "Restore visible DCF assumptions before scenario review." + return "Continue monitoring for new source-backed evidence or the next review date." + + +def _lane_needs_process_work(lane: DecisionLabLane) -> bool: + work_states = { + "plan": {"not_started", "unavailable"}, + "evidence": {"not_started", "conflict_review_needed", "unavailable"}, + "invalidation": {"missing", "unavailable"}, + "scenario": {"unavailable"}, + "review_trigger": {"overdue", "unscheduled", "evidence_change_due", "unavailable"}, + "learning": {"commercial_evidence_blocked", "unavailable"}, + } + return lane.state in work_states[lane.key] + + +def build_research_decision_lab_state( + *, + profile_key: str, + journal_state: JournalState, + scorecard: DecisionProcessScorecard, + outcome_status: OutcomeStatus, + review_items: Iterable[object] = (), +) -> ResearchDecisionLabState: + """Compose six independent research-process lanes from existing results.""" + + ticker = _text(scorecard.ticker).upper() + selected_profile = _text(profile_key) + if ( + not ticker + or not selected_profile + or journal_state.ticker != ticker + or journal_state.profile_key != selected_profile + or scorecard.profile_key != selected_profile + ): + raise ValueError("Decision Lab evidence must match the selected profile and ticker.") + + checks = _check_map(scorecard) + required_checks = { + "thesis_documented", + "evidence_recorded", + "conflicting_evidence_reviewed", + "invalidation_documented", + "review_current", + "dcf_assumptions_visible", + } + if not required_checks.issubset(checks): + raise ValueError("Decision Lab requires the complete decision-process check contract.") + + open_change_count = _matching_open_change_count(tuple(review_items), ticker) + lanes = ( + _plan_lane(journal_state, checks), + _evidence_lane(journal_state, checks), + _invalidation_lane(journal_state, checks), + _scenario_lane(checks), + _review_trigger_lane(journal_state, checks, open_change_count=open_change_count), + _learning_lane(outcome_status), + ) + lane_map = {lane.key: lane for lane in lanes} + next_process_step = _next_process_step(lane_map, checks) + status = "process_work_needed" if any(_lane_needs_process_work(lane) for lane in lanes) else "process_documented" + identity = _identity( + profile_key=selected_profile, + ticker=ticker, + status=status, + lanes=lanes, + next_process_step=next_process_step, + ) + return ResearchDecisionLabState( + profile_key=selected_profile, + ticker=ticker, + status=status, + lanes=lanes, + next_process_step=next_process_step, + boundary=BOUNDARY, + identity=identity, + ) + + +def unavailable_research_decision_lab_state( + *, + profile_key: str, + ticker: str, + reason: str, +) -> ResearchDecisionLabState: + """Return one compact fail-closed contract for invalid saved evidence.""" + + selected_profile = _text(profile_key) + selected_ticker = _text(ticker).upper() + explanation = _text(reason) or "The saved research-process evidence could not be verified." + if not selected_profile or not selected_ticker: + raise ValueError("Decision Lab requires a selected profile and ticker.") + lanes = tuple( + _lane( + key, + label, + "unavailable", + f"{label} is unavailable", + explanation, + "Verify the saved research-process evidence before using this lane.", + ) + for key, label in LANE_ORDER + ) + next_process_step = "Verify the saved research-process evidence before continuing." + identity = _identity( + profile_key=selected_profile, + ticker=selected_ticker, + status="unavailable", + lanes=lanes, + next_process_step=next_process_step, + ) + return ResearchDecisionLabState( + profile_key=selected_profile, + ticker=selected_ticker, + status="unavailable", + lanes=lanes, + next_process_step=next_process_step, + boundary=BOUNDARY, + identity=identity, + ) + + +def decision_lab_cards(state: ResearchDecisionLabState) -> list[dict[str, object]]: + cards = [ + { + "kicker": lane.label.upper(), + "title": lane.answer, + "body": lane.next_step, + "badges": [lane.state.replace("_", " "), "research process"], + "command": "", + } + for lane in state.lanes + ] + cards.append( + { + "kicker": "NEXT PROCESS STEP", + "title": state.next_process_step, + "body": "This process step does not replace the authoritative company research task.", + "badges": [state.status.replace("_", " "), "documentation workflow"], + "command": "", + } + ) + return cards + + +def decision_lab_rows(state: ResearchDecisionLabState) -> list[dict[str, str]]: + return [ + { + "Lane": lane.label, + "State": lane.state.replace("_", " "), + "Answer": lane.answer, + "Evidence": lane.evidence, + "Next process step": lane.next_step, + } + for lane in state.lanes + ] + + +def build_research_discipline_rows( + states_by_ticker: Mapping[str, ResearchDecisionLabState], + *, + focused_tickers: Iterable[str], +) -> tuple[ResearchDisciplineRow, ...]: + """Preserve focused-cohort order without severity or market-value sorting.""" + + normalized = {_text(ticker).upper(): state for ticker, state in states_by_ticker.items()} + rows: list[ResearchDisciplineRow] = [] + for cohort_order, raw_ticker in enumerate(focused_tickers): + ticker = _text(raw_ticker).upper() + state = normalized.get(ticker) + if state is None: + continue + if state.ticker != ticker: + raise ValueError("Research discipline state must match the focused ticker.") + due_lanes = tuple(lane.label for lane in state.lanes if _lane_needs_process_work(lane)) + rows.append( + ResearchDisciplineRow( + cohort_order=cohort_order, + ticker=ticker, + status=state.status, + due_lanes=due_lanes, + next_process_step=state.next_process_step, + identity=state.identity, + ) + ) + return tuple(rows) + + +def research_discipline_rows(rows: Iterable[ResearchDisciplineRow]) -> list[dict[str, str]]: + return [ + { + "Ticker": row.ticker, + "Process state": row.status.replace("_", " "), + "Due lanes": ", ".join(row.due_lanes) or "none due from saved evidence", + "Next process step": row.next_process_step, + } + for row in rows + ] diff --git a/src/research_ledger_lock.py b/src/research_ledger_lock.py new file mode 100644 index 00000000..7bd924a0 --- /dev/null +++ b/src/research_ledger_lock.py @@ -0,0 +1,100 @@ +"""Cooperative, reentrant write locks for append-only research ledgers.""" + +from __future__ import annotations + +import fcntl +import hashlib +import tempfile +import threading +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path +from typing import Iterator + + +@dataclass +class _LockState: + thread_lock: threading.RLock + depth: int = 0 + handle: object | None = None + + +_registry_guard = threading.Lock() +_states: dict[str, _LockState] = {} + + +def resolve_ledger_path(path: Path | str) -> Path: + """Return the stable filesystem identity used by receipts and locks.""" + + return Path(path).resolve(strict=False) + + +def _lock_artifact(identity: Path) -> Path: + digest = hashlib.sha256(str(identity).encode("utf-8")).hexdigest() + return Path(tempfile.gettempdir()) / "research-ledger-locks-v1" / f"{digest}.lock" + + +def _release_handle(state: _LockState) -> None: + """Release a prior lock handle, retaining it when close must be retried.""" + + handle = state.handle + if handle is None: + return + unlock_error: OSError | None = None + try: + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + except OSError as exc: + unlock_error = exc + try: + handle.close() + except OSError as close_error: + if unlock_error is not None: + raise unlock_error from close_error + raise + state.handle = None + if unlock_error is not None: + raise unlock_error + + +@contextmanager +def ledger_write_lock(path: Path | str) -> Iterator[Path]: + """Hold the shared lock for one resolved ledger across threads and processes. + + The `RLock` makes nested calls from one thread safe; `flock` coordinates + cooperating processes. Lock artifacts live under the system temp directory, + never beside source or ledger files. + """ + + identity = resolve_ledger_path(path) + key = str(identity) + with _registry_guard: + state = _states.setdefault(key, _LockState(threading.RLock())) + state.thread_lock.acquire() + entered = False + try: + if state.depth == 0: + _release_handle(state) + artifact = _lock_artifact(identity) + artifact.parent.mkdir(parents=True, exist_ok=True) + handle = artifact.open("a+b") + try: + fcntl.flock(handle.fileno(), fcntl.LOCK_EX) + except BaseException as exc: + try: + handle.close() + except OSError as close_error: + state.handle = handle + raise close_error from exc + raise + state.handle = handle + state.depth += 1 + entered = True + yield identity + finally: + try: + if entered: + state.depth -= 1 + if state.depth == 0: + _release_handle(state) + finally: + state.thread_lock.release() diff --git a/src/research_outcome_review.py b/src/research_outcome_review.py new file mode 100644 index 00000000..316fd6d9 --- /dev/null +++ b/src/research_outcome_review.py @@ -0,0 +1,245 @@ +"""Append-only research outcome review for learning from prior thesis work.""" + +from __future__ import annotations + +import argparse +import csv +from dataclasses import asdict, dataclass, fields +from pathlib import Path +from typing import Iterable, Mapping + +from src.commercial_source_rights import ( + SourceRights, + load_source_rights_registry, + review_commercial_field_scope, +) +from src.earnings_nowcast_contract import parse_utc_timestamp +from src.research_ledger_lock import ledger_write_lock + + +SCHEMA_VERSION = "research-outcome-review-v1" +OUTCOME_STATES = {"supported", "mixed", "not_supported", "inconclusive"} + + +@dataclass(frozen=True) +class ResearchOutcome: + schema_version: str + outcome_id: str + profile_key: str + ticker: str + thesis_id: str + original_thesis_entry_id: str + reviewed_at: str + observation_start: str + observation_end: str + reviewer: str + outcome_state: str + summary: str + source: str + source_ref: str + source_published_at: str + learning: str + + +FIELDS = tuple(field.name for field in fields(ResearchOutcome)) + + +@dataclass(frozen=True) +class OutcomePreview: + state: str + reason: str + fields: tuple[str, ...] + write_performed: bool = False + + +@dataclass(frozen=True) +class OutcomeStatus: + state: str + review_count: int + latest_outcome_state: str + latest_learning: str + next_action: str + commercial_blocker_count: int + commercial_blockers: tuple[str, ...] + + +def _validation_error(row: ResearchOutcome, existing: Iterable[ResearchOutcome]) -> str: + if row.schema_version != SCHEMA_VERSION: + return f"schema_version must be {SCHEMA_VERSION}" + for field in FIELDS: + if not str(getattr(row, field) or "").strip(): + return f"{field} is required" + if row.outcome_state not in OUTCOME_STATES: + return "outcome_state is unsupported" + if any(existing_row.outcome_id == row.outcome_id for existing_row in existing): + return f"outcome_id already exists: {row.outcome_id}" + identity = ( + row.profile_key, row.ticker.upper(), row.thesis_id, row.original_thesis_entry_id, + row.observation_start, row.observation_end, row.source_ref, + ) + if any( + ( + existing_row.profile_key, existing_row.ticker.upper(), existing_row.thesis_id, + existing_row.original_thesis_entry_id, existing_row.observation_start, + existing_row.observation_end, existing_row.source_ref, + ) == identity + for existing_row in existing + ): + return "duplicate outcome evidence already exists for this thesis and observation window" + try: + reviewed = parse_utc_timestamp(row.reviewed_at, label="reviewed_at") + start = parse_utc_timestamp(row.observation_start, label="observation_start") + end = parse_utc_timestamp(row.observation_end, label="observation_end") + published = parse_utc_timestamp(row.source_published_at, label="source_published_at") + except ValueError as exc: + return str(exc) + if start > end: + return "observation_start cannot be after observation_end" + if end > reviewed: + return "observation_end cannot be after reviewed_at" + if published > reviewed: + return "source_published_at cannot be after reviewed_at" + return "" + + +def preview_outcome(row: ResearchOutcome, *, existing: Iterable[ResearchOutcome]) -> OutcomePreview: + reason = _validation_error(row, tuple(existing)) + return OutcomePreview("rejected" if reason else "reviewable", reason, FIELDS) + + +def load_outcomes(path: Path | str) -> tuple[ResearchOutcome, ...]: + source = Path(path) + if not source.is_file(): + return () + with source.open(newline="", encoding="utf-8") as handle: + reader = csv.DictReader(handle) + if tuple(reader.fieldnames or ()) != FIELDS: + raise ValueError("Research outcome ledger header does not match the append-only contract.") + rows = tuple(ResearchOutcome(**{field: str(row.get(field) or "").strip() for field in FIELDS}) for row in reader) + validated: list[ResearchOutcome] = [] + for row in rows: + reason = _validation_error(row, validated) + if reason: + raise ValueError(reason) + validated.append(row) + return rows + + +def append_reviewed_outcome(path: Path | str, row: ResearchOutcome, *, confirm_reviewed: bool) -> Path: + if not confirm_reviewed: + raise ValueError("confirm_reviewed is required before recording an outcome") + destination = Path(path) + with ledger_write_lock(destination): + existing = load_outcomes(destination) + reason = _validation_error(row, existing) + if reason: + raise ValueError(reason) + destination.parent.mkdir(parents=True, exist_ok=True) + exists = destination.exists() and destination.stat().st_size > 0 + with destination.open("a", newline="", encoding="utf-8") as handle: + writer = csv.DictWriter(handle, fieldnames=FIELDS) + if not exists: + writer.writeheader() + writer.writerow(asdict(row)) + return destination + + +def derive_outcome_status( + rows: Iterable[ResearchOutcome], *, profile_key: str, ticker: str, + commercial_mode: bool = False, + rights_registry: Mapping[str, SourceRights] | None = None, +) -> OutcomeStatus: + scoped = sorted( + (row for row in rows if row.profile_key == profile_key and row.ticker.upper() == ticker.upper()), + key=lambda row: parse_utc_timestamp(row.reviewed_at), + ) + if not scoped: + return OutcomeStatus("not_started", 0, "", "", "Record an outcome only after the observation window closes and evidence is reviewed.", 0, ()) + commercial_blockers: list[str] = [] + if commercial_mode: + registry = rights_registry if rights_registry is not None else load_source_rights_registry() + for row in scoped: + review = review_commercial_field_scope( + registry, row.source, ("research_outcomes",) + ) + if not review.commercial_evidence_ready: + missing = ", ".join(review.missing_supported_fields) or "research_outcomes" + commercial_blockers.append( + f"{row.outcome_id}: exact source {row.source or '-'} is {review.rights_status}; " + f"registered scope missing: {missing}" + ) + if commercial_blockers: + return OutcomeStatus( + "commercial_evidence_blocked", 0, "", "", + "Review exact-source rights and registered research_outcomes scope before using this learning record.", + len(commercial_blockers), tuple(commercial_blockers), + ) + latest = scoped[-1] + return OutcomeStatus( + "reviewed", + len(scoped), + latest.outcome_state, + latest.learning, + "Use the recorded learning when the thesis is next reviewed.", + 0, + (), + ) + + +def outcome_status_cards(status: OutcomeStatus) -> list[dict[str, object]]: + if status.state == "not_started": + title = "No research outcome review is due yet" + body = status.next_action + elif status.state == "commercial_evidence_blocked": + title = "Research outcome commercial evidence is blocked" + body = ( + f"{status.commercial_blocker_count} outcome row(s) lack approved exact-source " + "rights or registered research-outcomes scope." + ) + else: + title = f"Latest reviewed outcome: {status.latest_outcome_state.replace('_', ' ')}" + body = f"{status.review_count} reviewed learning record(s). Latest learning: {status.latest_learning}" + return [{ + "kicker": "RESEARCH LEARNING", + "title": title, + "body": body, + "badges": [status.state.replace("_", " "), "append-only", "no performance scoring"], + "command": "", + }] + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Read an append-only research outcome review ledger.") + parser.add_argument("--ledger", default="data/research_outcome_reviews.csv") + parser.add_argument("--profile-key", default="default") + parser.add_argument("--ticker") + parser.add_argument("--preview-input") + parser.add_argument("--record-input") + parser.add_argument("--confirm-reviewed", action="store_true") + args = parser.parse_args(argv) + if args.preview_input or args.record_input: + input_path = args.preview_input or args.record_input + existing = load_outcomes(args.ledger) + candidates = load_outcomes(input_path) + if args.preview_input: + for row in candidates: + preview = preview_outcome(row, existing=existing) + print(f"{row.outcome_id}: {preview.state} {preview.reason}".rstrip()) + print("Preview only: no file was changed.") + return 0 + if not args.confirm_reviewed: + raise ValueError("record requires --confirm-reviewed after preview and source review") + for row in candidates: + append_reviewed_outcome(args.ledger, row, confirm_reviewed=True) + print(f"Appended reviewed research outcomes to {args.ledger}") + return 0 + if not args.ticker: + raise ValueError("--ticker is required for status") + status = derive_outcome_status(load_outcomes(args.ledger), profile_key=args.profile_key, ticker=args.ticker) + print(f"Research outcome review\nTicker: {args.ticker.upper()}\nState: {status.state}\nReviews: {status.review_count}\nNext: {status.next_action}") + print("Boundary: this is a research-process learning record, not return attribution, skill scoring, or investment advice.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/research_record_authoring.py b/src/research_record_authoring.py new file mode 100644 index 00000000..0806d5c7 --- /dev/null +++ b/src/research_record_authoring.py @@ -0,0 +1,389 @@ +"""Pure preview composition for reviewed research-record authoring.""" + +from __future__ import annotations + +import csv +import hashlib +import hmac +import json +from dataclasses import asdict, dataclass +from pathlib import Path +from types import MappingProxyType +from typing import Mapping + +from src.catalyst_evidence_timeline import ( + CatalystEvent, + append_reviewed_event, + load_catalyst_events, + preview_event, +) +from src.research_outcome_review import ( + ResearchOutcome, + append_reviewed_outcome, + load_outcomes, + preview_outcome, +) +from src.research_thesis_journal import ( + JOURNAL_SCHEMA_VERSION, + JournalEntry, + append_journal_entry, + load_journal_entries, + validate_journal_entry, +) +from src.research_ledger_lock import ledger_write_lock, resolve_ledger_path + + +RECORD_KINDS = ("thesis", "evidence", "catalyst", "outcome") + + +@dataclass(frozen=True) +class AuthoringPaths: + journal: Path + catalysts: Path + outcomes: Path + + def all(self) -> tuple[Path, Path, Path]: + return (self.journal, self.catalysts, self.outcomes) + + +@dataclass(frozen=True) +class AuthoringDraft: + record_kind: str + profile_key: str + ticker: str + fields: tuple[tuple[str, str], ...] + + def field_map(self) -> Mapping[str, str]: + return MappingProxyType(dict(self.fields)) + + +@dataclass(frozen=True) +class AuthoringPreview: + state: str + reason: str + record_kind: str + profile_key: str + ticker: str + destination_label: str + previewed_at: str + persisted_fields: tuple[tuple[str, str], ...] + receipt: str + draft_digest: str + ledger_fingerprint: str + record: JournalEntry | CatalystEvent | ResearchOutcome | None + write_performed: bool = False + + +@dataclass(frozen=True) +class AuthoringSaveResult: + state: str + reason: str + record_kind: str + record_id: str + destination_label: str + write_performed: bool + + +def build_authoring_draft( + record_kind: str, + *, + profile_key: str, + ticker: str, + fields: Mapping[str, object], +) -> AuthoringDraft: + kind = str(record_kind or "").strip().lower() + if kind not in RECORD_KINDS: + raise ValueError(f"Unsupported record kind: {record_kind!r}") + profile = str(profile_key or "").strip() + symbol = str(ticker or "").strip().upper() + if not profile: + raise ValueError("profile_key is required") + if not symbol: + raise ValueError("ticker is required") + normalized = tuple(sorted((str(key), str(value or "").strip()) for key, value in fields.items())) + return AuthoringDraft(kind, profile, symbol, normalized) + + +def _stable_digest(payload: object) -> str: + encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode() + return hashlib.sha256(encoded).hexdigest() + + +def authoring_draft_digest(draft: AuthoringDraft) -> str: + return _stable_digest(asdict(draft)) + + +def _ledger_fingerprint(path: Path) -> str: + payload = b"missing\0" if not path.exists() else b"present\0" + path.read_bytes() + return hashlib.sha256(payload).hexdigest() + + +def _destination(draft: AuthoringDraft, paths: AuthoringPaths) -> Path: + if draft.record_kind in {"thesis", "evidence"}: + return paths.journal + if draft.record_kind == "catalyst": + return paths.catalysts + return paths.outcomes + + +def _record_id(record: JournalEntry | CatalystEvent | ResearchOutcome) -> str: + if isinstance(record, JournalEntry): + return record.entry_id + if isinstance(record, CatalystEvent): + return record.event_id + return record.outcome_id + + +def _scoped_theses(paths: AuthoringPaths, draft: AuthoringDraft) -> tuple[JournalEntry, ...]: + return tuple( + row + for row in load_journal_entries(paths.journal) + if row.entry_type == "thesis" + and row.profile_key == draft.profile_key + and row.ticker.upper() == draft.ticker + ) + + +def _build_record( + draft: AuthoringDraft, + *, + previewed_at: str, + generated_id: str, + paths: AuthoringPaths, +) -> JournalEntry | CatalystEvent | ResearchOutcome: + values = dict(draft.fields) + if draft.record_kind in {"thesis", "evidence"}: + if draft.record_kind == "evidence" and not any( + row.thesis_id == values.get("thesis_id") for row in _scoped_theses(paths, draft) + ): + raise ValueError("thesis_id must reference an existing thesis in this profile and ticker") + return JournalEntry( + schema_version=JOURNAL_SCHEMA_VERSION, + entry_id=generated_id, + profile_key=draft.profile_key, + ticker=draft.ticker, + thesis_id=values.get("thesis_id", ""), + entry_type=draft.record_kind, + recorded_at=previewed_at, + effective_at=values.get("effective_at", ""), + reviewer=values.get("reviewer", ""), + summary=values.get("summary", ""), + evidence_direction=values.get("evidence_direction", ""), + source=values.get("source", ""), + source_ref=values.get("source_ref", ""), + source_published_at=values.get("source_published_at", ""), + confidence=values.get("confidence", ""), + review_due_date=values.get("review_due_date", ""), + supersedes_entry_id=values.get("supersedes_entry_id", ""), + ) + if draft.record_kind == "catalyst": + return CatalystEvent( + schema_version="catalyst-evidence-v1", + event_id=generated_id, + profile_key=draft.profile_key, + ticker=draft.ticker, + **values, + ) + theses = _scoped_theses(paths, draft) + if not any( + row.thesis_id == values.get("thesis_id") + and row.entry_id == values.get("original_thesis_entry_id") + for row in theses + ): + raise ValueError("outcome must reference an existing thesis entry in this profile and ticker") + return ResearchOutcome( + schema_version="research-outcome-review-v1", + outcome_id=generated_id, + profile_key=draft.profile_key, + ticker=draft.ticker, + **values, + ) + + +def preview_authoring_record( + draft: AuthoringDraft, + *, + paths: AuthoringPaths, + previewed_at: str, + generated_id: str, +) -> AuthoringPreview: + destination = _destination(draft, paths) + draft_digest = authoring_draft_digest(draft) + ledger_fingerprint = "" + try: + ledger_fingerprint = _ledger_fingerprint(destination) + record = _build_record(draft, previewed_at=previewed_at, generated_id=generated_id, paths=paths) + if isinstance(record, JournalEntry): + validate_journal_entry(record, existing_entries=load_journal_entries(paths.journal)) + elif isinstance(record, CatalystEvent): + event_preview = preview_event(record, existing=load_catalyst_events(paths.catalysts)) + if event_preview.state == "rejected": + raise ValueError(event_preview.reason) + else: + outcome_preview = preview_outcome(record, existing=load_outcomes(paths.outcomes)) + if outcome_preview.state == "rejected": + raise ValueError(outcome_preview.reason) + except (OSError, UnicodeError, csv.Error, ValueError) as exc: + return AuthoringPreview( + "rejected", + str(exc), + draft.record_kind, + draft.profile_key, + draft.ticker, + destination.name, + previewed_at, + (), + "", + draft_digest, + ledger_fingerprint, + None, + ) + persisted = tuple((key, str(value)) for key, value in asdict(record).items()) + receipt = _stable_digest( + { + "draft": draft_digest, + "ledger": ledger_fingerprint, + "record": persisted, + "previewed_at": previewed_at, + "destination_label": destination.name, + "destination": str(resolve_ledger_path(destination)), + } + ) + return AuthoringPreview( + "reviewable", + "", + draft.record_kind, + draft.profile_key, + draft.ticker, + destination.name, + previewed_at, + persisted, + receipt, + draft_digest, + ledger_fingerprint, + record, + ) + + +def confirm_authoring_preview( + preview: AuthoringPreview, + *, + current_draft: AuthoringDraft, + paths: AuthoringPaths, + active_profile_key: str, + active_ticker: str, + active_kind: str, + confirm_reviewed: bool, +) -> AuthoringSaveResult: + def rejected(state: str, reason: str) -> AuthoringSaveResult: + return AuthoringSaveResult( + state, + reason, + preview.record_kind, + "", + preview.destination_label, + False, + ) + if not confirm_reviewed: + return rejected("confirmation_required", "Review and confirm the exact preview before saving.") + if preview.state != "reviewable" or preview.record is None or not preview.receipt: + return rejected("rejected", "Only a valid preview can be confirmed.") + active = ( + str(active_profile_key or "").strip(), + str(active_ticker or "").strip().upper(), + str(active_kind or "").strip().lower(), + ) + if active != (preview.profile_key, preview.ticker, preview.record_kind): + return rejected("preview_stale", "Selected profile, ticker, or record type changed; preview again.") + if not hmac.compare_digest(_stable_digest(asdict(current_draft)), preview.draft_digest): + return rejected("preview_stale", "Draft changed after preview; preview again.") + destination = _destination(current_draft, paths) + result: AuthoringSaveResult | None = None + append_completed = False + persisted_record: JournalEntry | CatalystEvent | ResearchOutcome | None = None + try: + with ledger_write_lock(destination): + if not hmac.compare_digest(_ledger_fingerprint(destination), preview.ledger_fingerprint): + result = rejected("preview_stale", "Ledger changed after preview; reload and preview again.") + else: + refreshed = preview_authoring_record( + current_draft, + paths=paths, + previewed_at=preview.previewed_at, + generated_id=_record_id(preview.record), + ) + if ( + refreshed.state != "reviewable" + or not hmac.compare_digest(refreshed.receipt, preview.receipt) + or refreshed.persisted_fields != preview.persisted_fields + or refreshed.destination_label != preview.destination_label + ): + result = rejected("preview_stale", "Record no longer matches the validated preview; preview again.") + elif not hmac.compare_digest(_ledger_fingerprint(destination), refreshed.ledger_fingerprint): + result = rejected("preview_stale", "Ledger changed during preview revalidation; preview again.") + else: + refreshed_record = refreshed.record + if refreshed_record is None: + result = rejected( + "preview_stale", + "Record no longer matches the validated preview; preview again.", + ) + else: + try: + if isinstance(refreshed_record, JournalEntry): + append_journal_entry(paths.journal, refreshed_record) + elif isinstance(refreshed_record, CatalystEvent): + append_reviewed_event( + paths.catalysts, refreshed_record, confirm_reviewed=True + ) + else: + append_reviewed_outcome( + paths.outcomes, refreshed_record, confirm_reviewed=True + ) + except (OSError, UnicodeError, csv.Error, ValueError) as exc: + try: + ledger_changed = not hmac.compare_digest( + _ledger_fingerprint(destination), + refreshed.ledger_fingerprint, + ) + except (OSError, UnicodeError, csv.Error): + ledger_changed = True + if ledger_changed: + result = AuthoringSaveResult( + "save_pending_reload", + "Save completion could not be verified; reloading the ledger once before retry.", + preview.record_kind, + _record_id(refreshed_record), + destination.name, + False, + ) + else: + result = rejected("save_failed", f"Record was not saved: {exc}") + else: + persisted_record = refreshed_record + append_completed = True + except OSError as exc: + if result is not None and result.state == "save_pending_reload": + return result + if append_completed: + return AuthoringSaveResult( + "save_pending_reload", + "Save completion could not be verified; reloading the ledger once before retry.", + preview.record_kind, + _record_id(persisted_record) if persisted_record is not None else "", + destination.name, + False, + ) + return rejected("save_failed", f"Record was not saved: {exc}") + if result is not None: + return result + if not append_completed: + return rejected("save_failed", "Record was not saved before confirmation completed.") + return AuthoringSaveResult( + "saved", + "Saved append-only reviewed record.", + preview.record_kind, + _record_id(persisted_record) if persisted_record is not None else "", + destination.name, + True, + ) diff --git a/src/research_record_authoring_ui.py b/src/research_record_authoring_ui.py new file mode 100644 index 00000000..a04a00f3 --- /dev/null +++ b/src/research_record_authoring_ui.py @@ -0,0 +1,375 @@ +"""Streamlit composer for append-only reviewed research records.""" + +from __future__ import annotations + +import csv +from datetime import UTC, datetime +from typing import Any +from uuid import uuid4 + +from src.research_record_authoring import ( + AuthoringPaths, + AuthoringPreview, + authoring_draft_digest, + build_authoring_draft, + confirm_authoring_preview, + preview_authoring_record, +) +from src.catalyst_evidence_timeline import load_catalyst_events +from src.research_outcome_review import load_outcomes +from src.research_thesis_journal import JournalEntry, load_journal_entries + + +FIELD_CONTRACTS = { + "thesis": ( + "thesis_id", + "summary", + "effective_at", + "reviewer", + "confidence", + "review_due_date", + "supersedes_entry_id", + ), + "evidence": ( + "thesis_id", + "summary", + "effective_at", + "reviewer", + "evidence_direction", + "source", + "source_ref", + "source_published_at", + ), + "catalyst": ( + "event_type", + "title", + "summary", + "effective_at", + "published_at", + "retrieved_at", + "source", + "source_ref", + "evidence_state", + "reviewer", + ), + "outcome": ( + "thesis_id", + "original_thesis_entry_id", + "reviewed_at", + "observation_start", + "observation_end", + "reviewer", + "outcome_state", + "summary", + "source", + "source_ref", + "source_published_at", + "learning", + ), +} + +SELECT_OPTIONS = { + "evidence_direction": ("supporting", "conflicting", "context"), + "event_type": ( + "earnings", + "product", + "regulatory", + "customer", + "industry", + "capital_allocation", + "management", + "macro", + ), + "evidence_state": ( + "candidate_context_only", + "supported", + "still_blocked", + "skipped", + "excluded", + ), + "outcome_state": ("supported", "mixed", "not_supported", "inconclusive"), +} + + +def authoring_field_contract(record_kind: str) -> tuple[str, ...]: + """Return the immutable input fields for one persisted record kind.""" + + try: + return FIELD_CONTRACTS[record_kind] + except KeyError as exc: + raise ValueError(f"Unsupported record kind: {record_kind!r}") from exc + + +def authoring_session_key(profile_key: str, ticker: str, suffix: str) -> str: + """Namespace transient widgets by their locked Company Workbench scope.""" + + return f"research-authoring:{profile_key}:{ticker.upper()}:{suffix}" + + +def _generated_id(kind: str) -> str: + return f"{kind}-{uuid4().hex}" + + +def _utc_now() -> str: + return datetime.now(UTC).isoformat().replace("+00:00", "Z") + + +def _scoped_theses( + paths: AuthoringPaths, profile_key: str, ticker: str +) -> tuple[JournalEntry, ...]: + return tuple( + row + for row in load_journal_entries(paths.journal) + if row.entry_type == "thesis" + and row.profile_key == profile_key + and row.ticker.upper() == ticker.upper() + ) + + +def _active_thesis(theses: tuple[JournalEntry, ...]) -> JournalEntry | None: + superseded_ids = {row.supersedes_entry_id for row in theses if row.supersedes_entry_id} + active = tuple(row for row in theses if row.entry_id not in superseded_ids) + if len(active) > 1: + raise ValueError("Journal contains more than one active thesis for this locked scope.") + return active[0] if active else None + + +def _field_label(name: str) -> str: + return name.replace("_", " ").title() + + +def _render_field( + st_api: Any, + *, + name: str, + kind: str, + profile_key: str, + ticker: str, + thesis_options: tuple[tuple[str, str], ...], + active_thesis: JournalEntry | None, + current_fields: dict[str, str], +) -> str: + key = authoring_session_key(profile_key, ticker, f"field:{kind}:{name}") + label = _field_label(name) + if name == "thesis_id" and kind == "thesis" and active_thesis is not None: + return st_api.text_input( + label, + value=active_thesis.thesis_id, + disabled=True, + key=key, + ) + if name == "thesis_id" and kind in {"evidence", "outcome"}: + return st_api.selectbox( + label, + tuple(dict.fromkeys(thesis_id for thesis_id, _ in thesis_options)), + key=key, + ) + if name == "original_thesis_entry_id": + selected_thesis_id = current_fields.get("thesis_id", "") + return st_api.selectbox( + label, + tuple(entry_id for thesis_id, entry_id in thesis_options if thesis_id == selected_thesis_id), + key=key, + ) + if name == "supersedes_entry_id": + return st_api.selectbox( + label, + (active_thesis.entry_id,) if active_thesis is not None else ("",), + key=key, + ) + if name in SELECT_OPTIONS: + return st_api.selectbox(label, SELECT_OPTIONS[name], key=key) + if name in {"summary", "learning"}: + return st_api.text_area(label, key=key) + return st_api.text_input(label, key=key) + + +def _render_preview(st_api: Any, preview: AuthoringPreview) -> None: + st_api.markdown("#### Exact append-only preview") + st_api.caption( + f"Destination: {preview.destination_label} | Previewed at: {preview.previewed_at}" + ) + st_api.dataframe( + [{"Field": name, "Value": value} for name, value in preview.persisted_fields], + width="stretch", + hide_index=True, + ) + st_api.caption( + "Research-only record. Corrections require a new append-only record; history is never edited or deleted." + ) + + +def _show_reloaded_save_receipt( + st_api: Any, *, profile_key: str, ticker: str, paths: AuthoringPaths +) -> None: + """Show one save receipt only after its persisted record is read back.""" + + symbol = ticker.upper() + receipt_key = authoring_session_key(profile_key, symbol, "pending-reload-receipt") + receipt = st_api.session_state.get(receipt_key) + if receipt is None: + return + del st_api.session_state[receipt_key] + if not isinstance(receipt, dict): + st_api.warning("Saved record could not be reloaded; review the ledger") + return + record_kind = str(receipt.get("record_kind") or "").strip().lower() + record_id = str(receipt.get("record_id") or "").strip() + try: + if record_kind in {"thesis", "evidence"}: + record_reloaded = any( + entry.entry_id == record_id + and entry.profile_key == profile_key + and entry.ticker.upper() == symbol + for entry in load_journal_entries(paths.journal) + ) + elif record_kind == "catalyst": + record_reloaded = any( + event.event_id == record_id + and event.profile_key == profile_key + and event.ticker.upper() == symbol + for event in load_catalyst_events(paths.catalysts) + ) + elif record_kind == "outcome": + record_reloaded = any( + outcome.outcome_id == record_id + and outcome.profile_key == profile_key + and outcome.ticker.upper() == symbol + for outcome in load_outcomes(paths.outcomes) + ) + else: + record_reloaded = False + except (OSError, UnicodeError, csv.Error, ValueError): + record_reloaded = False + if record_reloaded: + st_api.success( + f"Saved {record_id}. Corrections require a new append-only record; history is never edited or deleted." + ) + else: + st_api.warning("Saved record could not be reloaded; review the ledger") + + +def render_research_record_authoring( + *, st_api: Any, profile_key: str, ticker: str, paths: AuthoringPaths +) -> None: + """Render a locked-scope, preview-first authoring composer without direct writes.""" + + symbol = ticker.upper() + _show_reloaded_save_receipt( + st_api, + profile_key=profile_key, + ticker=symbol, + paths=paths, + ) + with st_api.expander("Add a reviewed research record", expanded=False): + st_api.markdown( + f"Profile: {profile_key} | Ticker: {symbol} — locked to this Company Workbench." + ) + kind = st_api.selectbox( + "Record type", + tuple(FIELD_CONTRACTS), + format_func=str.title, + key=authoring_session_key(profile_key, symbol, "kind"), + ) + thesis_options: tuple[tuple[str, str], ...] = () + active_thesis: JournalEntry | None = None + if kind in {"thesis", "evidence", "outcome"}: + try: + scoped_theses = _scoped_theses(paths, profile_key, symbol) + thesis_options = tuple( + (row.thesis_id, row.entry_id) for row in scoped_theses + ) + active_thesis = _active_thesis(scoped_theses) + except (OSError, ValueError) as exc: + st_api.error(f"Thesis references could not be loaded; no record can be saved: {exc}") + return + if kind in {"evidence", "outcome"} and not thesis_options: + st_api.warning( + "This record type requires an existing thesis in this locked profile and ticker. " + "Add and confirm a thesis first." + ) + return + if kind == "thesis" and active_thesis is not None: + st_api.caption( + "This record will be a revision of the active thesis. " + f"Lineage {active_thesis.thesis_id} and active entry " + f"{active_thesis.entry_id} are locked." + ) + + fields: dict[str, str] = {} + for name in authoring_field_contract(kind): + fields[name] = _render_field( + st_api, + name=name, + kind=kind, + profile_key=profile_key, + ticker=symbol, + thesis_options=thesis_options, + active_thesis=active_thesis, + current_fields=fields, + ) + draft = build_authoring_draft( + kind, profile_key=profile_key, ticker=symbol, fields=fields + ) + preview_key = authoring_session_key(profile_key, symbol, "preview") + confirmation_key = authoring_session_key(profile_key, symbol, "confirmed") + reset_key = authoring_session_key(profile_key, symbol, "reset-confirmation") + if st_api.session_state.pop(reset_key, False): + st_api.session_state.pop(confirmation_key, None) + if st_api.button( + "Validate and preview", + key=authoring_session_key(profile_key, symbol, "validate"), + use_container_width=True, + ): + st_api.session_state.pop( + confirmation_key, None + ) + st_api.session_state[preview_key] = preview_authoring_record( + draft, + paths=paths, + previewed_at=_utc_now(), + generated_id=_generated_id(kind), + ) + preview: AuthoringPreview | None = st_api.session_state.get(preview_key) + if preview is None: + st_api.caption( + "No record is saved until this draft passes preview and you confirm the exact reviewed source evidence." + ) + return + if preview.draft_digest != authoring_draft_digest(draft): + st_api.warning( + "Draft changed after preview. Validate and preview again before saving." + ) + return + if preview.state == "rejected": + st_api.error(preview.reason) + return + + _render_preview(st_api, preview) + confirmed = st_api.checkbox( + "I reviewed this exact record and its source evidence", + key=confirmation_key, + ) + if st_api.button( + "Confirm and save", + key=authoring_session_key(profile_key, symbol, "save"), + use_container_width=True, + ): + result = confirm_authoring_preview( + preview, + current_draft=draft, + paths=paths, + active_profile_key=profile_key, + active_ticker=symbol, + active_kind=kind, + confirm_reviewed=confirmed, + ) + if result.state in {"saved", "save_pending_reload"}: + del st_api.session_state[preview_key] + st_api.session_state[reset_key] = True + st_api.session_state[ + authoring_session_key(profile_key, symbol, "pending-reload-receipt") + ] = {"record_kind": result.record_kind, "record_id": result.record_id} + st_api.rerun() + else: + st_api.error(result.reason) diff --git a/src/research_thesis_journal.py b/src/research_thesis_journal.py index a98fb0be..cc68dcdb 100644 --- a/src/research_thesis_journal.py +++ b/src/research_thesis_journal.py @@ -9,6 +9,8 @@ from pathlib import Path from typing import Iterable +from src.research_ledger_lock import ledger_write_lock + JOURNAL_SCHEMA_VERSION = "research-thesis-journal-v1" JOURNAL_COLUMNS = ( @@ -151,24 +153,47 @@ def validate_journal_entry(entry: JournalEntry, *, existing_entries: Iterable[Jo existing = tuple(existing_entries) if any(row.entry_id == entry.entry_id for row in existing): raise ValueError(f"entry_id already exists: {entry.entry_id}") + scoped_theses = tuple( + row + for row in existing + if row.entry_type == "thesis" + and row.profile_key == entry.profile_key + and row.ticker.strip().upper() == entry.ticker.strip().upper() + ) + superseded_ids = { + row.supersedes_entry_id for row in scoped_theses if row.supersedes_entry_id + } + active_theses = tuple( + row for row in scoped_theses if row.entry_id not in superseded_ids + ) + if len(active_theses) > 1: + raise ValueError( + "Journal contains more than one active thesis for the selected profile and ticker." + ) target = next((row for row in existing if row.entry_id == entry.supersedes_entry_id), None) if entry.supersedes_entry_id: if target is None: raise ValueError("supersedes_entry_id must reference an existing thesis entry") if entry.entry_type != "thesis" or target.entry_type != "thesis": raise ValueError("only thesis entries may supersede prior thesis entries") - if (target.profile_key, target.ticker, target.thesis_id) != ( - entry.profile_key, - entry.ticker, - entry.thesis_id, + if target.profile_key != entry.profile_key or ( + target.ticker.strip().upper() != entry.ticker.strip().upper() ): raise ValueError("a thesis revision must supersede an entry for the same profile, ticker, and thesis") - elif entry.entry_type == "thesis" and any( - row.entry_type == "thesis" - and (row.profile_key, row.ticker, row.thesis_id) == (entry.profile_key, entry.ticker, entry.thesis_id) - for row in existing - ): - raise ValueError("a later thesis entry must name supersedes_entry_id") + if entry.entry_type == "thesis" and active_theses: + active = active_theses[0] + if entry.supersedes_entry_id != active.entry_id: + raise ValueError( + f"a new thesis must supersede the active thesis entry: {active.entry_id}" + ) + if entry.thesis_id != active.thesis_id: + raise ValueError( + f"a thesis revision must preserve the active thesis_id: {active.thesis_id}" + ) + if entry.supersedes_entry_id and target is not None and target.thesis_id != entry.thesis_id: + raise ValueError( + "a thesis revision must supersede an entry for the same profile, ticker, and thesis" + ) def load_journal_entries(path: Path | str) -> tuple[JournalEntry, ...]: @@ -197,15 +222,16 @@ def append_journal_entry(path: Path | str, entry: JournalEntry) -> Path: """Append one reviewed entry without modifying source or readiness data.""" destination = Path(path) - existing = load_journal_entries(destination) - validate_journal_entry(entry, existing_entries=existing) - destination.parent.mkdir(parents=True, exist_ok=True) - exists = destination.exists() and destination.stat().st_size > 0 - with destination.open("a", newline="", encoding="utf-8") as handle: - writer = csv.DictWriter(handle, fieldnames=JOURNAL_COLUMNS) - if not exists: - writer.writeheader() - writer.writerow(asdict(entry)) + with ledger_write_lock(destination): + existing = load_journal_entries(destination) + validate_journal_entry(entry, existing_entries=existing) + destination.parent.mkdir(parents=True, exist_ok=True) + exists = destination.exists() and destination.stat().st_size > 0 + with destination.open("a", newline="", encoding="utf-8") as handle: + writer = csv.DictWriter(handle, fieldnames=JOURNAL_COLUMNS) + if not exists: + writer.writeheader() + writer.writerow(asdict(entry)) return destination diff --git a/src/research_workspace.py b/src/research_workspace.py new file mode 100644 index 00000000..3f6bd555 --- /dev/null +++ b/src/research_workspace.py @@ -0,0 +1,683 @@ +"""Read-only composition helpers for the personal research workspace.""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping +import html +import pandas as pd +from urllib.parse import quote + +from src.company_workbench_cash_generation_preview import ( + CompanyWorkbenchCashGenerationPreview, +) +from src.focused_cohort_coverage import FocusedCohortCoverage +from src.focused_research_cohort import FocusedCohort +from src.quarterly_business_trend import QuarterlyTrendPacket +from src.weekly_research_summary import WeeklyResearchSummary + + +RESEARCH_ROUTING_STATES = {"review_now", "monitor", "wait_for_evidence", "excluded"} + + +def research_desk_cards(*, change_status: str, review_items, readiness_summary: dict[str, object]): + items = tuple(review_items or ()) + total = int(readiness_summary.get("master_universe") or readiness_summary.get("universe_count") or 0) + price_ready = int(readiness_summary.get("price_ready") or 0) + dcf_ready = int(readiness_summary.get("dcf_ready") or 0) + peer_ready = int(readiness_summary.get("peer_ready") or 0) + change_count = len(items) + comparable = str(change_status or "").strip() in {"changes_detected", "no_changes"} + changed_answer = ( + f"{change_count} unresolved evidence change{'s' if change_count != 1 else ''} need review." + if change_count + else "No unresolved evidence-backed change is available for review." + if comparable + else "A comparable before-and-after research snapshot is not available yet." + ) + attention_answer = ( + f"{change_count} queued evidence item{'s' if change_count != 1 else ''} need attention." + if change_count + else "No company is currently queued from verified change evidence." + ) + blocked_count = max(total - dcf_ready, 0) if total else 0 + blocked_answer = ( + f"{blocked_count} of {total} tracked rows do not have a source-backed DCF-ready path; " + f"{max(total - peer_ready, 0)} still lack trusted peer readiness." + if total + else "Readiness totals are unavailable; blocked or stale states remain withheld." + ) + next_answer = ( + "Review the queued evidence changes first, then open Discover for the next company." + if change_count + else "Open Discover to choose a readiness-backed company for review." + ) + return [ + { + "question": "What changed?", + "answer": changed_answer, + "routing_state": "review_now" if change_count else "monitor", + }, + { + "question": "Which companies need attention?", + "answer": attention_answer, + "routing_state": "review_now" if change_count else "monitor", + }, + { + "question": "What is blocked or stale?", + "answer": blocked_answer, + "routing_state": "wait_for_evidence" if blocked_count else "monitor", + }, + { + "question": "What should I review next?", + "answer": next_answer, + "routing_state": "review_now" if change_count else "monitor", + }, + ] + + +def company_workbench_section_contract() -> list[dict[str, object]]: + return [ + { + "title": "Selected Company", + "contents": ["Ticker and entity", "Research state", "Data confidence", "Freshness"], + "expanded": True, + }, + { + "title": "What Changed", + "contents": ["Source-backed evidence changes", "Previous state", "Current state", "Review task"], + "expanded": True, + }, + { + "title": "Business Trend", + "contents": ["Financial trend", "Price trend", "Evidence window", "Unavailable inputs"], + "expanded": True, + }, + { + "title": "Valuation", + "contents": ["Source-backed range", "Assumptions", "Sensitivity", "Withheld state"], + "expanded": True, + }, + { + "title": "Forward View", + "contents": ["Scenarios", "Peer context", "Earnings outlook", "Catalysts and risks"], + "expanded": True, + }, + { + "title": "What Remains Withheld", + "contents": ["Missing periods", "Blocked inputs", "Candidate context", "Source wait conditions"], + "expanded": True, + }, + { + "title": "Research Conclusion", + "contents": ["Usable evidence", "Uncertainty", "Routing state", "Next research action"], + "expanded": True, + }, + { + "title": "Next Research Task", + "contents": ["One evidence review action", "Wait condition when unavailable"], + "expanded": True, + }, + { + "title": "Advanced Evidence", + "contents": ["Provenance", "Freshness Timeline", "Data Health", "Proof History", "Raw evidence"], + "expanded": False, + }, + ] + + +def company_change_answer(ticker: str, review_items) -> dict[str, object]: + symbol = str(ticker or "").strip().upper() + matching = [item for item in tuple(review_items or ()) if str(item.event.ticker or "").strip().upper() == symbol] + eligible = [ + item + for item in matching + if str(getattr(item.event, "evidence_status", "") or "").strip() == "source_backed" + ] + source_refs = tuple( + dict.fromkeys( + str(getattr(item.event, "source_ref", "") or "").strip() + for item in eligible + if str(getattr(item.event, "source_ref", "") or "").strip() + ) + ) + count = len({str(getattr(item.event, "event_id", "") or id(item)) for item in eligible}) + if not matching: + return { + "state": "monitor", + "answer": "No unresolved source-backed change is queued for this company.", + "next_task": "Continue the current review or wait for changed source evidence.", + "source_refs": (), + "source_backed_eligible": False, + "change_context_kind": "none", + } + if not count: + snapshot_item = matching[0] + snapshot_status = str(getattr(snapshot_item, "review_status", "") or "").strip() + snapshot_state = { + "open": "review_now", + "still_blocked": "wait_for_evidence", + "intentionally_deferred": "monitor", + }.get(snapshot_status, "monitor") + snapshot_task = str(getattr(snapshot_item.event, "suggested_research_task", "") or "").strip() + if snapshot_status == "still_blocked": + snapshot_task = str(getattr(snapshot_item, "wait_condition", "") or "").strip() or ( + "Reviewed evidence remains blocked; wait for new source evidence." + ) + elif snapshot_status == "intentionally_deferred": + snapshot_task = str(getattr(snapshot_item, "wait_condition", "") or "").strip() or ( + "Review is intentionally deferred until the recorded condition changes." + ) + return { + "state": snapshot_state, + "answer": "Snapshot-only change context is visible but cannot outrank a source-backed research priority.", + "next_task": snapshot_task or "Continue the current review or wait for changed source evidence.", + "source_refs": (), + "source_backed_eligible": False, + "change_context_kind": "snapshot_only", + } + + first = eligible[0] + review_status = str(getattr(first, "review_status", "") or "").strip() + state = { + "open": "review_now", + "still_blocked": "wait_for_evidence", + "intentionally_deferred": "monitor", + }.get(review_status, "monitor") + first_task = str(getattr(first.event, "suggested_research_task", "") or "Review the changed evidence.").strip() + if review_status == "still_blocked": + first_task = str(getattr(first, "wait_condition", "") or "").strip() or ( + "Reviewed evidence remains blocked; wait for new source evidence." + ) + elif review_status == "intentionally_deferred": + first_task = str(getattr(first, "wait_condition", "") or "").strip() or ( + "Review is intentionally deferred until the recorded condition changes." + ) + return { + "state": state, + "answer": ( + "1 unresolved source-backed change needs review." + if count == 1 + else f"{count} unresolved source-backed changes need review." + ), + "next_task": first_task, + "source_refs": source_refs, + "source_backed_eligible": True, + "change_context_kind": "source_backed", + } + + +def _neutral_company_next_research_task() -> dict[str, object]: + return { + "title": "Wait for reviewed evidence or choose another company", + "body": "No source-backed change or executable company task is available. Do not infer one from missing data.", + "state": "wait_for_evidence", + "badges": ["monitor", "research-only"], + } + + +def company_next_research_task( + change_answer: Mapping[str, object] | None, + conclusion_cards: Iterable[Mapping[str, object]] | None, +) -> dict[str, object]: + if not isinstance(change_answer, Mapping): + return _neutral_company_next_research_task() + if ( + not isinstance(conclusion_cards, Iterable) + or isinstance(conclusion_cards, (str, bytes, Mapping)) + ): + return _neutral_company_next_research_task() + try: + raw_cards = tuple(conclusion_cards) + except TypeError: + return _neutral_company_next_research_task() + + cards: list[tuple[Mapping[str, object], tuple[object, ...]]] = [] + for raw_card in raw_cards: + if not isinstance(raw_card, Mapping): + return _neutral_company_next_research_task() + raw_badges = raw_card.get("badges", ()) + if ( + not isinstance(raw_badges, Iterable) + or isinstance(raw_badges, (str, bytes, Mapping)) + ): + return _neutral_company_next_research_task() + try: + badges = tuple(raw_badges) + except TypeError: + return _neutral_company_next_research_task() + cards.append((raw_card, badges)) + + change_state = str(change_answer.get("state") or "").strip() + change_title = str(change_answer.get("next_task") or "").strip() + if ( + change_answer.get("source_backed_eligible") is True + and change_state in RESEARCH_ROUTING_STATES + and change_title + ): + body = { + "review_now": "Complete this source-backed evidence review before starting another research task.", + "wait_for_evidence": "This source-backed change remains blocked; preserve its recorded wait condition.", + "monitor": "This source-backed change is intentionally deferred; preserve its recorded wait condition.", + }.get(change_state, "Preserve this source-backed change routing state before starting another research task.") + return { + "title": change_title, + "body": body, + "state": change_state, + "badges": ["source-backed change", "research-only"], + } + + for card, raw_badges in cards: + title = str(card.get("title") or "").strip() + if not title: + continue + badges = [str(value).strip() for value in raw_badges if str(value).strip()] + return { + "title": title, + "body": str(card.get("body") or "").strip(), + "state": ( + str(card.get("state") or "").strip() + if str(card.get("state") or "").strip() in RESEARCH_ROUTING_STATES + else "wait_for_evidence" + ), + "badges": list(dict.fromkeys([*badges, "research-only"])), + } + + return _neutral_company_next_research_task() + + +def focused_cohort_cards(cohort: FocusedCohort) -> list[dict[str, object]]: + return [ + { + "kicker": "FOCUSED COHORT", + "title": f"{len(cohort.members)} of {cohort.requested_size} requested companies", + "body": cohort.message, + "state": cohort.status, + "badges": [cohort.status.replace("_", " "), "evidence availability"], + "command": "", + }, + { + "kicker": "SELECTION BOUNDARY", + "title": "Reviewability, not expected return", + "body": "Operating-company and price readiness establish eligibility; deeper source-backed lanes improve review order without creating a recommendation.", + "state": "research_only", + "badges": ["deterministic", "no recommendation score"], + "command": "", + }, + ] + + +def focused_cohort_coverage_cards(coverage: FocusedCohortCoverage) -> list[dict[str, object]]: + usable = sum(row.state == "usable_now" for row in coverage.rows) + candidate = sum(row.state == "candidate_context_only" for row in coverage.rows) + gated = sum(row.state in {"partial", "candidate_context_only", "blocked"} for row in coverage.rows) + excluded = sum(row.state == "excluded" for row in coverage.rows) + return [ + { + "kicker": "COHORT COVERAGE", + "title": f"{usable} usable lane{'s' if usable != 1 else ''}", + "body": coverage.message, + "state": coverage.status, + "badges": [f"{coverage.company_count} companies", "saved evidence only"], + "command": "", + }, + { + "kicker": "WITHHELD OR CONTEXT ONLY", + "title": f"{gated} gated lane{'s' if gated != 1 else ''}", + "body": ( + f"{candidate} lane{'s are' if candidate != 1 else ' is'} candidate context only; " + f"{excluded} lane{'s are' if excluded != 1 else ' is'} excluded as not applicable. " + "Research-only states remain separate from supported evidence." + ), + "state": "wait_for_evidence" if gated else "monitor", + "badges": ["candidate context separated", "no inferred coverage"], + "command": "", + }, + ] + + +def focused_ticker_coverage_cards( + coverage: FocusedCohortCoverage, + ticker: str, +) -> list[dict[str, object]]: + symbol = str(ticker or "").strip().upper() + rows = tuple(row for row in coverage.rows if row.ticker == symbol) + usable = sum(row.state == "usable_now" for row in rows) + partial = sum(row.state in {"partial", "candidate_context_only"} for row in rows) + blocked = sum(row.state == "blocked" for row in rows) + excluded = sum(row.state == "excluded" for row in rows) + return [ + { + "kicker": f"{symbol or 'SELECTED COMPANY'} COVERAGE", + "title": f"{usable} usable lane{'s' if usable != 1 else ''}", + "body": "Saved source evidence supports these lanes now; each remains research context only.", + "state": "ready" if usable and not (partial or blocked) else "partial" if rows else "blocked", + "badges": ["source-backed only", "no inferred inputs"], + "command": "", + }, + { + "kicker": "GATED OR NOT APPLICABLE", + "title": f"{blocked} blocked lane{'s' if blocked != 1 else ''}", + "body": ( + f"{partial} partial or candidate-context lane(s); {excluded} excluded lane(s). " + "Open Advanced Evidence only when the exact lane proof is needed." + ), + "state": "wait_for_evidence" if blocked or partial else "monitor", + "badges": ["blocked stays blocked", "research-only"], + "command": "", + }, + ] + + +def quarterly_trend_cards(packet: QuarterlyTrendPacket) -> list[dict[str, object]]: + cards: list[dict[str, object]] = [ + { + "kicker": "QUARTERLY BUSINESS TREND", + "title": packet.latest_fiscal_period or "Quarterly evidence unavailable", + "body": packet.message, + "state": packet.status, + "badges": [packet.status, packet.source_confidence.replace("_", " ")], + "command": "", + } + ] + metrics = ( + ("Revenue", packet.revenue, False), + ("EPS", packet.eps, False), + ("Operating margin", packet.operating_margin, True), + ("Free cash flow", packet.free_cash_flow, False), + ("FCF margin", packet.fcf_margin, True), + ) + for label, trend, is_percent in metrics: + change_parts = [] + if trend.sequential_change_pct is not None: + change_parts.append(f"sequential {trend.sequential_change_pct:+.1f}%") + if trend.year_over_year_change_pct is not None: + change_parts.append(f"year over year {trend.year_over_year_change_pct:+.1f}%") + boundary = "; ".join((*trend.missing_comparisons, trend.withheld_reason)).strip("; ") + if trend.latest_value is None: + title = "Withheld" + elif is_percent: + title = f"{trend.latest_value * 100:.1f}%" + else: + title = f"{trend.latest_value:g}" + cards.append( + { + "kicker": label.upper(), + "title": title, + "body": ", ".join(change_parts) if change_parts else boundary or "Comparable change is withheld.", + "state": trend.status, + "badges": [trend.status, trend.latest_fiscal_period or "no period"], + "command": "", + } + ) + return cards + + +def _cash_preview_display_value(metric: str, value: float | None) -> str: + if value is None: + return "Withheld" + if metric in {"operating_margin", "fcf_margin"}: + return f"{value * 100.0:.1f}%" + return f"{value:,.0f}" + + +def cash_generation_preview_cards( + preview: CompanyWorkbenchCashGenerationPreview, +) -> list[dict[str, object]]: + """Return answer-first preview cards without technical lineage.""" + + cards: list[dict[str, object]] = [ + { + "kicker": "CASH-GENERATION REVIEW PREVIEW", + "title": "Cash-generation review preview — not production evidence", + "body": ( + f"{preview.message} Production activation is false; readiness " + "promotions are none; no persistence or readiness rebuild occurs." + ), + "state": "preview_only" if preview.status == "accepted_for_review" else "withheld", + "badges": ["preview only", "not production evidence"], + "command": "", + } + ] + for label, metric in ( + ("OPERATING MARGIN", preview.operating_margin), + ("FREE CASH FLOW", preview.free_cash_flow), + ("FCF MARGIN", preview.fcf_margin), + ): + cards.append( + { + "kicker": label, + "title": _cash_preview_display_value(metric.metric, metric.value), + "body": ( + f"{metric.fiscal_period} accepted packet preview." + if metric.value is not None + else metric.withheld_reason + or "Complete compatible evidence is required." + ), + "state": metric.status, + "badges": ["preview only", metric.status.replace("_", " ")], + "command": "", + } + ) + return cards + + +def cash_generation_preview_rows( + preview: CompanyWorkbenchCashGenerationPreview, +) -> list[dict[str, object]]: + """Return technical preview lineage for the collapsed Advanced surface.""" + + def evidence_row( + evidence: str, + value: object, + *, + definition: str = "", + source_ref: str = "", + published_at: str = "", + retrieved_at: str = "", + ) -> dict[str, object]: + return { + "Evidence": evidence, + "Value": value, + "Definition": definition, + "Source Reference": source_ref, + "Published At": published_at, + "Retrieved At": retrieved_at, + } + + rows = [ + evidence_row("Accession", preview.accession, source_ref=preview.source_url), + evidence_row("Source", preview.source_url, source_ref=preview.source_url), + evidence_row("Accepted at", preview.accepted_at, published_at=preview.accepted_at), + evidence_row("Cutoff", preview.cutoff), + evidence_row("Capex sign", preview.capex_sign_evidence), + evidence_row( + "Boundary", + "production activation false; readiness promotions none; no persistence; no readiness rebuild", + ), + ] + rows.extend( + evidence_row("Blocker", blocker) + for blocker in preview.blockers + ) + rows.extend( + evidence_row( + "Component", + f"{component.metric}: {component.value:g} {component.currency}", + definition=( + f"{component.accounting_basis}; {component.duration_basis}; " + f"{component.fiscal_period}; {component.q4_evidence_state}" + ), + source_ref=component.source_ref, + published_at=component.published_at, + retrieved_at=component.retrieved_at, + ) + for component in preview.components + ) + return rows + + +def weekly_summary_cards(summary: WeeklyResearchSummary) -> list[dict[str, object]]: + cards: list[dict[str, object]] = [ + { + "kicker": "WEEKLY RESEARCH SUMMARY", + "title": f"{len(summary.items)} traceable item{'s' if len(summary.items) != 1 else ''}", + "body": summary.message, + "state": "review_now" if summary.items else "monitor", + "badges": [summary.status.replace("_", " "), f"{summary.cohort_size} companies"], + "command": "", + } + ] + cards.extend( + { + "kicker": item.category.replace("_", " ").upper(), + "title": item.ticker, + "body": item.answer, + "state": item.state, + "badges": [item.state.replace("_", " "), "traceable"], + "command": "", + } + for item in summary.items[:5] + ) + return cards + + +def research_monitor_frame(review_items) -> pd.DataFrame: + rows: list[dict[str, str]] = [] + seen_event_ids: set[str] = set() + state_map = { + "open": "review_now", + "still_blocked": "wait_for_evidence", + "intentionally_deferred": "monitor", + "excluded": "excluded", + } + for item in tuple(review_items or ()): + event = item.event + event_id = str(getattr(event, "event_id", "") or "").strip() + deduplication_key = event_id or "\x1f".join( + str(getattr(event, field, "") or "") + for field in ("ticker", "family", "subtype", "prior_value", "current_value", "source_ref", "detected_at") + ) + if deduplication_key in seen_event_ids: + continue + seen_event_ids.add(deduplication_key) + rows.append( + { + "Ticker": str(event.ticker or "").upper(), + "Change": str(event.subtype or "").replace("_", " ").title(), + "Previous state": str(getattr(event, "prior_value", "") or ""), + "Current state": str(getattr(event, "current_value", "") or ""), + "Evidence": str(event.evidence_status or "").replace("_", " "), + "Affected section": str(getattr(event, "family", "") or "").replace("_", " ").title(), + "Review state": state_map.get(str(item.review_status or ""), "monitor"), + "Effective date": str(getattr(event, "source_published_at", "") or ""), + "Detected": str(event.detected_at or ""), + "Next research task": str(event.suggested_research_task or item.wait_condition or ""), + "Wait condition": str(item.wait_condition or ""), + } + ) + return pd.DataFrame( + rows, + columns=[ + "Ticker", + "Change", + "Previous state", + "Current state", + "Evidence", + "Affected section", + "Review state", + "Effective date", + "Detected", + "Next research task", + "Wait condition", + ], + ) + + +def _quoted_ticker(ticker: str) -> str: + return quote(str(ticker or "").strip().upper(), safe="") + + +def advanced_evidence_links(ticker: str) -> list[dict[str, str]]: + symbol = _quoted_ticker(ticker) + suffix = f"&ticker={symbol}" if symbol else "" + return [ + { + "label": "Open Data Health", + "href": f"?mode=research&page=data-health{suffix}", + "purpose": "Inspect blocked inputs and source-proof paths.", + }, + { + "label": "Open Proof History", + "href": f"?mode=research&page=proof-history{suffix}", + "purpose": "Review evidence that changed a readiness state.", + }, + ] + + +def research_evidence_return_link(ticker: str) -> dict[str, str]: + symbol = _quoted_ticker(ticker) + if symbol: + return { + "label": "Return to Company Workbench", + "href": f"?mode=research&page=company-workbench&ticker={symbol}&open=1", + "purpose": "Continue the selected-company review without changing evidence state.", + } + return { + "label": "Return to Research Desk", + "href": "?mode=research&page=research-desk", + "purpose": "Return to the primary research workflow without changing evidence state.", + } + + +def research_workspace_header_html( + page_title: str, + *, + ticker: str = "", + profile_label: str, + freshness: str, + primary_action: str, + compact: bool = False, +) -> str: + scope = str(ticker or "Focused research scope").strip().upper() if ticker else "Focused research scope" + header_class = "research-workspace-header compact" if compact else "research-workspace-header" + meta_html = "" if compact else ( + "
" + f"
Freshness
{html.escape(str(freshness or 'Check saved readiness'))}
" + f"
Next action
{html.escape(str(primary_action or 'Review source-backed evidence'))}
" + "
" + ) + return ( + f"
" + "
" + "Personal research mode" + f"

{html.escape(str(page_title or 'Research Desk'))}

" + f"

{html.escape(scope)} · {html.escape(str(profile_label or 'Local research'))}

" + "
" + f"{meta_html}" + "

Research-only. Not investment advice; no trade instruction is produced.

" + "
" + ) + + +def research_desk_cards_html(cards) -> str: + rendered = "".join( + "
" + f"{html.escape(str(card.get('routing_state') or 'monitor').replace('_', ' '))}" + f"

{html.escape(str(card.get('question') or 'Research question'))}

" + f"

{html.escape(str(card.get('answer') or 'No verified answer is available.'))}

" + "
" + for card in cards + ) + return f"
{rendered}
" + + +def advanced_evidence_links_html(ticker: str) -> str: + rendered = "".join( + "" + f"{html.escape(link['label'])}" + f"{html.escape(link['purpose'])}" + "" + for link in advanced_evidence_links(ticker) + ) + return f"" diff --git a/src/reviewed_batch.py b/src/reviewed_batch.py index e5a4412e..95862a05 100644 --- a/src/reviewed_batch.py +++ b/src/reviewed_batch.py @@ -18,6 +18,7 @@ from src.session_source_preflight import load_session_source_preflight from src.share_count_proof_queue import build_share_count_proof_queue_from_files from src.paths import resolve_data_dir, resolve_outputs_dir, resolve_project_root +from src.profile_context import build_profile_context DEFAULT_PACKET_MD = Path("outputs/reviewed_batch_packet.md") @@ -240,7 +241,7 @@ def readiness_freshness_status( ) -> FreshnessStatus: project_root = resolve_project_root(root) data_path = resolve_data_dir(data_dir, project_root) - resolve_outputs_dir(output_dir, project_root) + output_path = resolve_outputs_dir(output_dir, project_root) readiness_paths = [_selected_data_path(path, data_path) for path in REQUIRED_READINESS_REPORTS] source_paths = [_selected_data_path(path, data_path) for path in SOURCE_FILES_FOR_FRESHNESS] missing = [str(path.relative_to(data_path)) for path in readiness_paths if not path.exists()] @@ -260,6 +261,17 @@ def readiness_freshness_status( + ", ".join(str(path.relative_to(data_path)) for path in newer_sources) + ". Run make readiness before relying on final counts.", ) + profile_context = build_profile_context( + project_root=project_root, + data_dir=data_path, + output_dir=output_path, + ) + if profile_context.freshness_state != "current": + return FreshnessStatus( + profile_context.freshness_state, + profile_context.freshness_message, + profile_context.refresh_command or "make readiness", + ) return FreshnessStatus("current", "Readiness artifacts are current relative to watched source files.") @@ -576,7 +588,7 @@ def _lane_proof_instructions(lane: str, top_n: int) -> list[str]: "Record peer_mapping_ready, peer_price_ready, peer_momentum_ready, peer_fundamentals_ready, peer_valuation_ready, and peer_valuation_comparison_ready before changes.", "Start from the first-class packet command: make peer-batch-proof TOP_N= or make peer-batch-proof TICKERS=.", f"Inspect missing peer relationships with make peer-mapping-queue TOP_N={top_n} and make focus-peers TICKER=.", - "Peer mapping import schema: ticker, peer_ticker, peer_group, sector, industry, source, as_of_date.", + "Peer mapping import schema: ticker, peer_ticker, peer_group, sector, industry, peer_role, relationship_rationale, comparability_basis, valuation_anchor_eligible, source, as_of_date.", "Source proof checklist: source must name the peer relationship or comparable business context, include a durable URL or local document reference, and have a review date; do not use memory, popularity, or row-count convenience as proof.", "Treat sector or industry fallback as context only; it is not trusted peer mapping proof.", "Run make imports-validate and make imports-preview before imports-apply; data/rejected/peers_import_rejected.csv must be clear or explained.", diff --git a/src/sec_quarterly_cash_generation_pilot.py b/src/sec_quarterly_cash_generation_pilot.py new file mode 100644 index 00000000..6d47c1c8 --- /dev/null +++ b/src/sec_quarterly_cash_generation_pilot.py @@ -0,0 +1,562 @@ +"""Read-only SEC evidence parsing for one exact quarterly cash-generation pilot.""" + +from __future__ import annotations + +import math +from dataclasses import dataclass, field +from datetime import date +from html.parser import HTMLParser +from typing import Any, Mapping + +from src.commercial_source_rights import SourceRights +from src.earnings_nowcast_contract import QuarterlyActual, parse_utc_timestamp +from src.quarterly_cash_generation import QuarterlyBusinessObservation +from src.quarterly_cash_generation_adapter import ( + QuarterlyAdapterAcceptance, + assess_quarterly_cash_generation_adapter, +) + + +SOURCE_ID = "sec_companyfacts" +REVENUE_CONCEPTS = ( + "RevenueFromContractWithCustomerExcludingAssessedTax", + "Revenues", + "SalesRevenueNet", + "SalesRevenueGoodsNet", +) +OPERATING_INCOME_CONCEPTS = ("OperatingIncomeLoss",) +CASH_FROM_OPERATIONS_CONCEPTS = ( + "NetCashProvidedByUsedInOperatingActivities", + "NetCashProvidedByUsedInOperatingActivitiesContinuingOperations", +) +CAPEX_CONCEPTS = ( + "PaymentsToAcquireProductiveAssets", + "PaymentsToAcquirePropertyPlantAndEquipment", +) + + +@dataclass(frozen=True) +class SecQuarterlyPilotExtraction: + ticker: str + cik: str + fiscal_period: str + period_start_date: str + period_end_date: str + accession: str + filing_date: str + accepted_at: str + source_url: str + observations: tuple[QuarterlyBusinessObservation, ...] + revenue_actuals: tuple[QuarterlyActual, ...] + capex_sign_evidence: str + blockers: tuple[str, ...] + + +@dataclass(frozen=True) +class SecQuarterlyPilotPreview: + extraction: SecQuarterlyPilotExtraction + acceptance: QuarterlyAdapterAcceptance | None + status: str + blockers: tuple[str, ...] + production_activation: bool = False + readiness_promotions: tuple[str, ...] = () + + +@dataclass(frozen=True) +class _SelectedFact: + concept: str + value: float + filed: str + + +@dataclass +class _InlineFact: + concept: str + context_ref: str + scale: int + fact_id: str + event_index: int + end_event_index: int = 0 + text_parts: list[str] = field(default_factory=list) + + +@dataclass +class _InlineRow: + events: list[object] = field(default_factory=list) + facts: list[_InlineFact] = field(default_factory=list) + + +class _InlineFilingParser(HTMLParser): + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.contexts: dict[str, tuple[str, str]] = {} + self.rows: list[_InlineRow] = [] + self._context_id: str | None = None + self._context_start = "" + self._context_end = "" + self._context_field: str | None = None + self._row: _InlineRow | None = None + self._fact: _InlineFact | None = None + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + normalized = tag.lower() + attributes = {key.lower(): str(value or "") for key, value in attrs} + if normalized == "xbrli:context": + self._context_id = attributes.get("id") or None + self._context_start = "" + self._context_end = "" + elif normalized == "xbrli:startdate" and self._context_id: + self._context_field = "start" + elif normalized == "xbrli:enddate" and self._context_id: + self._context_field = "end" + elif normalized == "tr": + self._row = _InlineRow() + elif normalized == "ix:nonfraction" and self._row is not None: + concept = attributes.get("name", "").split(":")[-1] + try: + scale = int(attributes.get("scale", "0")) + except ValueError: + scale = 0 + self._fact = _InlineFact( + concept=concept, + context_ref=attributes.get("contextref", ""), + scale=scale, + fact_id=attributes.get("id", ""), + event_index=len(self._row.events), + ) + self._row.events.append(self._fact) + self._row.facts.append(self._fact) + + def handle_data(self, data: str) -> None: + if self._context_field == "start": + self._context_start += data + elif self._context_field == "end": + self._context_end += data + if self._row is not None: + self._row.events.append(data) + if self._fact is not None: + self._fact.text_parts.append(data) + + def handle_endtag(self, tag: str) -> None: + normalized = tag.lower() + if normalized in {"xbrli:startdate", "xbrli:enddate"}: + self._context_field = None + elif normalized == "xbrli:context": + if self._context_id: + self.contexts[self._context_id] = ( + self._context_start.strip(), + self._context_end.strip(), + ) + self._context_id = None + elif normalized == "ix:nonfraction" and self._row is not None and self._fact is not None: + self._fact.end_event_index = len(self._row.events) + self._fact = None + elif normalized == "tr" and self._row is not None: + self.rows.append(self._row) + self._row = None + + +def _quarter_number(fiscal_period: str) -> int: + return int(fiscal_period[-1]) + + +def _select_companyfact( + payload: Mapping[str, Any], + concepts: tuple[str, ...], + *, + accession: str, + start: str, + end: str, + fiscal_period: str, +) -> tuple[_SelectedFact | None, str | None]: + fiscal_year = int(fiscal_period[:4]) + fiscal_quarter = f"Q{_quarter_number(fiscal_period)}" + facts = payload.get("facts", {}).get("us-gaap", {}) + for concept in concepts: + rows = facts.get(concept, {}).get("units", {}).get("USD", ()) + matching = [ + row + for row in rows + if row.get("accn") == accession + and row.get("start") == start + and row.get("end") == end + and row.get("form") in {"10-Q", "10-Q/A"} + and row.get("fy") == fiscal_year + and row.get("fp") == fiscal_quarter + ] + values = {float(row["val"]) for row in matching if row.get("val") is not None} + if len(values) > 1: + return None, "ambiguous" + if len(values) == 1: + chosen = matching[0] + return ( + _SelectedFact( + concept=concept, + value=next(iter(values)), + filed=str(chosen.get("filed") or ""), + ), + None, + ) + return None, "missing" + + +def _submission_for_accession( + payload: Mapping[str, Any], accession: str +) -> dict[str, str] | None: + recent = payload.get("filings", {}).get("recent", {}) + accessions = recent.get("accessionNumber", ()) + try: + index = list(accessions).index(accession) + except ValueError: + return None + fields = ("filingDate", "acceptanceDateTime", "form", "primaryDocument") + try: + return {field: str(recent[field][index]) for field in fields} + except (KeyError, IndexError, TypeError): + return None + + +def _displayed_value(fact: _InlineFact) -> float | None: + cleaned = "".join(fact.text_parts).strip().replace(",", "").replace("$", "") + try: + return float(cleaned) * (10**fact.scale) + except ValueError: + return None + + +def _nearest_text(events: list[object], start: int, step: int) -> str: + index = start + while 0 <= index < len(events): + value = events[index] + if isinstance(value, str) and value.strip(): + return value.strip() + index += step + return "" + + +def _match_inline_fact( + parser: _InlineFilingParser, + selected: _SelectedFact, + *, + start: str, + end: str, + require_outflow: bool = False, +) -> _InlineFact | None: + matches: list[_InlineFact] = [] + for row in parser.rows: + for fact in row.facts: + context = parser.contexts.get(fact.context_ref) + displayed = _displayed_value(fact) + if ( + fact.concept != selected.concept + or context != (start, end) + or displayed is None + or not math.isclose(abs(displayed), abs(selected.value), abs_tol=0.5) + ): + continue + if require_outflow: + before = _nearest_text(row.events, fact.event_index - 1, -1) + after = _nearest_text(row.events, fact.end_event_index, 1) + if before != "(" or after != ")": + continue + matches.append(fact) + return next((fact for fact in matches if fact.fact_id), None) + + +def _blocked_extraction( + *, + ticker: str, + cik: str, + fiscal_period: str, + period_start_date: str, + period_end_date: str, + accession: str, + blockers: list[str], +) -> SecQuarterlyPilotExtraction: + return SecQuarterlyPilotExtraction( + ticker=ticker, + cik=cik, + fiscal_period=fiscal_period, + period_start_date=period_start_date, + period_end_date=period_end_date, + accession=accession, + filing_date="", + accepted_at="", + source_url="", + observations=(), + revenue_actuals=(), + capex_sign_evidence="blocked", + blockers=tuple(dict.fromkeys(blockers)), + ) + + +def extract_sec_quarterly_cash_generation( + *, + ticker: str, + cik: str, + fiscal_period: str, + period_start_date: str, + period_end_date: str, + accession: str, + primary_document: str | None = None, + companyfacts_payload: Mapping[str, Any], + submissions_payload: Mapping[str, Any], + filing_html: str, + retrieved_at: str, + as_of: str, +) -> SecQuarterlyPilotExtraction: + """Build one exact-quarter in-memory evidence batch or deterministic blockers.""" + + symbol = str(ticker or "").strip().upper() + normalized_cik = str(cik or "").strip().zfill(10) + normalized_period = str(fiscal_period or "").strip().upper() + blockers: list[str] = [] + try: + period_start = date.fromisoformat(period_start_date) + period_end = date.fromisoformat(period_end_date) + retrieved = parse_utc_timestamp(retrieved_at, label="retrieved_at") + cutoff = parse_utc_timestamp(as_of, label="as_of") + except ValueError: + blockers.append("request:identity_or_time_invalid") + return _blocked_extraction( + ticker=symbol, + cik=normalized_cik, + fiscal_period=normalized_period, + period_start_date=period_start_date, + period_end_date=period_end_date, + accession=accession, + blockers=blockers, + ) + + if normalized_period.endswith("-Q4") and not 70 <= (period_end - period_start).days <= 100: + blockers.append("q4_explicit_three_month_filing_required") + try: + companyfacts_cik = str(int(companyfacts_payload.get("cik", ""))).zfill(10) + except (TypeError, ValueError): + companyfacts_cik = "" + if companyfacts_cik != normalized_cik: + blockers.append("companyfacts:cik_mismatch") + if blockers: + return _blocked_extraction( + ticker=symbol, + cik=normalized_cik, + fiscal_period=normalized_period, + period_start_date=period_start_date, + period_end_date=period_end_date, + accession=accession, + blockers=blockers, + ) + + submission = _submission_for_accession(submissions_payload, accession) + if submission is None: + blockers.append("submissions:accession_missing") + return _blocked_extraction( + ticker=symbol, + cik=normalized_cik, + fiscal_period=normalized_period, + period_start_date=period_start_date, + period_end_date=period_end_date, + accession=accession, + blockers=blockers, + ) + requested_document = str(primary_document or "").strip() + if requested_document and submission["primaryDocument"] != requested_document: + blockers.append("filing:primary_document_mismatch") + return _blocked_extraction( + ticker=symbol, + cik=normalized_cik, + fiscal_period=normalized_period, + period_start_date=period_start_date, + period_end_date=period_end_date, + accession=accession, + blockers=blockers, + ) + try: + accepted = parse_utc_timestamp( + submission["acceptanceDateTime"], label="acceptanceDateTime" + ) + except ValueError: + blockers.append("submissions:acceptance_time_invalid") + return _blocked_extraction( + ticker=symbol, + cik=normalized_cik, + fiscal_period=normalized_period, + period_start_date=period_start_date, + period_end_date=period_end_date, + accession=accession, + blockers=blockers, + ) + if accepted > cutoff: + blockers.append("acceptance_after_cutoff") + if submission["form"] not in {"10-Q", "10-Q/A"}: + blockers.append("submissions:form_not_quarterly") + + raw_selections = { + "revenue": _select_companyfact( + companyfacts_payload, + REVENUE_CONCEPTS, + accession=accession, + start=period_start_date, + end=period_end_date, + fiscal_period=normalized_period, + ), + "operating_income": _select_companyfact( + companyfacts_payload, + OPERATING_INCOME_CONCEPTS, + accession=accession, + start=period_start_date, + end=period_end_date, + fiscal_period=normalized_period, + ), + "cash_from_operations": _select_companyfact( + companyfacts_payload, + CASH_FROM_OPERATIONS_CONCEPTS, + accession=accession, + start=period_start_date, + end=period_end_date, + fiscal_period=normalized_period, + ), + "capital_expenditures": _select_companyfact( + companyfacts_payload, + CAPEX_CONCEPTS, + accession=accession, + start=period_start_date, + end=period_end_date, + fiscal_period=normalized_period, + ), + } + selections = {metric: selection[0] for metric, selection in raw_selections.items()} + for metric, (selected, state) in raw_selections.items(): + if selected is None: + blockers.append(f"{metric}:fact_{state}") + + parser = _InlineFilingParser() + parser.feed(filing_html) + inline: dict[str, _InlineFact] = {} + for metric, selected in selections.items(): + if selected is None: + continue + matched = _match_inline_fact( + parser, + selected, + start=period_start_date, + end=period_end_date, + ) + if matched is None: + blockers.append(f"{metric}:inline_fact_missing") + elif metric == "capital_expenditures" and _match_inline_fact( + parser, + selected, + start=period_start_date, + end=period_end_date, + require_outflow=True, + ) is None: + blockers.append("capital_expenditures:explicit_outflow_evidence_missing") + else: + inline[metric] = matched + + if blockers: + return _blocked_extraction( + ticker=symbol, + cik=normalized_cik, + fiscal_period=normalized_period, + period_start_date=period_start_date, + period_end_date=period_end_date, + accession=accession, + blockers=blockers, + ) + + accession_path = accession.replace("-", "") + source_url = ( + "https://www.sec.gov/Archives/edgar/data/" + f"{int(normalized_cik)}/{accession_path}/{submission['primaryDocument']}" + ) + accepted_at = accepted.isoformat() + retrieved_at_utc = retrieved.isoformat() + observations = tuple( + QuarterlyBusinessObservation( + ticker=symbol, + fiscal_period=normalized_period, + period_end_date=period_end_date, + metric=metric, + value=(-abs(selected.value) if metric == "capital_expenditures" else selected.value), + currency="USD", + unit_scale=1.0, + accounting_basis="reported", + duration_basis="three_months", + source=SOURCE_ID, + source_ref=f"{source_url}#{inline[metric].fact_id}", + published_at=accepted_at, + retrieved_at=retrieved_at_utc, + q4_evidence_state="not_q4", + ) + for metric in ( + "operating_income", + "cash_from_operations", + "capital_expenditures", + ) + if (selected := selections[metric]) is not None + ) + revenue = selections["revenue"] + assert revenue is not None + revenue_actuals = ( + QuarterlyActual( + ticker=symbol, + fiscal_period=normalized_period, + period_end_date=period_end_date, + reported_at=accepted_at, + revenue_actual=revenue.value, + eps_actual=None, + source=SOURCE_ID, + source_ref=f"{source_url}#{inline['revenue'].fact_id}", + retrieved_at=retrieved_at_utc, + revenue_currency="USD", + revenue_unit_scale=1.0, + revenue_basis="reported", + split_adjustment_basis="primary_split_basis_unverified", + ), + ) + return SecQuarterlyPilotExtraction( + ticker=symbol, + cik=normalized_cik, + fiscal_period=normalized_period, + period_start_date=period_start_date, + period_end_date=period_end_date, + accession=accession, + filing_date=submission["filingDate"], + accepted_at=accepted_at, + source_url=source_url, + observations=observations, + revenue_actuals=revenue_actuals, + capex_sign_evidence="explicit_filed_table_outflow", + blockers=(), + ) + + +def preview_sec_quarterly_cash_generation( + *, + extraction: SecQuarterlyPilotExtraction, + rights_registry: Mapping[str, SourceRights], + as_of: str | None = None, +) -> SecQuarterlyPilotPreview: + if extraction.blockers: + return SecQuarterlyPilotPreview( + extraction=extraction, + acceptance=None, + status="blocked", + blockers=extraction.blockers, + ) + acceptance = assess_quarterly_cash_generation_adapter( + extraction.ticker, + SOURCE_ID, + extraction.observations, + extraction.revenue_actuals, + rights_registry=rights_registry, + as_of=as_of, + ) + return SecQuarterlyPilotPreview( + extraction=extraction, + acceptance=acceptance, + status=acceptance.status, + blockers=acceptance.blockers, + ) diff --git a/src/sec_quarterly_cash_generation_preview.py b/src/sec_quarterly_cash_generation_preview.py new file mode 100644 index 00000000..d7fad429 --- /dev/null +++ b/src/sec_quarterly_cash_generation_preview.py @@ -0,0 +1,185 @@ +"""Read-only live preview for one exact SEC quarterly cash-generation filing.""" + +from __future__ import annotations + +import argparse +import json +from datetime import datetime, timezone +from typing import Any, Callable +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +from src.commercial_source_rights import load_source_rights_registry +from src.providers.sec_companyfacts import SECUserAgentError, _require_user_agent +from src.sec_quarterly_cash_generation_pilot import ( + SecQuarterlyPilotPreview, + extract_sec_quarterly_cash_generation, + preview_sec_quarterly_cash_generation, +) + + +COMPANYFACTS_URL = "https://data.sec.gov/api/xbrl/companyfacts/CIK{cik}.json" +SUBMISSIONS_URL = "https://data.sec.gov/submissions/CIK{cik}.json" +FILING_URL = ( + "https://www.sec.gov/Archives/edgar/data/{cik_number}/{accession_path}/" + "{primary_document}" +) + + +class SecQuarterlyPreviewFetchError(RuntimeError): + pass + + +def _fetch_bytes(url: str, user_agent: str) -> bytes: + request = Request( + url, + headers={ + "User-Agent": user_agent, + "Accept": "application/json,text/html", + }, + ) + try: + with urlopen(request, timeout=30) as response: + return response.read() + except HTTPError as exc: + raise SecQuarterlyPreviewFetchError( + f"SEC preview request failed with HTTP {exc.code}" + ) from exc + except URLError as exc: + raise SecQuarterlyPreviewFetchError("SEC preview request failed") from exc + + +def fetch_sec_quarterly_pilot_payloads( + *, + cik: str, + accession: str, + primary_document: str, + user_agent: str | None = None, + fetcher: Callable[[str, str], bytes] | None = None, +) -> dict[str, Any]: + """Fetch three exact SEC payloads in memory without a cache or file output.""" + + resolved_user_agent = _require_user_agent(user_agent) + normalized_cik = str(cik or "").strip().zfill(10) + accession_path = str(accession or "").strip().replace("-", "") + document = str(primary_document or "").strip() + if not normalized_cik.isdigit() or not accession_path.isdigit() or not document: + raise ValueError("exact CIK, accession, and primary document are required") + urls = ( + COMPANYFACTS_URL.format(cik=normalized_cik), + SUBMISSIONS_URL.format(cik=normalized_cik), + FILING_URL.format( + cik_number=int(normalized_cik), + accession_path=accession_path, + primary_document=document, + ), + ) + retrieve = fetcher or _fetch_bytes + raw_companyfacts = retrieve(urls[0], resolved_user_agent) + raw_submissions = retrieve(urls[1], resolved_user_agent) + raw_filing = retrieve(urls[2], resolved_user_agent) + try: + return { + "companyfacts": json.loads(raw_companyfacts.decode("utf-8")), + "submissions": json.loads(raw_submissions.decode("utf-8")), + "filing_html": raw_filing.decode("utf-8"), + } + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise SecQuarterlyPreviewFetchError("SEC preview payload was malformed") from exc + + +def render_sec_quarterly_pilot_preview(preview: SecQuarterlyPilotPreview) -> str: + extraction = preview.extraction + company = "NVIDIA" if extraction.ticker == "NVDA" else extraction.ticker + fiscal_year, quarter = extraction.fiscal_period.split("-Q") + lines = [ + "SEC quarterly cash-generation preview", + f"status: {preview.status}", + f"company: {company} Q{quarter} FY{fiscal_year}", + f"accession: {extraction.accession}", + f"accepted at: {extraction.accepted_at or 'unavailable'}", + f"source: {extraction.source_url or 'unavailable'}", + ] + values = {row.metric: row for row in extraction.observations} + revenue = extraction.revenue_actuals[0] if extraction.revenue_actuals else None + if revenue is not None: + lines.append(f"revenue: {revenue.revenue_actual} {revenue.revenue_currency}") + for metric, label in ( + ("operating_income", "operating income"), + ("cash_from_operations", "cash from operations"), + ("capital_expenditures", "capital expenditures"), + ): + row = values.get(metric) + if row is not None: + lines.append(f"{label}: {row.value} {row.currency}") + lines.extend( + [ + f"capex sign evidence: {extraction.capex_sign_evidence}", + "blockers: " + (", ".join(preview.blockers) if preview.blockers else "none"), + "production activation: false", + "readiness promotions: none", + "generated artifacts: none", + ] + ) + return "\n".join(lines) + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Preview one exact SEC quarterly cash-generation filing without writes." + ) + parser.add_argument("--ticker", default="NVDA") + parser.add_argument("--cik", default="0001045810") + parser.add_argument("--fiscal-period", default="2027-Q1") + parser.add_argument("--period-start", default="2026-01-26") + parser.add_argument("--period-end", default="2026-04-26") + parser.add_argument("--accession", default="0001045810-26-000052") + parser.add_argument("--primary-document", default="nvda-20260426.htm") + parser.add_argument("--as-of", required=True) + parser.add_argument("--sec-user-agent") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = _parser().parse_args(argv) + try: + payloads = fetch_sec_quarterly_pilot_payloads( + cik=args.cik, + accession=args.accession, + primary_document=args.primary_document, + user_agent=args.sec_user_agent, + ) + retrieved_at = datetime.now(timezone.utc).isoformat() + extraction = extract_sec_quarterly_cash_generation( + ticker=args.ticker, + cik=args.cik, + fiscal_period=args.fiscal_period, + period_start_date=args.period_start, + period_end_date=args.period_end, + accession=args.accession, + primary_document=args.primary_document, + companyfacts_payload=payloads["companyfacts"], + submissions_payload=payloads["submissions"], + filing_html=payloads["filing_html"], + retrieved_at=retrieved_at, + as_of=args.as_of, + ) + preview = preview_sec_quarterly_cash_generation( + extraction=extraction, + rights_registry=load_source_rights_registry(), + as_of=args.as_of, + ) + print(render_sec_quarterly_pilot_preview(preview)) + return 0 if preview.status == "accepted_for_review" else 2 + except (SECUserAgentError, SecQuarterlyPreviewFetchError, ValueError) as exc: + print("SEC quarterly cash-generation preview") + print("status: blocked") + print(f"blockers: {exc}") + print("production activation: false") + print("readiness promotions: none") + print("generated artifacts: none") + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/session_source_preflight.py b/src/session_source_preflight.py index 9337ca11..1d26b64b 100644 --- a/src/session_source_preflight.py +++ b/src/session_source_preflight.py @@ -1,12 +1,14 @@ from __future__ import annotations import argparse +import copy import csv import importlib import json import os import socket from datetime import datetime, timezone +from dataclasses import asdict from pathlib import Path from typing import Any, Callable from urllib.error import HTTPError, URLError @@ -14,7 +16,9 @@ import pandas as pd +from src.continuation_gate import ContinuationGate, build_continuation_gate from src.paths import format_path_context, resolve_data_dir, resolve_outputs_dir, resolve_project_root +from src.profile_context import build_profile_context from src.provider_env import load_provider_environment from src.providers.alternative_fundamentals import ALPHA_VANTAGE_API_KEY_ENV, FMP_API_KEY_ENV, FINNHUB_API_KEY_ENV from src.data_update import DEFAULT_IBKR_CLIENT_ID, DEFAULT_IBKR_HOST, DEFAULT_IBKR_PORT, IBKR_CLIENT_ID_ENV, IBKR_HOST_ENV, IBKR_PORT_ENV @@ -1119,6 +1123,49 @@ def build_session_source_preflight( } +def apply_continuation_gate( + preflight: dict[str, Any], + continuation_gate: ContinuationGate, +) -> dict[str, Any]: + """Overlay continuation routing without changing source availability evidence.""" + + overlaid = copy.deepcopy(preflight) + overlaid["continuation_gate"] = asdict(continuation_gate) + if not continuation_gate.suppress_execution: + return overlaid + + console = overlaid.get("source_activation_console_v2", {}) + if not isinstance(console, dict): + console = {} + overlaid["source_activation_console_v2"] = console + console["next_executable_lane"] = continuation_gate.state + console["next_executable_command"] = continuation_gate.next_safe_command + operator_summary = console.get("operator_summary", {}) + if not isinstance(operator_summary, dict): + operator_summary = {} + console["operator_summary"] = operator_summary + existing_avoid = operator_summary.get("avoid_repeating", []) + if not isinstance(existing_avoid, list): + existing_avoid = [str(existing_avoid)] if str(existing_avoid).strip() else [] + avoid_repeating = _dedupe_preserve_order( + [ + *[str(item) for item in existing_avoid if str(item).strip()], + "broad_refresh", + "source_proof", + "readiness_rebuild", + ] + ) + operator_summary.update( + { + "can_run_now": [continuation_gate.state], + "avoid_repeating": avoid_repeating, + "next_step": continuation_gate.next_safe_command, + "next_step_reason": continuation_gate.reason, + } + ) + return overlaid + + def render_session_source_preflight(preflight: dict[str, Any]) -> str: sources = preflight["sources"] categories = preflight.get("source_categories", {}) @@ -1138,6 +1185,16 @@ def render_session_source_preflight(preflight: dict[str, Any]) -> str: f" paid_or_locked: {', '.join(categories.get('paid_or_locked', [])) or '-'}", "source_status:", ] + continuation_gate = preflight.get("continuation_gate", {}) + if isinstance(continuation_gate, dict) and continuation_gate.get("suppress_execution"): + lines[1:1] = [ + f"Stale readiness continuation gate: {continuation_gate.get('state', '-')}", + f"- Next safe preview: {continuation_gate.get('next_safe_command', '-')}", + f"- Reason: {continuation_gate.get('reason', '-')}", + "- Source availability and lane details below are planning context only; they do not authorize execution.", + f"- Rebuild boundary: {continuation_gate.get('rebuild_command', 'make readiness')} is a separate intentional reviewed write.", + f"- Stop rule: {continuation_gate.get('stop_rule', '-')}", + ] for source_name in ( "sec", "sec_submissions", @@ -1393,6 +1450,16 @@ def main() -> None: yfinance_import_probe=probe_yfinance_import, sample_ticker=args.sample_ticker, ) + preflight = apply_continuation_gate( + preflight, + build_continuation_gate( + build_profile_context( + project_root=root, + data_dir=data_path, + output_dir=resolve_outputs_dir(project_root=root), + ) + ), + ) if args.write_output: write_session_source_preflight_output(preflight, root) diff --git a/src/source_activation_guide.py b/src/source_activation_guide.py index 805d4788..0ee006d5 100644 --- a/src/source_activation_guide.py +++ b/src/source_activation_guide.py @@ -3,10 +3,13 @@ import argparse import json import os +from dataclasses import asdict from pathlib import Path from typing import Any -from src.paths import resolve_project_root +from src.continuation_gate import build_continuation_gate +from src.paths import resolve_data_dir, resolve_outputs_dir, resolve_project_root +from src.profile_context import build_profile_context from src.provider_env import load_provider_environment from src.session_source_preflight import load_session_source_preflight @@ -609,6 +612,28 @@ def build_provider_setup_checklist( } ) current_gate = _current_gate_from_preflight(current_preflight) + continuation_gate = None + if root is not None: + project_root = resolve_project_root(Path(root)) + continuation_gate = build_continuation_gate( + build_profile_context( + project_root=project_root, + data_dir=resolve_data_dir(project_root=project_root), + output_dir=resolve_outputs_dir(project_root=project_root), + ) + ) + if continuation_gate.suppress_execution: + existing_avoid = str(current_gate.get("avoid_repeating") or "").strip() + avoid_parts = [] if existing_avoid in {"", "-"} else [existing_avoid] + avoid_parts.extend(["broad_refresh", "source_proof", "readiness_rebuild"]) + current_gate.update( + { + "can_run_now": continuation_gate.state, + "avoid_repeating": ", ".join(dict.fromkeys(avoid_parts)), + "next_step": continuation_gate.next_safe_command, + "next_step_reason": continuation_gate.reason, + } + ) return { "title": "Provider Setup Checklist", "research_boundary": guide["research_boundary"], @@ -627,6 +652,7 @@ def build_provider_setup_checklist( "apply_gate": guide["apply_gate"], "non_retry_rule": guide["non_retry_rule"], "current_gate": current_gate, + "continuation_gate": asdict(continuation_gate) if continuation_gate is not None else {}, } @@ -635,9 +661,21 @@ def render_provider_setup_checklist(checklist: dict[str, Any]) -> str: str(checklist["title"]), str(checklist["research_boundary"]), str(checklist["secret_policy"]), - "", - "First provider answer:", ] + continuation_gate = checklist.get("continuation_gate", {}) + if isinstance(continuation_gate, dict) and continuation_gate.get("suppress_execution"): + lines.extend( + [ + "", + f"Stale readiness continuation gate: {continuation_gate.get('state', '-')}", + f"- Next safe preview: {continuation_gate.get('next_safe_command', '-')}", + f"- Reason: {continuation_gate.get('reason', '-')}", + "- Planning context only: provider rows and setup classifications below do not authorize source execution.", + f"- Rebuild boundary: {continuation_gate.get('rebuild_command', 'make readiness')} is a separate intentional reviewed write.", + f"- Stop rule: {continuation_gate.get('stop_rule', '-')}", + ] + ) + lines.extend(["", "First provider answer:"]) first_answer = checklist.get("first_answer", {}) if isinstance(first_answer, dict) and first_answer: lines.extend( diff --git a/src/universe_model.py b/src/universe_model.py index e5d6c550..ee9237a6 100644 --- a/src/universe_model.py +++ b/src/universe_model.py @@ -167,7 +167,12 @@ def _legacy_universe_to_active(legacy: pd.DataFrame) -> pd.DataFrame: return pd.DataFrame(rows, columns=ACTIVE_COLUMNS) -def ensure_universe_files(base_dir: Path | str | None = None, *, data_dir: Path | str | None = None) -> tuple[pd.DataFrame, pd.DataFrame]: +def ensure_universe_files( + base_dir: Path | str | None = None, + *, + data_dir: Path | str | None = None, + write_outputs: bool = True, +) -> tuple[pd.DataFrame, pd.DataFrame]: root = resolve_project_root(base_dir) data_path = resolve_data_dir(data_dir, root) master_path = data_path / "universe_master.csv" @@ -177,7 +182,8 @@ def ensure_universe_files(base_dir: Path | str | None = None, *, data_dir: Path active = _read_csv(active_path) if master.empty and not legacy.empty: master = _legacy_universe_to_master(legacy) - _write_csv(master, master_path, MASTER_COLUMNS) + if write_outputs: + _write_csv(master, master_path, MASTER_COLUMNS) elif not legacy.empty: legacy_master = _legacy_universe_to_master(legacy) missing_legacy = legacy_master.loc[~legacy_master["ticker"].isin(set(master.get("ticker", pd.Series(dtype=str))))] @@ -188,7 +194,8 @@ def ensure_universe_files(base_dir: Path | str | None = None, *, data_dir: Path .sort_values("ticker") .reset_index(drop=True) ) - _write_csv(master, master_path, MASTER_COLUMNS) + if write_outputs: + _write_csv(master, master_path, MASTER_COLUMNS) if not legacy_master.empty and "ticker" in master.columns: master = master.copy() legacy_by_ticker = legacy_master.set_index("ticker", drop=False) @@ -215,10 +222,12 @@ def ensure_universe_files(base_dir: Path | str | None = None, *, data_dir: Path master.at[idx, "name"] = legacy_row.get("name", "") repaired = True if repaired: - _write_csv(master, master_path, MASTER_COLUMNS) + if write_outputs: + _write_csv(master, master_path, MASTER_COLUMNS) if active.empty and not legacy.empty: active = _legacy_universe_to_active(legacy) - _write_csv(active, active_path, ACTIVE_COLUMNS) + if write_outputs: + _write_csv(active, active_path, ACTIVE_COLUMNS) return master, active @@ -301,10 +310,11 @@ def build_universe_coverage_report( *, data_dir: Path | str | None = None, output_path: Path | str | None = None, + write_output: bool = True, ) -> pd.DataFrame: root = resolve_project_root(base_dir) data_path = resolve_data_dir(data_dir, root) - master, active = ensure_universe_files(root, data_dir=data_path) + master, active = ensure_universe_files(root, data_dir=data_path, write_outputs=write_output) output = Path(output_path) if output_path is not None else data_path / "reports" / "universe_coverage_report.csv" output = output if output.is_absolute() else root / output active_tickers = set(active.get("ticker", pd.Series(dtype=str)).dropna().astype(str).str.upper()) @@ -336,8 +346,9 @@ def build_universe_coverage_report( } ) report = pd.DataFrame(rows) - output.parent.mkdir(parents=True, exist_ok=True) - report.to_csv(output, index=False) + if write_output: + output.parent.mkdir(parents=True, exist_ok=True) + report.to_csv(output, index=False) return report diff --git a/src/weekly_research_summary.py b/src/weekly_research_summary.py new file mode 100644 index 00000000..d1fb9805 --- /dev/null +++ b/src/weekly_research_summary.py @@ -0,0 +1,215 @@ +"""Traceable, recommendation-free weekly summaries for a focused cohort.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date, timedelta +from typing import Iterable, Mapping + +from src.earnings_nowcast_contract import parse_utc_timestamp +from src.focused_research_cohort import FocusedCohort + + +CATEGORY_ORDER = { + "newly_blocked": 0, + "invalidation_condition": 1, + "requires_review": 2, + "new_evidence": 3, + "newly_usable": 4, + "stale_review": 5, + "waiting": 6, +} + + +@dataclass(frozen=True) +class WeeklySummaryItem: + category: str + ticker: str + answer: str + state: str + source_ref: str + effective_at: str + + +@dataclass(frozen=True) +class WeeklyResearchSummary: + status: str + as_of: str + cohort_size: int + unique_event_count: int + items: tuple[WeeklySummaryItem, ...] + message: str + + +def _text(value: object) -> str: + return str(value or "").strip() + + +def _truthy(value: object) -> bool: + return _text(value).lower() in {"true", "1", "yes", "y"} + + +def _event_item(category: str, review_item, answer: str, state: str) -> WeeklySummaryItem: + event = review_item.event + return WeeklySummaryItem( + category=category, + ticker=_text(event.ticker).upper(), + answer=answer, + state=state, + source_ref=_text(getattr(event, "source_ref", "")) or f"event:{_text(getattr(event, 'event_id', 'unknown'))}", + effective_at=_text(getattr(event, "source_published_at", "")) or _text(getattr(event, "detected_at", "")), + ) + + +def build_weekly_research_summary( + cohort: FocusedCohort, + review_items: Iterable[object], + *, + journal_rows: Iterable[Mapping[str, object]] = (), + as_of: str, + window_days: int = 7, +) -> WeeklyResearchSummary: + boundary = parse_utc_timestamp(as_of) + window_start = boundary - timedelta(days=max(window_days, 0)) + cohort_tickers = {member.ticker for member in cohort.members} + seen: set[str] = set() + eligible_events: list[object] = [] + for item in review_items: + event = item.event + ticker = _text(getattr(event, "ticker", "")).upper() + if ticker not in cohort_tickers: + continue + event_id = _text(getattr(event, "event_id", "")) or "\x1f".join( + _text(getattr(event, field, "")) + for field in ("ticker", "family", "subtype", "prior_value", "current_value", "source_ref", "detected_at") + ) + if event_id in seen: + continue + detected_text = _text(getattr(event, "detected_at", "")) + try: + detected = parse_utc_timestamp(detected_text) + except ValueError: + continue + if detected < window_start or detected > boundary: + continue + seen.add(event_id) + eligible_events.append(item) + + items: list[WeeklySummaryItem] = [] + for review_item in eligible_events: + event = review_item.event + ticker = _text(event.ticker).upper() + evidence_status = _text(getattr(event, "evidence_status", "")) + review_status = _text(getattr(review_item, "review_status", "")) or "open" + if evidence_status == "source_backed": + items.append( + _event_item( + "new_evidence", + review_item, + f"{ticker}: {_text(event.subtype).replace('_', ' ')} is supported by changed source evidence.", + "review_now", + ) + ) + if review_status == "open": + items.append( + _event_item( + "requires_review", + review_item, + _text(getattr(event, "suggested_research_task", "")) or f"{ticker}: Review the changed evidence.", + "review_now", + ) + ) + prior = _text(getattr(event, "prior_value", "")).lower() + current = _text(getattr(event, "current_value", "")).lower() + if _text(getattr(event, "family", "")) == "readiness" and prior == "false" and current == "true": + items.append( + _event_item( + "newly_usable", + review_item, + f"{ticker}: {_text(event.subtype).replace('_', ' ')} now has source-backed readiness.", + "review_now", + ) + ) + if _text(getattr(event, "family", "")) == "readiness" and prior == "true" and current == "false": + items.append( + _event_item( + "newly_blocked", + review_item, + f"{ticker}: {_text(event.subtype).replace('_', ' ')} is no longer ready; review the changed source state.", + "wait_for_evidence", + ) + ) + if review_status in {"still_blocked", "intentionally_deferred"}: + items.append( + _event_item( + "waiting", + review_item, + _text(getattr(review_item, "wait_condition", "")) or "Wait for changed source evidence.", + "wait_for_evidence" if review_status == "still_blocked" else "monitor", + ) + ) + + as_of_date = boundary.date() + for row in journal_rows: + ticker = _text(row.get("ticker")).upper() + if ticker not in cohort_tickers: + continue + source_ref = _text(row.get("source_ref")) + review_due = _text(row.get("review_due_date")) + if review_due: + try: + due_date = date.fromisoformat(review_due) + except ValueError: + due_date = as_of_date + if due_date < as_of_date: + items.append( + WeeklySummaryItem( + "stale_review", + ticker, + f"{ticker}: Reviewer-authored research review was due on {review_due}.", + "review_now", + source_ref or f"journal:{ticker}", + review_due, + ) + ) + if _truthy(row.get("invalidation_triggered")) and _text(row.get("invalidation_condition")): + items.append( + WeeklySummaryItem( + "invalidation_condition", + ticker, + _text(row.get("invalidation_condition")), + "review_now", + source_ref or f"journal:{ticker}", + _text(row.get("triggered_at")) or as_of, + ) + ) + + items.sort(key=lambda item: (CATEGORY_ORDER.get(item.category, 99), item.ticker, item.source_ref, item.answer)) + status = "review_required" if items else "no_changes" + message = ( + f"{len(items)} traceable cohort research item(s) require review or monitoring." + if items + else "No traceable cohort evidence change requires review this week." + ) + return WeeklyResearchSummary( + status=status, + as_of=boundary.isoformat(), + cohort_size=len(cohort.members), + unique_event_count=len(eligible_events), + items=tuple(items), + message=message, + ) + + +def weekly_summary_rows(summary: WeeklyResearchSummary) -> list[dict[str, str]]: + return [ + { + "Category": item.category.replace("_", " ").title(), + "Ticker": item.ticker, + "Answer": item.answer, + "State": item.state.replace("_", " "), + "Source reference": item.source_ref, + "Effective at": item.effective_at, + } + for item in summary.items + ] diff --git a/tests/fixtures/earnings_nowcast_onboarding/consensus_snapshots.csv b/tests/fixtures/earnings_nowcast_onboarding/consensus_snapshots.csv new file mode 100644 index 00000000..c93f457a --- /dev/null +++ b/tests/fixtures/earnings_nowcast_onboarding/consensus_snapshots.csv @@ -0,0 +1,2 @@ +schema_version,ticker,fiscal_period,snapshot_at,revenue_consensus,eps_consensus,source,source_ref,retrieved_at,revenue_currency,revenue_unit_scale,revenue_basis,eps_currency,eps_basis,eps_share_basis,eps_operations_basis,split_adjustment_basis,expected_report_date +earnings-nowcast-evidence-v2,SYN1,2026-Q1,2026-01-25T12:00:00Z,112,1.00,synthetic_test_fixture,fixture://consensus/SYN1/2026-Q1/2026-01-25,2026-01-25T12:01:00Z,USD,1,reported,USD,gaap,diluted,reported,as_reported,2026-04-20 diff --git a/tests/fixtures/earnings_nowcast_onboarding/quarterly_actuals.csv b/tests/fixtures/earnings_nowcast_onboarding/quarterly_actuals.csv new file mode 100644 index 00000000..ec39a1df --- /dev/null +++ b/tests/fixtures/earnings_nowcast_onboarding/quarterly_actuals.csv @@ -0,0 +1,6 @@ +schema_version,ticker,fiscal_period,period_end_date,reported_at,revenue_actual,eps_actual,source,source_ref,retrieved_at,revenue_currency,revenue_unit_scale,revenue_basis,eps_currency,eps_basis,eps_share_basis,eps_operations_basis,split_adjustment_basis,supersedes_source_ref +earnings-nowcast-evidence-v2,SYN1,2024-Q4,2024-12-31,2025-01-20T21:00:00Z,96,0.80,synthetic_test_fixture,fixture://actual/SYN1/2024-Q4,2025-01-20T21:00:00Z,USD,1,reported,USD,gaap,diluted,reported,as_reported, +earnings-nowcast-evidence-v2,SYN1,2025-Q1,2025-03-31,2025-04-20T21:00:00Z,100,0.85,synthetic_test_fixture,fixture://actual/SYN1/2025-Q1,2025-04-20T21:00:00Z,USD,1,reported,USD,gaap,diluted,reported,as_reported, +earnings-nowcast-evidence-v2,SYN1,2025-Q2,2025-06-30,2025-07-20T21:00:00Z,104,0.90,synthetic_test_fixture,fixture://actual/SYN1/2025-Q2,2025-07-20T21:00:00Z,USD,1,reported,USD,gaap,diluted,reported,as_reported, +earnings-nowcast-evidence-v2,SYN1,2025-Q3,2025-09-30,2025-10-20T21:00:00Z,108,0.95,synthetic_test_fixture,fixture://actual/SYN1/2025-Q3,2025-10-20T21:00:00Z,USD,1,reported,USD,gaap,diluted,reported,as_reported, +earnings-nowcast-evidence-v2,SYN1,2025-Q4,2025-12-31,2026-01-20T21:00:00Z,112,1.00,synthetic_test_fixture,fixture://actual/SYN1/2025-Q4,2026-01-20T21:00:00Z,USD,1,reported,USD,gaap,diluted,reported,as_reported, diff --git a/tests/fixtures/earnings_nowcast_onboarding/signals.csv b/tests/fixtures/earnings_nowcast_onboarding/signals.csv new file mode 100644 index 00000000..73bfe70f --- /dev/null +++ b/tests/fixtures/earnings_nowcast_onboarding/signals.csv @@ -0,0 +1,2 @@ +schema_version,signal_id,target_ticker,source_ticker,fiscal_period,as_of_timestamp,signal_type,direction,affected_metric,confidence_band,evidence_source,evidence_source_ref,evidence_published_at,evidence_excerpt_hash,peer_relationship_state,review_state +earnings-nowcast-evidence-v2,onboarding-fixture-peer-1,SYN1,SYN2,2026-Q1,2026-01-31T23:59:59Z,peer_earnings_readthrough,positive,revenue,medium,synthetic_test_fixture,fixture://signal/SYN1/peer/1,2026-01-20T12:00:00Z,aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa,trusted,supported diff --git a/tests/fixtures/research_record_authoring_app.py b/tests/fixtures/research_record_authoring_app.py new file mode 100644 index 00000000..8a4a9f5f --- /dev/null +++ b/tests/fixtures/research_record_authoring_app.py @@ -0,0 +1,23 @@ +import os +from pathlib import Path + +import streamlit as st + +from src.research_record_authoring import AuthoringPaths +from src.research_record_authoring_ui import render_research_record_authoring + + +root = Path(os.environ["RESEARCH_AUTHORING_FIXTURE_DIR"]).resolve() +if not root.is_dir() or "pytest-" not in str(root): + raise RuntimeError("The authoring fixture requires a pytest temporary directory.") + +paths = AuthoringPaths(root / "journal.csv", root / "catalysts.csv", root / "outcomes.csv") +if any(path.parent.resolve() != root for path in paths.all()): + raise RuntimeError("The authoring fixture refuses paths outside its temporary directory.") + +render_research_record_authoring( + st_api=st, + profile_key="demo", + ticker="SYN1", + paths=paths, +) diff --git a/tests/fixtures/rich_local_data/peers.csv b/tests/fixtures/rich_local_data/peers.csv index 602610a4..577c83c9 100644 --- a/tests/fixtures/rich_local_data/peers.csv +++ b/tests/fixtures/rich_local_data/peers.csv @@ -1,3 +1,3 @@ -ticker,peer_ticker,peer_group,sector,industry,source,as_of_date -ALFA,BETA,fixture_group,Technology,Software,fixture_peers,2026-05-01 -ALFA,GAMMA,fixture_group,Technology,Software,fixture_peers,2026-05-01 +ticker,peer_ticker,peer_group,sector,industry,peer_role,relationship_rationale,comparability_basis,valuation_anchor_eligible,source,as_of_date +ALFA,BETA,fixture_group,Technology,Software,core_peer,Synthetic fixture operating overlap,business model; customer mix; growth and margin,yes,synthetic_test_fixture,2026-05-01 +ALFA,GAMMA,fixture_group,Technology,Software,secondary_peer,Synthetic fixture operating overlap,business model; customer mix; growth and margin,yes,synthetic_test_fixture,2026-05-01 diff --git a/tests/point_in_time_universe_fixture.py b/tests/point_in_time_universe_fixture.py new file mode 100644 index 00000000..923bf20c --- /dev/null +++ b/tests/point_in_time_universe_fixture.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import csv +import hashlib +import json +from pathlib import Path + + +def _write_csv(path: Path, rows: list[dict[str, str]]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=list(rows[0])) + writer.writeheader() + writer.writerows(rows) + + +def _sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def build_valid_package(root: Path) -> tuple[Path, Path]: + package = root / "package" + package.mkdir() + identity = [{ + "identity_row_id": "id-1", "security_id": "sec-1", "issuer_id": "issuer-1", + "ticker": "AAA", "exchange": "XNYS", "security_type": "common_stock", + "currency": "USD", "valid_from": "2020-01-01T00:00:00Z", "valid_to": "", + "source_id": "fixture_source", "source_ref": "fixture://identity/id-1", + "source_published_at": "2020-01-01T00:00:00Z", + "retrieved_at": "2020-01-02T00:00:00Z", + "supersedes_identity_row_id": "", + }] + membership = [ + { + "membership_row_id": f"member-{universe}", "universe_id": universe, + "universe_kind": kind, "security_id": "sec-1", + "membership_state": "included", "effective_from": "2020-01-01T00:00:00Z", + "effective_to": "", "observation_at": "2020-01-01T00:00:00Z", + "source_id": "fixture_source", "source_ref": f"fixture://membership/{universe}", + "source_published_at": "2020-01-01T00:00:00Z", + "retrieved_at": "2020-01-02T00:00:00Z", + "supersedes_membership_row_id": "", + } + for universe, kind in (("bench-1", "benchmark"), ("research-1", "research_universe")) + ] + events = [{ + "event_row_id": "event-1", "security_id": "sec-1", "event_type": "listing", + "effective_at": "2020-01-01T00:00:00Z", "successor_security_id": "", + "ratio_numerator": "", "ratio_denominator": "", "listing_state_after": "active", + "source_id": "fixture_source", "source_ref": "fixture://event/event-1", + "source_published_at": "2020-01-01T00:00:00Z", + "retrieved_at": "2020-01-02T00:00:00Z", "supersedes_event_row_id": "", + }] + evaluations = [ + { + "evaluation_row_id": f"eval-{universe}", "universe_id": universe, + "evaluation_at": "2021-01-01T00:00:00Z", + "available_at": "2021-01-01T00:00:00Z", "partition": "train", + "source_ref": f"fixture://evaluation/{universe}", + } + for universe in ("bench-1", "research-1") + ] + payloads = { + "identity.csv": identity, + "membership.csv": membership, + "events.csv": events, + "evaluations.csv": evaluations, + } + files = [] + contract_by_name = { + "identity.csv": "security_identity", + "membership.csv": "membership", + "events.csv": "events", + "evaluations.csv": "evaluations", + } + for name, rows in payloads.items(): + path = package / name + _write_csv(path, rows) + files.append({ + "path": name, "contract": contract_by_name[name], + "sha256": _sha256(path), "row_count": len(rows), + }) + registry = root / "source_rights.yml" + registry.write_text( + "sources:\n" + " - source_id: fixture_source\n" + " display_name: Test-only source\n" + " permitted_use: test_only\n" + " commercial_use: approved\n" + " redistribution: test_only\n" + " storage_limits: pytest temporary directory only\n" + " attribution: synthetic fixture\n" + " rate_limits: not_applicable\n" + " authentication: none\n" + " expected_freshness: point in time\n" + " supported_fields: [security_identity, universe_membership, corporate_actions, delistings]\n" + " fallback_priority: 1\n", + encoding="utf-8", + ) + manifest = { + "schema_version": "point_in_time_universe_v1", + "dataset_id": "fixture-dataset", "manifest_id": "fixture-manifest", + "manifest_created_at": "2030-01-01T00:00:00Z", + "observation_cutoff_at": "2021-01-01T00:00:00Z", + "coverage_semantics": "complete_snapshot", + "declared_universes": [ + {"universe_id": "bench-1", "universe_kind": "benchmark"}, + {"universe_id": "research-1", "universe_kind": "research_universe"}, + ], + "allowed_source_ids": ["fixture_source"], + "source_rights_registry_sha256": _sha256(registry), + "files": files, + "evaluation_policy": { + "kind": "train_validation_test", + "train_end_at": "2021-01-01T00:00:00Z", + "validation_start_at": "2021-01-02T00:00:00Z", + "validation_end_at": "2021-01-03T00:00:00Z", + "test_start_at": "2021-01-04T00:00:00Z", + }, + "corporate_action_policy": { + "listing": "required", "ticker_change": "not_applicable", + "exchange_change": "not_applicable", "split": "not_applicable", + "reverse_split": "not_applicable", "merger": "not_applicable", + "acquisition": "not_applicable", "spinoff": "not_applicable", + "delisting": "not_applicable", "suspension": "not_applicable", + "reactivation": "not_applicable", + }, + "delisting_policy": { + "retain_historical_members": True, + "missing_evidence": "blocked", + }, + "survivorship_policy": { + "filter_by_current_listing_state": False, + }, + "reproduction_contract": "membership_count_and_sha256_at_cutoff_v1", + } + manifest_path = package / "manifest.json" + manifest_path.write_text(json.dumps(manifest, sort_keys=True), encoding="utf-8") + return manifest_path, registry diff --git a/tests/point_in_time_universe_remediation_fixtures.py b/tests/point_in_time_universe_remediation_fixtures.py new file mode 100644 index 00000000..8043191a --- /dev/null +++ b/tests/point_in_time_universe_remediation_fixtures.py @@ -0,0 +1,273 @@ +from __future__ import annotations + +from tests.test_point_in_time_universe import _rewrite_manifest +from tests.test_point_in_time_universe_contracts import ( + _rewrite_csv_and_manifest, +) + +STABLE_MEMBER_DIGEST = ( + "e54b5c950961a603394187d7f98d6146612751f87d47f95e7076f880a2f25e5b" +) + + +def walk_forward_rows() -> list[dict[str, str]]: + return [ + { + "evaluation_row_id": f"eval-{universe}-{year}", + "universe_id": universe, + "evaluation_at": f"{year}-01-01T00:00:00Z", + "available_at": f"{year}-01-01T00:00:00Z", + "partition": "walk_forward", + "source_ref": f"fixture://evaluation/{universe}/{year}", + } + for universe in ("bench-1", "research-1") + for year in (2021, 2022, 2023) + ] + + +def add_second_security_evidence(manifest) -> None: + _rewrite_csv_and_manifest( + manifest, + "security_identity", + lambda rows: rows.append( + { + **rows[0], + "identity_row_id": "id-2", + "security_id": "sec-2", + "issuer_id": "issuer-2", + "ticker": "BBB", + "source_ref": "fixture://identity/id-2", + } + ), + ) + _rewrite_csv_and_manifest( + manifest, + "events", + lambda rows: rows.append( + { + **rows[0], + "event_row_id": "event-sec-2-listing", + "security_id": "sec-2", + "source_ref": "fixture://event/sec-2-listing", + } + ), + ) + + +def append_action_event( + manifest, + event_type: str, + *, + security_id: str = "sec-1", + effective_at: str = "2020-06-01T00:00:00Z", + visible_at_cutoff: bool = True, + required_policy: bool = True, +) -> None: + def mutate(rows): + rows.append( + { + **rows[0], + "event_row_id": f"event-{event_type}", + "security_id": security_id, + "event_type": event_type, + "effective_at": effective_at, + "successor_security_id": "", + "ratio_numerator": "", + "ratio_denominator": "", + "listing_state_after": "", + "source_ref": f"fixture://event/{event_type}", + "source_published_at": ( + effective_at + if visible_at_cutoff + else "2022-01-01T00:00:00Z" + ), + "retrieved_at": ( + effective_at + if visible_at_cutoff + else "2022-01-02T00:00:00Z" + ), + "supersedes_event_row_id": "", + } + ) + + _rewrite_csv_and_manifest(manifest, "events", mutate) + _rewrite_manifest( + manifest, + lambda raw: raw["corporate_action_policy"].update( + { + event_type: ( + "required" + if required_policy + else "not_applicable" + ) + } + ), + ) + + +def require_event(manifest, event_type: str) -> None: + def mutate(raw): + raw["corporate_action_policy"]["listing"] = "not_applicable" + raw["corporate_action_policy"][event_type] = "required" + + _rewrite_manifest(manifest, mutate) + + +def add_successor_identity( + manifest, + successor_security_id: str, +) -> None: + def mutate(rows): + rows.append( + { + **rows[0], + "identity_row_id": "id-successor", + "security_id": successor_security_id, + "issuer_id": "issuer-successor", + "ticker": "NEXT", + "valid_from": "2020-06-01T00:00:00Z", + "source_ref": "fixture://identity/id-successor", + "source_published_at": "2020-06-01T00:00:00Z", + "retrieved_at": "2020-06-02T00:00:00Z", + } + ) + + _rewrite_csv_and_manifest(manifest, "security_identity", mutate) + + +def set_successor_event( + manifest, + successor_security_id: str, + *, + event_type: str = "merger", +) -> None: + _rewrite_csv_and_manifest( + manifest, + "events", + lambda rows: rows[0].update( + event_type=event_type, + effective_at="2020-06-01T00:00:00Z", + successor_security_id=successor_security_id, + listing_state_after="", + source_published_at="2020-06-01T00:00:00Z", + retrieved_at="2020-06-02T00:00:00Z", + ), + ) + require_event(manifest, event_type) + + +def replace_membership_with_successor( + manifest, + successor_security_id: str, +) -> None: + def mutate(rows): + replacements = [] + for original in rows: + replacements.extend( + [ + { + **original, + "membership_row_id": ( + f"{original['membership_row_id']}-excluded" + ), + "membership_state": "excluded", + "effective_from": "2020-06-01T00:00:00Z", + "observation_at": "2020-06-01T00:00:00Z", + "source_ref": ( + f"{original['source_ref']}/excluded" + ), + "source_published_at": "2020-06-01T00:00:00Z", + "retrieved_at": "2020-06-02T00:00:00Z", + "supersedes_membership_row_id": ( + original["membership_row_id"] + ), + }, + { + **original, + "membership_row_id": ( + f"member-successor-{original['universe_id']}" + ), + "security_id": successor_security_id, + "effective_from": "2020-06-01T00:00:00Z", + "observation_at": "2020-06-01T00:00:00Z", + "source_ref": ( + "fixture://membership/successor/" + f"{original['universe_id']}" + ), + "source_published_at": "2020-06-01T00:00:00Z", + "retrieved_at": "2020-06-02T00:00:00Z", + "supersedes_membership_row_id": "", + }, + ] + ) + rows.extend(replacements) + + _rewrite_csv_and_manifest(manifest, "membership", mutate) + + +def set_identity_valid_from( + manifest, + security_id: str, + valid_from: str, +) -> None: + def mutate(rows): + target = next( + row + for row in rows + if row["security_id"] == security_id + ) + target["valid_from"] = valid_from + + _rewrite_csv_and_manifest( + manifest, + "security_identity", + mutate, + ) + + +def append_identity_correction( + manifest, + security_id: str, + *, + valid_from: str, + valid_to: str, + visible_at_cutoff: bool = True, +) -> None: + def mutate(rows): + prior = next( + row + for row in rows + if row["security_id"] == security_id + ) + published = ( + "2020-08-01T00:00:00Z" + if visible_at_cutoff + else "2022-01-01T00:00:00Z" + ) + retrieved = ( + "2020-08-02T00:00:00Z" + if visible_at_cutoff + else "2022-01-02T00:00:00Z" + ) + rows.append( + { + **prior, + "identity_row_id": ( + f"{prior['identity_row_id']}-correction" + ), + "ticker": f"{prior['ticker']}-CORRECTED", + "exchange": "XNAS", + "valid_from": valid_from, + "valid_to": valid_to, + "source_ref": f"{prior['source_ref']}/correction", + "source_published_at": published, + "retrieved_at": retrieved, + "supersedes_identity_row_id": prior["identity_row_id"], + } + ) + + _rewrite_csv_and_manifest( + manifest, + "security_identity", + mutate, + ) diff --git a/tests/test_auto_refresh_orchestrator.py b/tests/test_auto_refresh_orchestrator.py index 229bd357..a69cb5d2 100644 --- a/tests/test_auto_refresh_orchestrator.py +++ b/tests/test_auto_refresh_orchestrator.py @@ -1,5 +1,10 @@ +import json +import subprocess +from pathlib import Path + from src.auto_refresh_orchestrator import ( AutoGateInput, + available_refresh_providers, build_auto_refresh_status_payload, build_default_lane_policies, build_scheduler_plan, @@ -9,6 +14,41 @@ render_scheduler_runbook, render_scheduler_plan, ) +from src.refresh_operations import ProviderAttempt +from src.continuation_gate import ContinuationGate + + +def test_auto_refresh_status_routes_stale_readiness_to_inspection_only(): + preflight = { + "source_activation": {"status": "not_required", "reason": "executable_source_available"}, + "source_categories": {"free_public_available": ["sec"]}, + "source_activation_console_v2": { + "next_executable_lane": "sec_fundamentals_share_count", + "next_executable_command": "make coverage-frontier TOP_N=10", + }, + } + gate = ContinuationGate( + state="inspection_only", + next_safe_command="make readiness-preview TOP_N=20", + reason="Selected-profile source dates are newer than saved readiness.", + rebuild_command="make readiness", + stop_rule="Do not start broad refresh or source-proof work.", + suppress_execution=True, + ) + plan = build_scheduler_plan(schedule="daily") + + payload = build_auto_refresh_status_payload(preflight, plan, continuation_gate=gate) + status = render_auto_refresh_status(preflight, plan, continuation_gate=gate) + runbook = render_scheduler_runbook(plan, preflight, continuation_gate=gate) + + assert payload["can_run_now"] == "inspection_only" + assert payload["next_executable_command"] == "make readiness-preview TOP_N=20" + assert payload["continuation_gate"]["suppress_execution"] is True + assert "Stale readiness continuation gate: inspection_only" in status + assert "refresh_operations below are planning context only" in status + assert "next_executable_command: make coverage-frontier TOP_N=10" not in status + assert "Start:\n- make readiness-preview TOP_N=20" in runbook + assert "- make coverage-frontier TOP_N=10" not in runbook.split("Lane loop:", 1)[0] def test_auto_gate_allows_source_backed_narrow_valid_preview(): @@ -61,8 +101,11 @@ def test_scheduler_plan_separates_daily_weekly_and_optional_lanes(): assert policies[0].lane == "daily_price_refresh" assert plan.daily_commands[0].startswith("make price-refresh-loop") - assert any("sec-filing-share-stage" in command for command in plan.daily_commands) - assert "make auto-apply-gate" in rendered + assert all("DRY_RUN=1" in command or "queue" in command for command in plan.daily_commands) + assert "sec-filing-share-stage" not in rendered + assert "imports-apply" not in rendered + assert "make auto-apply-gate" not in rendered + assert "SLEEP_SECONDS" not in rendered assert any("peer" in command for command in plan.weekly_commands) assert any("optional" in command for command in plan.optional_commands) optional_policy = next(policy for policy in policies if policy.lane == "optional_earnings_estimates") @@ -100,6 +143,106 @@ def test_schedule_specific_plans_only_render_selected_lane_policies(): assert "Weekly Peer Candidate Context" not in optional +def test_scheduler_plan_uses_provider_availability_and_bounded_attempt_history(): + plan = build_scheduler_plan( + schedule="daily", + available_providers=("yahoo", "sec_submissions"), + session_id="run-01", + retry_cap=1, + attempts=(ProviderAttempt("yahoo", "run-01", "failed"),), + ) + operations = {operation.lane: operation for operation in plan.refresh_operations} + + assert operations["daily_price_refresh"].status == "blocked" + assert operations["daily_price_refresh"].selected_provider is None + assert operations["daily_price_refresh"].failure_reason == "identical_provider_attempt" + assert operations["daily_sec_filing_share_count"].status == "planned" + assert operations["daily_sec_filing_share_count"].selected_provider == "sec_submissions" + assert operations["daily_fundamentals_dcf"].status == "blocked" + assert plan.retry_cap == 1 + assert plan.session_id == "run-01" + + +def test_scheduler_cli_accepts_provider_state_and_attempts(capsys): + exit_code = main( + [ + "--schedule", + "daily", + "--available-providers", + "yahoo,sec_submissions", + "--session-id", + "run-01", + "--retry-cap", + "1", + "--provider-attempt", + "yahoo:run-01:failed", + "--json", + ] + ) + + payload = json.loads(capsys.readouterr().out) + operations = {item["lane"]: item for item in payload["refresh_operations"]} + assert exit_code == 0 + assert operations["daily_price_refresh"]["status"] == "blocked" + assert operations["daily_price_refresh"]["failure_reason"] == "identical_provider_attempt" + assert operations["daily_sec_filing_share_count"]["selected_provider"] == "sec_submissions" + + +def test_preflight_availability_maps_to_refresh_provider_contracts(): + preflight = { + "sources": { + "sec": {"status": "available"}, + "sec_submissions": {"status": "unavailable"}, + "yfinance_stage": {"status": "unavailable"}, + "price_ladder": {"status": "unavailable"}, + "fmp": {"status": "available"}, + "alpha_vantage": {"status": "unavailable"}, + "finnhub": {"status": "unavailable"}, + } + } + + available = available_refresh_providers(preflight) + + assert "sec_companyfacts" in available + assert "sec_submissions" not in available + assert "sec_filing_document" not in available + assert "stooq" not in available + assert "yahoo" not in available + assert "fmp" in available + assert {"local_industry", "sic", "sector"}.issubset(available) + + +def test_status_cli_derives_fail_closed_provider_plans_from_preflight(monkeypatch, capsys): + preflight = { + "source_activation": {"status": "required", "reason": "providers_unavailable"}, + "source_categories": {}, + "source_activation_console_v2": {}, + "sources": { + "sec": {"status": "unavailable"}, + "sec_submissions": {"status": "unavailable"}, + "yfinance_stage": {"status": "unavailable"}, + "price_ladder": {"status": "unavailable"}, + "fmp": {"status": "unavailable"}, + "alpha_vantage": {"status": "unavailable"}, + "finnhub": {"status": "unavailable"}, + }, + } + monkeypatch.setattr( + "src.auto_refresh_orchestrator.build_session_source_preflight", + lambda _root: preflight, + ) + + exit_code = main(["--schedule", "daily", "--status", "--json"]) + + payload = json.loads(capsys.readouterr().out) + operations = {item["lane"]: item for item in payload["refresh_operations"]} + assert exit_code == 0 + assert operations["daily_price_refresh"]["status"] == "blocked" + assert operations["daily_sec_filing_share_count"]["status"] == "blocked" + assert operations["daily_fundamentals_dcf"]["status"] == "blocked" + assert all(item["selected_provider"] is None for item in operations.values()) + + def test_blocked_gate_can_be_report_only_for_scheduler_pivot(capsys): exit_code = main( [ @@ -154,7 +297,10 @@ def test_scheduler_runbook_is_compact_and_pivot_oriented(): assert "1. Daily Price Coverage" in runbook assert "2. Daily SEC Filing Share Count" in runbook assert "3. Daily Fundamentals / DCF Source Ladder" in runbook - assert "ALLOW_BLOCKED_GATE=1 make auto-apply-gate" in runbook + assert "imports-apply" not in runbook + assert "sec-filing-share-stage" not in runbook + assert "make auto-apply-gate" not in runbook + assert "gated apply:" not in runbook assert "record still_blocked and pivot" in runbook assert "Weekly Peer Candidate Context" not in runbook assert "Optional Earnings / Analyst Estimates" not in runbook @@ -237,3 +383,49 @@ def test_auto_refresh_status_payload_is_scheduler_parseable(): assert payload["next_runbook"] == "make auto-refresh-runbook SCHEDULE=weekly" assert payload["source_categories"]["free_public_available"] == "stooq, sec" assert payload["artifact_policy"] == "generated CSV/JSON/report churn stays excluded unless intentionally reviewed evidence." + assert payload["refresh_operations"][0]["automatic_apply_enabled"] is False + + +def test_status_payload_reconciles_an_unverified_plan_with_preflight_availability(): + preflight = { + "source_activation": {"status": "not_required"}, + "source_categories": {}, + "source_activation_console_v2": {}, + "sources": { + "sec": {"status": "available"}, + "sec_submissions": {"status": "available"}, + "yfinance_stage": {"status": "unavailable"}, + "price_ladder": {"status": "unavailable"}, + "fmp": {"status": "unavailable"}, + "alpha_vantage": {"status": "unavailable"}, + "finnhub": {"status": "unavailable"}, + }, + } + + payload = build_auto_refresh_status_payload(preflight, build_scheduler_plan(schedule="daily")) + operations = {item["lane"]: item for item in payload["refresh_operations"]} + + assert operations["daily_sec_filing_share_count"]["selected_provider"] == "sec_submissions" + assert operations["daily_fundamentals_dcf"]["selected_provider"] == "sec_companyfacts" + assert operations["daily_price_refresh"]["status"] == "blocked" + + +def test_refresh_operations_make_targets_are_read_only_cli_contracts(): + root = Path(__file__).resolve().parents[1] + for target, flag in ( + ("refresh-operations-status", "--status"), + ("refresh-operations-runbook", "--runbook"), + ): + result = subprocess.run( + ["make", "-n", target, "SCHEDULE=daily"], + cwd=root, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0 + assert "src.auto_refresh_orchestrator" in result.stdout + assert flag in result.stdout + assert "imports-apply" not in result.stdout + assert "price-refresh-loop MAX_CANDIDATES" not in result.stdout diff --git a/tests/test_browser_qa_evidence.py b/tests/test_browser_qa_evidence.py index 901c22c1..84e572f0 100644 --- a/tests/test_browser_qa_evidence.py +++ b/tests/test_browser_qa_evidence.py @@ -21,7 +21,11 @@ image_size, main, ) -from src.browser_qa_evidence import DEFAULT_BROWSER_QA_EVIDENCE, DEFAULT_BROWSER_QA_ROUTE_CHECKS +from src.browser_qa_evidence import ( + DEFAULT_BROWSER_QA_EVIDENCE, + DEFAULT_BROWSER_QA_RESPONSIVE_ROUTE_CHECKS, + DEFAULT_BROWSER_QA_ROUTE_CHECKS, +) def _write_png(path: Path, width: int = 1200, height: int = 627) -> None: @@ -79,6 +83,24 @@ def test_default_public_browser_qa_contract_uses_current_compact_workflow_marker assert "research-loop-strip" not in rendered +def test_linkedin_asset_contract_is_count_safe_workbench_answer_evidence(): + asset = DEFAULT_BROWSER_QA_EVIDENCE[0] + + assert asset.name == "LinkedIn Company Workbench thumbnail" + assert asset.route.endswith("?mode=research&page=company-workbench&ticker=AVGO&open=1") + assert asset.expected_markers == ( + "Company Workbench", + "Use now", + "Still withheld", + "Open Data Health", + "Stop if peer mappings or peer valuation inputs lack source-backed rows.", + "Research-only", + ) + assert (asset.min_width, asset.min_height) == (1200, 627) + assert "answer-first" in asset.use.lower() + assert "readiness" not in " ".join(asset.expected_markers).lower() + + def test_browser_qa_evidence_rows_keep_routes_assets_and_boundaries_visible(tmp_path): asset = tmp_path / "docs" / "assets" / "linkedin-public-dashboard.png" asset.parent.mkdir(parents=True) @@ -379,7 +401,7 @@ def test_browser_qa_share_recommendation_prefers_ready_public_image_and_keeps_bl assert rows[2]["State"] == "route_markers_only" assert "exact current copy" in rows[2]["Recommendation"].lower() assert "data health proof lane screenshot" in rendered - assert "use make status-check top_n=5 for current counts" in rendered + assert "do not publish readiness counts from screenshots" in rendered assert "screenshots do not unlock fundamentals" in rendered assert "generated thumbnails" in rendered assert "normal local browser" in rendered @@ -411,7 +433,7 @@ def test_browser_qa_evidence_payload_is_machine_readable_and_research_safe(tmp_p assert len(payload["local_capture_checklist"]) == 3 assert len(payload["capture_session_plan"]) == 6 assert len(payload["route_qa_checklist"]) >= 7 - assert len(payload["responsive_route_qa_checklist"]) == 5 + assert len(payload["responsive_route_qa_checklist"]) == 9 assert "browser qa evidence is product evidence only" in rendered assert "responsive_route_qa_checklist" in rendered assert "saved readiness" in rendered @@ -436,7 +458,7 @@ def test_browser_qa_evidence_payload_is_machine_readable_and_research_safe(tmp_p assert "public_share_recommendation" in rendered assert "pending_capture_closeout" in rendered assert "linkedin-public-dashboard.png" in rendered - assert "use make status-check top_n=5 for current counts" in rendered + assert "do not publish readiness counts from screenshots" in rendered assert "make staged-hygiene-check" in rendered assert "reviewed_asset_stage_command" in rendered assert "do not use generated thumbnails" in rendered @@ -476,7 +498,8 @@ def test_browser_qa_responsive_route_rows_cover_public_flow_without_raw_ops(): rendered = " ".join(str(value) for row in rows for value in row.values()).lower() pages = [str(row["Page"]) for row in rows] - assert pages == ["Home", "Stock Selector", "Single-Stock Report", "Data Health", "Proof History"] + assert pages[:5] == ["Home", "Stock Selector", "Single-Stock Report", "Data Health", "Proof History"] + assert pages[5:] == ["Research Desk", "Discover", "Company Workbench", "Monitor"] assert all(row["Desktop Viewport"] == "1280x720" for row in rows) assert all(row["Phone Viewport"] == "390x844" for row in rows) assert all("Saved readiness" in str(row["First View Must Keep"]) for row in rows) @@ -530,6 +553,12 @@ def test_default_route_checks_cover_workflow_fit_proof_loading_and_queue_routing assert "Public Data Health coverage answer" in route_names assert "Data Health proof lane progressive load" in route_names assert "Data Health queue drawer routing" in route_names + assert { + "Research Desk", + "Research Discover", + "Research Company Workbench", + "Research Monitor", + }.issubset(route_names) public_rows = [ row for row in rows @@ -541,6 +570,10 @@ def test_default_route_checks_cover_workflow_fit_proof_loading_and_queue_routing assert "Which stock can I review?" in str(stock_selector["First View Markers"]) assert "Search this review queue" in str(stock_selector["First View Markers"]) assert "direct review-queue search" in str(stock_selector["Details Boundary"]) + workbench = next(row for row in rows if row["Route Check"] == "Research Company Workbench") + assert "Company Workbench" in str(workbench["First View Markers"]) + assert "Open Data Health" in str(workbench["First View Markers"]) + assert "Selected Company" not in str(workbench["First View Markers"]) assert "choose a reviewable ticker" in str(stock_selector["QA Focus"]) single_stock = next(row for row in rows if row["Route Check"] == "Single-stock workflow fit") assert "USE NOW" in str(single_stock["First View Markers"]) @@ -598,7 +631,7 @@ def test_browser_qa_evidence_cli_is_read_only_and_research_safe(tmp_path, capsys assert "ready_with_manual_capture_pending" in output assert "public share recommendation" in output assert "linkedin-public-dashboard.png" in output - assert "use make status-check top_n=5 for current counts" in output + assert "do not publish readiness counts from screenshots" in output assert "manual capture targets" in output assert "pending capture closeout" in output assert "stage if reviewed" in output @@ -611,6 +644,8 @@ def test_browser_qa_evidence_cli_is_read_only_and_research_safe(tmp_path, capsys assert "operator-data-health-queue-routing-real.jpg" in output assert "real streamlit screenshots" in output assert "route qa checklist" in output + assert "responsive public and personal research workflow qa" in output + assert "five public pages and four personal-research pages" in output assert "manual browser review" in output assert "single-stock workflow fit" in output assert "data health proof lane progressive load" in output @@ -678,3 +713,37 @@ def test_browser_qa_capture_plan_cli_prints_only_capture_sequence(capsys): assert "route qa checklist" not in output assert "buy" not in output assert "sell" not in output + + +def test_company_workbench_browser_qa_requires_preview_before_confirmation_and_keeps_authoring_below_answer(): + route = next( + item + for item in DEFAULT_BROWSER_QA_ROUTE_CHECKS + if item.name == "Research Company Workbench" + ) + responsive = next( + item + for item in DEFAULT_BROWSER_QA_RESPONSIVE_ROUTE_CHECKS + if item.page == "Company Workbench" + ) + + assert "Add a reviewed research record" not in route.first_view_markers + assert ( + "The `Add a reviewed research record` composer stays below the selected-company and journal/outcome answers; Advanced evidence remains collapsed." + == route.details_boundary + ) + assert ( + "Stop if the workbench shows traceback text, synthetic evidence as real, an unavailable forecast, an Arrow-incompatible evidence table, a visible confirmation appears before an exact preview, or a route-marker screenshot is presented as validation, confirmation, or persistence evidence." + == route.stop_rule + ) + assert ( + "Route-marker screenshots do not prove validation, confirmation, or persistence; the temporary-ledger AppTest and direct persistence tests provide that evidence." + in route.qa_focus + ) + assert "Add a reviewed research record" not in responsive.first_view_must_keep + assert "collapsed composer" not in responsive.first_view_must_keep + assert "below-answer composer" in responsive.mobile_risk + assert ( + "Stop if the phone view overflows, shows traceback text, renders technical evidence before the selected-company answer, or shows a visible confirmation before an exact preview." + == responsive.stop_rule + ) diff --git a/tests/test_catalyst_evidence_timeline.py b/tests/test_catalyst_evidence_timeline.py new file mode 100644 index 00000000..8ad6ef4c --- /dev/null +++ b/tests/test_catalyst_evidence_timeline.py @@ -0,0 +1,127 @@ +from pathlib import Path + +import pytest + +from src.catalyst_evidence_timeline import ( + CatalystEvent, + append_reviewed_event, + build_catalyst_timeline, + load_catalyst_events, + preview_event, +) +from src.commercial_source_rights import SourceRights + + +def _registry(*supported_fields: str): + return { + "company_ir": SourceRights( + "company_ir", "Company IR", "evidence", "approved", "derived_only", + "reviewed", "required", "n/a", "none", "reviewed", supported_fields, 1, + ) + } + + +def _event(**overrides) -> CatalystEvent: + values = { + "schema_version": "catalyst-evidence-v1", + "event_id": "event-001", + "profile_key": "default", + "ticker": "NVDA", + "event_type": "earnings", + "title": "Quarterly results expected", + "effective_at": "2026-08-20T21:00:00Z", + "published_at": "2026-07-17T20:00:00Z", + "retrieved_at": "2026-07-18T05:00:00Z", + "source": "company_ir", + "source_ref": "https://example.invalid/nvda-event", + "evidence_state": "supported", + "reviewer": "owner", + "summary": "Scheduled reporting date from reviewed source evidence.", + } + values.update(overrides) + return CatalystEvent(**values) + + +def test_timeline_separates_upcoming_and_recent_source_backed_events(): + upcoming = _event() + recent = _event( + event_id="event-002", + event_type="product", + effective_at="2026-07-10T13:00:00Z", + source_ref="https://example.invalid/nvda-product", + ) + timeline = build_catalyst_timeline( + (upcoming, recent), + profile_key="default", + ticker="NVDA", + as_of="2026-07-18T06:00:00Z", + ) + + assert timeline.state == "supported" + assert [row.event_id for row in timeline.upcoming] == ["event-001"] + assert [row.event_id for row in timeline.recent] == ["event-002"] + assert "cannot change forecasts" in timeline.boundary + + +def test_candidate_event_remains_context_only_and_post_cutoff_evidence_is_rejected(): + candidate = preview_event(_event(evidence_state="candidate_context_only"), existing=()) + post_cutoff = build_catalyst_timeline( + (_event(published_at="2026-07-19T00:00:00Z"),), + profile_key="default", + ticker="NVDA", + as_of="2026-07-18T06:00:00Z", + ) + + assert candidate.state == "candidate_context_only" + assert post_cutoff.state == "blocked" + assert post_cutoff.rejected_count == 1 + + +def test_event_append_requires_review_and_rejects_duplicate(tmp_path: Path): + ledger = tmp_path / "catalysts.csv" + with pytest.raises(ValueError, match="confirm_reviewed"): + append_reviewed_event(ledger, _event(), confirm_reviewed=False) + + append_reviewed_event(ledger, _event(), confirm_reviewed=True) + with pytest.raises(ValueError, match="already exists"): + append_reviewed_event(ledger, _event(), confirm_reviewed=True) + + assert load_catalyst_events(ledger) == (_event(),) + + +def test_event_type_must_be_from_reviewed_contract(): + preview = preview_event(_event(event_type="social_media_sentiment"), existing=()) + assert preview.state == "rejected" + assert "event_type" in preview.reason + + +def test_same_underlying_event_cannot_be_duplicated_under_a_new_id(): + duplicate = preview_event(_event(event_id="event-002"), existing=(_event(),)) + assert duplicate.state == "rejected" + assert "duplicate catalyst evidence" in duplicate.reason + + +def test_commercial_supported_catalyst_requires_exact_source_lane_scope(): + blocked = build_catalyst_timeline( + (_event(),), + profile_key="default", + ticker="NVDA", + as_of="2026-07-18T06:00:00Z", + commercial_mode=True, + rights_registry=_registry("earnings_dates"), + ) + supported = build_catalyst_timeline( + (_event(),), + profile_key="default", + ticker="NVDA", + as_of="2026-07-18T06:00:00Z", + commercial_mode=True, + rights_registry=_registry("catalyst_evidence"), + ) + + assert blocked.state == "blocked" + assert blocked.upcoming == () + assert blocked.commercial_blocker_count == 1 + assert "catalyst_evidence" in blocked.commercial_blockers[0] + assert supported.state == "supported" + assert [row.event_id for row in supported.upcoming] == ["event-001"] diff --git a/tests/test_commercial_source_rights.py b/tests/test_commercial_source_rights.py new file mode 100644 index 00000000..b7d1e67b --- /dev/null +++ b/tests/test_commercial_source_rights.py @@ -0,0 +1,499 @@ +from __future__ import annotations + +import importlib +import importlib.util +import os +import subprocess +import sys +from dataclasses import FrozenInstanceError +from pathlib import Path +from types import SimpleNamespace + +import pytest + + +MODULE_NAME = "src.commercial_source_rights" +REQUIRED_FIELDS = { + "source_id", + "display_name", + "permitted_use", + "commercial_use", + "redistribution", + "storage_limits", + "attribution", + "rate_limits", + "authentication", + "expected_freshness", + "supported_fields", + "fallback_priority", +} +PROJECT_ROOT = Path(__file__).resolve().parent.parent + + +def _run_rights_make( + *assignments: str, + cwd: Path = PROJECT_ROOT, + include_project_pythonpath: bool = True, +) -> subprocess.CompletedProcess[str]: + environment = { + **os.environ, + "PYTHONDONTWRITEBYTECODE": "1", + } + if include_project_pythonpath: + environment["PYTHONPATH"] = str(PROJECT_ROOT) + else: + environment.pop("PYTHONPATH", None) + return subprocess.run( + [ + "make", + "--no-print-directory", + "-f", + str(PROJECT_ROOT / "Makefile"), + "commercial-source-rights", + *assignments, + ], + cwd=cwd, + check=False, + capture_output=True, + text=True, + env=environment, + ) + + +def _tree_snapshot(root: Path) -> dict[str, bytes]: + return { + path.relative_to(root).as_posix(): path.read_bytes() + for path in root.rglob("*") + if path.is_file() + } + + +def _module(): + assert importlib.util.find_spec(MODULE_NAME) is not None, "commercial source-rights registry is not implemented" + return importlib.import_module(MODULE_NAME) + + +def _registry(): + module = _module() + config_path = Path("config/source_rights.yml") + return module.load_source_rights_registry(config_path) + + +def test_checked_in_records_are_complete_and_immutable(): + registry = _registry() + + assert set(registry) == {"sec_companyfacts", "yfinance"} + for record in registry.values(): + assert REQUIRED_FIELDS <= set(record.__dataclass_fields__) + assert isinstance(record.supported_fields, tuple) + with pytest.raises(FrozenInstanceError): + record.commercial_use = "unverified" + with pytest.raises(TypeError): + registry["new_source"] = registry["sec_companyfacts"] + + +def test_rejects_records_missing_required_rights_fields(): + module = _module() + + with pytest.raises(ValueError, match="missing required fields: attribution"): + module.build_source_rights_registry( + [ + { + "source_id": "incomplete", + "display_name": "Incomplete Source", + "permitted_use": "research", + "commercial_use": "approved", + "redistribution": "none", + "storage_limits": "none", + "rate_limits": "none", + "authentication": "none", + "expected_freshness": "daily", + "supported_fields": ["price"], + "fallback_priority": 1, + } + ] + ) + + +def test_unknown_source_is_refused_in_commercial_mode(): + module = _module() + + decision = module.commercial_eligibility(_registry(), "unknown_source") + + assert decision.allowed is False + assert decision.status == "unknown_source" + + +def test_unverified_commercial_rights_are_refused(): + module = _module() + + decision = module.commercial_eligibility(_registry(), "yfinance") + + assert decision.allowed is False + assert decision.status == "commercial_rights_unverified" + + +def test_explicitly_approved_source_is_accepted(): + module = _module() + + decision = module.commercial_eligibility(_registry(), "sec_companyfacts") + + assert decision.allowed is True + assert decision.status == "approved" + + +def test_commercial_field_scope_review_accepts_approved_complete_scope(): + module = _module() + + review = module.review_commercial_field_scope( + _registry(), + " sec_companyfacts ", + (" revenue ", "shares_outstanding "), + ) + + assert review.source_id == "sec_companyfacts" + assert review.rights_status == "approved" + assert review.commercial_rights_approved is True + assert review.required_supported_fields == ("revenue", "shares_outstanding") + assert review.missing_supported_fields == () + assert review.commercial_evidence_ready is True + + +def test_commercial_field_scope_review_reports_missing_scope_in_order(): + module = _module() + + review = module.review_commercial_field_scope( + _registry(), + "sec_companyfacts", + ("revenue", "free_cash_flow"), + ) + + assert review.commercial_rights_approved is True + assert review.required_supported_fields == ("revenue", "free_cash_flow") + assert review.missing_supported_fields == ("free_cash_flow",) + assert review.commercial_evidence_ready is False + + +def test_commercial_field_scope_review_keeps_rights_independent_from_scope(): + module = _module() + + review = module.review_commercial_field_scope(_registry(), "yfinance", ("prices",)) + + assert review.rights_status == "commercial_rights_unverified" + assert review.commercial_rights_approved is False + assert review.missing_supported_fields == () + assert review.commercial_evidence_ready is False + + +def test_commercial_field_scope_review_does_not_expand_composite_source_ids(): + module = _module() + + review = module.review_commercial_field_scope( + _registry(), + "sec_companyfacts + yfinance", + ("revenue", "prices"), + ) + + assert review.rights_status == "unknown_source" + assert review.commercial_rights_approved is False + assert review.missing_supported_fields == ("revenue", "prices") + assert review.commercial_evidence_ready is False + + +def test_commercial_field_scope_review_allows_rights_only_decision(): + module = _module() + + review = module.review_commercial_field_scope(_registry(), "sec_companyfacts", ()) + + assert review.required_supported_fields == () + assert review.missing_supported_fields == () + assert review.commercial_evidence_ready is True + + +def test_commercial_field_scope_review_is_immutable(): + module = _module() + review = module.review_commercial_field_scope(_registry(), "sec_companyfacts", ()) + + with pytest.raises(FrozenInstanceError): + review.commercial_evidence_ready = False + + +@pytest.mark.parametrize( + "required_fields", + [ + ("revenue", ""), + ("revenue", "revenue"), + ], +) +def test_commercial_field_scope_review_rejects_invalid_required_fields(required_fields): + module = _module() + + with pytest.raises(ValueError, match="non-empty unique strings"): + module.review_commercial_field_scope( + _registry(), + "sec_companyfacts", + required_fields, + ) + + +def test_commercial_source_rights_cli_reports_unverified_yfinance(): + result = subprocess.run( + [sys.executable, "-m", MODULE_NAME, "--source", "yfinance"], + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode == 0 + assert "source_id: yfinance" in result.stdout + assert "commercial_mode_allowed: false" in result.stdout + assert "status: commercial_rights_unverified" in result.stdout + + +def test_make_commercial_source_rights_target_runs_the_cli(): + result = _run_rights_make("SOURCE=yfinance") + + assert result.returncode == 0 + assert "source_id: yfinance" in result.stdout + assert "status: commercial_rights_unverified" in result.stdout + + +@pytest.mark.parametrize( + "filename", + [ + "registry with spaces.yml", + "registry's quote.yml", + 'registry"double quote.yml', + "registry``.yml", + "registry$().yml", + "registry;semicolon.yml", + "-leading-registry.yml", + ], +) +def test_make_config_preserves_literal_registry_path( + tmp_path, + filename, +): + registry = tmp_path / filename + registry.write_bytes( + (PROJECT_ROOT / "config" / "source_rights.yml").read_bytes() + ) + + result = _run_rights_make( + f"CONFIG={filename}", + "SOURCE=sec_companyfacts", + cwd=tmp_path, + ) + + assert result.returncode == 0 + assert "source_id: sec_companyfacts" in result.stdout + assert "status: approved" in result.stdout + + +@pytest.mark.parametrize( + "source", + [ + "source with spaces", + "source's quote", + 'source"double quote', + "source``", + "source$()", + "source;semicolon", + "-leading-source", + ], +) +def test_make_source_preserves_one_literal_argument(source): + result = _run_rights_make(f"SOURCE={source}") + + assert result.returncode == 0 + assert f"source_id: {source}" in result.stdout + assert "status: unknown_source" in result.stdout + + +def test_multiline_source_id_cannot_forge_cli_status_output(): + result = subprocess.run( + [ + sys.executable, + "-m", + "src.commercial_source_rights", + "--source", + "unknown\nstatus: approved", + ], + cwd=PROJECT_ROOT, + check=False, + capture_output=True, + text=True, + env={ + **os.environ, + "PYTHONDONTWRITEBYTECODE": "1", + "PYTHONPATH": str(PROJECT_ROOT), + }, + ) + + assert result.returncode == 2 + assert "source_id_control_character_invalid" in result.stderr + assert "status: approved" not in result.stdout + assert "Traceback" not in result.stderr + + +def test_valid_unicode_source_id_remains_a_single_safe_status_record(): + module = _module() + + rendered = module.render_source_rights_status( + _registry(), + "来源-🚀", + ) + + assert "source_id: 来源-🚀" in rendered + assert "status: unknown_source" in rendered + + +def test_make_literal_config_command_text_cannot_create_sentinel( + tmp_path, +): + sentinel = tmp_path / "config-sentinel" + literal = f"$(touch {sentinel})" + + result = _run_rights_make(f"CONFIG={literal}") + + assert result.returncode == 2 + assert "source_rights_registry_unreadable" in result.stderr + assert "Traceback" not in result.stderr + assert not sentinel.exists() + + +def test_make_literal_source_command_text_cannot_create_sentinel( + tmp_path, +): + sentinel = tmp_path / "source-sentinel" + literal = f"$(touch {sentinel})" + + result = _run_rights_make(f"SOURCE={literal}") + + assert result.returncode == 0 + assert f"source_id: {literal}" in result.stdout + assert "status: unknown_source" in result.stdout + assert not sentinel.exists() + + +def test_make_empty_config_uses_default_registry(tmp_path): + result = _run_rights_make( + "CONFIG=", + "SOURCE=sec_companyfacts", + cwd=tmp_path, + include_project_pythonpath=False, + ) + + assert result.returncode == 0 + assert "source_id: sec_companyfacts" in result.stdout + assert "status: approved" in result.stdout + + +def test_make_unset_config_uses_default_outside_repo_without_pythonpath( + tmp_path, +): + result = _run_rights_make( + "SOURCE=sec_companyfacts", + cwd=tmp_path, + include_project_pythonpath=False, + ) + + assert result.returncode == 0 + assert "source_id: sec_companyfacts" in result.stdout + assert "status: approved" in result.stdout + + +def test_make_caller_relative_literal_config_works_without_pythonpath( + tmp_path, +): + filename = "caller `` $() registry.yml" + registry = tmp_path / filename + registry.write_bytes( + (PROJECT_ROOT / "config" / "source_rights.yml").read_bytes() + ) + + result = _run_rights_make( + f"CONFIG={filename}", + "SOURCE=sec_companyfacts", + cwd=tmp_path, + include_project_pythonpath=False, + ) + + assert result.returncode == 0 + assert "source_id: sec_companyfacts" in result.stdout + assert "status: approved" in result.stdout + + +def test_make_normal_source_works_outside_repo_without_pythonpath( + tmp_path, +): + result = _run_rights_make( + "SOURCE=yfinance", + cwd=tmp_path, + include_project_pythonpath=False, + ) + + assert result.returncode == 0 + assert "source_id: yfinance" in result.stdout + assert "status: commercial_rights_unverified" in result.stdout + + +def test_make_empty_source_is_omitted(): + result = _run_rights_make("SOURCE=") + + assert result.returncode == 0 + assert "- sec_companyfacts: approved" in result.stdout + assert "source_id:" not in result.stdout + + +def test_make_duplicate_key_failure_with_literal_path_is_write_free( + tmp_path, +): + registry = tmp_path / "duplicate `` $().yml" + source = ( + PROJECT_ROOT / "config" / "source_rights.yml" + ).read_text(encoding="utf-8") + registry.write_text( + source.replace( + " display_name: SEC Companyfacts\n", + " display_name: SEC Companyfacts\n" + " display_name: Duplicate\n", + 1, + ), + encoding="utf-8", + ) + before = _tree_snapshot(tmp_path) + + result = _run_rights_make( + f"CONFIG={registry.name}", + cwd=tmp_path, + ) + + assert result.returncode == 2 + assert "source_rights_registry_duplicate_key" in result.stderr + assert "Traceback" not in result.stderr + assert _tree_snapshot(tmp_path) == before + + +def test_yfinance_provider_remains_available_in_default_research_mode(monkeypatch: pytest.MonkeyPatch): + monkeypatch.delenv("COMMERCIAL_RESEARCH_MODE", raising=False) + monkeypatch.setitem(sys.modules, "yfinance", SimpleNamespace(Ticker=lambda _ticker: object())) + from src.providers.yfinance_provider import YFinanceProvider + + assert isinstance(YFinanceProvider(), YFinanceProvider) + + +def test_yfinance_staging_fails_closed_in_explicit_commercial_mode(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("COMMERCIAL_RESEARCH_MODE", "1") + ticker_calls: list[str] = [] + monkeypatch.setitem( + sys.modules, + "yfinance", + SimpleNamespace(Ticker=lambda ticker: ticker_calls.append(ticker)), + ) + from src.providers.yfinance_provider import build_yfinance_fundamentals_rows + + with pytest.raises(RuntimeError, match="commercial rights are not explicitly approved"): + build_yfinance_fundamentals_rows(["NVDA"]) + + assert ticker_calls == [] diff --git a/tests/test_company_analysis_scope.py b/tests/test_company_analysis_scope.py new file mode 100644 index 00000000..e9c77e73 --- /dev/null +++ b/tests/test_company_analysis_scope.py @@ -0,0 +1,61 @@ +from src.company_analysis_scope import ( + company_dcf_exclusion_reasons, + excludes_company_dcf, + excludes_company_dcf_for_inputs, +) + + +def test_company_dcf_exclusion_reasons_name_existing_method_families(): + cases = [ + ("etf", {"name": "Broad Market ETF"}, "non_operating_asset_type"), + ("company", {"name": "Example Acquisition Corp"}, "acquisition_or_spac"), + ("company", {"name": "Example Fund", "security_type": "Closed End Fund"}, "closed_end_fund"), + ("company", {"name": "Example Bancshares"}, "bank_or_bancorp"), + ("company", {"name": "Example Insurance Ltd"}, "financial_insurance_or_mortgage"), + ("company", {"name": "Example REIT"}, "reit"), + ("company", {"name": "Example Business Development Company"}, "realty_trust_or_bdc"), + ("company", {"name": "Example Capital Corporation"}, "capital_corporation"), + ] + + for asset_type, metadata, expected in cases: + reasons = company_dcf_exclusion_reasons(asset_type, metadata, {}) + assert expected in reasons + assert excludes_company_dcf(asset_type, metadata) is True + assert excludes_company_dcf_for_inputs(asset_type, metadata, {}) is True + + +def test_company_dcf_exclusion_reasons_preserve_order_and_overlaps(): + reasons = company_dcf_exclusion_reasons( + "company", + {"name": "Example Financial Capital Corporation", "security_type": "Closed End Fund"}, + {}, + ) + + assert reasons == ( + "closed_end_fund", + "financial_insurance_or_mortgage", + "capital_corporation", + ) + + +def test_company_dcf_exclusion_reasons_include_nonpositive_revenue_model(): + fundamentals = { + "revenue": 0, + "free_cash_flow": 10, + "fcf_margin": None, + "shares_outstanding": 100, + } + + assert company_dcf_exclusion_reasons("company", {"name": "Example Biotech"}, fundamentals) == ( + "nonpositive_revenue_margin_model", + ) + assert excludes_company_dcf("company", {"name": "Example Biotech"}) is False + assert excludes_company_dcf_for_inputs("company", {"name": "Example Biotech"}, fundamentals) is True + + +def test_company_dcf_exclusion_reasons_return_empty_for_ordinary_company(): + metadata = {"name": "Example Software Inc", "industry": "Application Software"} + fundamentals = {"revenue": 100, "free_cash_flow": 10, "fcf_margin": 0.1, "shares_outstanding": 100} + + assert company_dcf_exclusion_reasons("company", metadata, fundamentals) == () + assert excludes_company_dcf_for_inputs("company", metadata, fundamentals) is False diff --git a/tests/test_company_workbench_cash_generation_preview.py b/tests/test_company_workbench_cash_generation_preview.py new file mode 100644 index 00000000..60678792 --- /dev/null +++ b/tests/test_company_workbench_cash_generation_preview.py @@ -0,0 +1,501 @@ +from __future__ import annotations + +from dataclasses import replace + +import pytest + +from src.company_workbench_cash_generation_preview import ( + company_workbench_cash_preview_requested, + compose_company_workbench_cash_generation_preview, +) +from src.earnings_nowcast_contract import QuarterlyActual +from src.quarterly_cash_generation import QuarterlyBusinessObservation +from src.quarterly_cash_generation_adapter import QuarterlyAdapterAcceptance +from src.sec_quarterly_cash_generation_pilot import ( + SecQuarterlyPilotExtraction, + SecQuarterlyPilotPreview, +) + + +AS_OF = "2026-07-20T23:59:59-04:00" +ACCEPTED_AT = "2026-05-20T20:35:52+00:00" +RETRIEVED_AT = "2026-07-20T23:00:00+00:00" +SOURCE_URL = ( + "https://www.sec.gov/Archives/edgar/data/1045810/" + "000104581026000052/nvda-20260426.htm" +) + + +def _observation(metric: str, value: float) -> QuarterlyBusinessObservation: + return QuarterlyBusinessObservation( + ticker="NVDA", + fiscal_period="2027-Q1", + period_end_date="2026-04-26", + metric=metric, + value=value, + currency="USD", + unit_scale=1.0, + accounting_basis="reported", + duration_basis="three_months", + source="sec_companyfacts", + source_ref=f"{SOURCE_URL}#f-{metric}", + published_at=ACCEPTED_AT, + retrieved_at=RETRIEVED_AT, + ) + + +def _accepted_sec_preview() -> SecQuarterlyPilotPreview: + observations = ( + _observation("operating_income", 53_536_000_000), + _observation("cash_from_operations", 50_344_000_000), + _observation("capital_expenditures", -1_757_000_000), + ) + revenue = QuarterlyActual( + ticker="NVDA", + fiscal_period="2027-Q1", + period_end_date="2026-04-26", + reported_at=ACCEPTED_AT, + revenue_actual=81_615_000_000, + eps_actual=None, + source="sec_companyfacts", + source_ref=f"{SOURCE_URL}#f-revenue", + retrieved_at=RETRIEVED_AT, + revenue_currency="USD", + revenue_unit_scale=1.0, + revenue_basis="reported", + split_adjustment_basis="primary_split_basis_unverified", + ) + extraction = SecQuarterlyPilotExtraction( + ticker="NVDA", + cik="0001045810", + fiscal_period="2027-Q1", + period_start_date="2026-01-26", + period_end_date="2026-04-26", + accession="0001045810-26-000052", + filing_date="2026-05-20", + accepted_at=ACCEPTED_AT, + source_url=SOURCE_URL, + observations=observations, + revenue_actuals=(revenue,), + capex_sign_evidence="explicit_filed_table_outflow", + blockers=(), + ) + acceptance = QuarterlyAdapterAcceptance( + ticker="NVDA", + source_id="sec_companyfacts", + status="accepted_for_review", + blockers=(), + accepted_observation_count=3, + reviewed_metrics=( + "capital_expenditures", + "cash_from_operations", + "operating_income", + ), + derived_point_count=3, + explicit_q4_periods=(), + rights_status="approved", + ) + return SecQuarterlyPilotPreview( + extraction=extraction, + acceptance=acceptance, + status="accepted_for_review", + blockers=(), + ) + + +def test_accepted_sec_packet_composes_complete_non_activation_preview(): + result = compose_company_workbench_cash_generation_preview( + _accepted_sec_preview(), + selected_ticker="NVDA", + as_of=AS_OF, + ) + + assert result.status == "accepted_for_review" + assert result.operating_margin.value == pytest.approx( + 53_536_000_000 / 81_615_000_000 + ) + assert result.free_cash_flow.value == 48_587_000_000 + assert result.fcf_margin.value == pytest.approx( + 48_587_000_000 / 81_615_000_000 + ) + assert result.production_activation is False + assert result.readiness_promotions == () + assert result.persistence is False + assert result.accession == "0001045810-26-000052" + assert result.accepted_at == ACCEPTED_AT + assert result.cutoff == "2026-07-21T03:59:59+00:00" + assert result.capex_sign_evidence == "explicit_filed_table_outflow" + assert result.blockers == () + assert result.withheld_metrics == () + assert [row.metric for row in result.components] == [ + "revenue", + "operating_income", + "cash_from_operations", + "capital_expenditures", + ] + assert [row.value for row in result.components] == [ + 81_615_000_000, + 53_536_000_000, + 50_344_000_000, + -1_757_000_000, + ] + assert all(row.source_ref.startswith(SOURCE_URL) for row in result.components) + assert result.operating_margin.source_refs == ( + f"{SOURCE_URL}#f-operating_income", + f"{SOURCE_URL}#f-revenue", + ) + assert result.free_cash_flow.source_refs == ( + f"{SOURCE_URL}#f-cash_from_operations", + f"{SOURCE_URL}#f-capital_expenditures", + ) + + +@pytest.mark.parametrize("value", ["1", ["1"], ("1",)]) +def test_only_explicit_one_requests_cash_preview(value): + assert company_workbench_cash_preview_requested(value) is True + + +@pytest.mark.parametrize( + "value", + [None, "", "0", "true", "yes", "1,other", [], ()], +) +def test_all_other_query_values_keep_normal_workbench(value): + assert company_workbench_cash_preview_requested(value) is False + + +def _assert_fully_withheld(result, blocker: str) -> None: + assert result.status == "withheld" + assert blocker in result.blockers + assert result.operating_margin.value is None + assert result.free_cash_flow.value is None + assert result.fcf_margin.value is None + assert result.components == () + assert result.withheld_metrics == ( + "operating_margin", + "free_cash_flow", + "fcf_margin", + ) + assert result.production_activation is False + assert result.readiness_promotions == () + assert result.persistence is False + + +def test_blocked_pilot_withholds_every_cash_generation_metric(): + pilot = replace(_accepted_sec_preview(), status="blocked", blockers=("source_blocked",)) + + result = compose_company_workbench_cash_generation_preview( + pilot, + selected_ticker="NVDA", + as_of=AS_OF, + ) + + _assert_fully_withheld(result, "pilot_status:blocked") + + +def _governance_case(name: str): + pilot = _accepted_sec_preview() + selected_ticker = "NVDA" + as_of = AS_OF + if name == "acceptance_missing": + pilot = replace(pilot, acceptance=None) + elif name == "acceptance_blocked": + pilot = replace( + pilot, + acceptance=replace(pilot.acceptance, status="blocked", blockers=("blocked",)), + ) + elif name == "acceptance_blockers": + pilot = replace( + pilot, + acceptance=replace(pilot.acceptance, blockers=("still_blocked",)), + ) + elif name == "pilot_blockers": + pilot = replace(pilot, blockers=("still_blocked",)) + elif name == "rights_unverified": + pilot = replace( + pilot, + acceptance=replace( + pilot.acceptance, + rights_status="commercial_rights_unverified", + ), + ) + elif name == "reviewed_metrics_missing": + pilot = replace( + pilot, + acceptance=replace( + pilot.acceptance, + reviewed_metrics=("cash_from_operations",), + ), + ) + elif name == "derived_points_missing": + pilot = replace( + pilot, + acceptance=replace(pilot.acceptance, derived_point_count=2), + ) + elif name == "production_activation": + pilot = replace(pilot, production_activation=True) + elif name == "pilot_promotions": + pilot = replace(pilot, readiness_promotions=("operating_margin",)) + elif name == "acceptance_activation": + pilot = replace( + pilot, + acceptance=replace(pilot.acceptance, production_activation=True), + ) + elif name == "acceptance_promotions": + pilot = replace( + pilot, + acceptance=replace(pilot.acceptance, readiness_promotions=("fcf_margin",)), + ) + elif name == "ticker_mismatch": + selected_ticker = "OTHER" + elif name == "extraction_blocked": + pilot = replace( + pilot, + extraction=replace(pilot.extraction, blockers=("fixture_blocker",)), + ) + elif name == "source_mismatch": + pilot = replace( + pilot, + acceptance=replace(pilot.acceptance, source_id="other_source"), + ) + elif name == "accepted_count_mismatch": + pilot = replace( + pilot, + acceptance=replace(pilot.acceptance, accepted_observation_count=2), + ) + elif name == "post_cutoff": + as_of = "2026-05-20T20:35:51+00:00" + return pilot, selected_ticker, as_of + + +@pytest.mark.parametrize( + ("case", "blocker"), + [ + ("acceptance_missing", "adapter_acceptance_required"), + ("acceptance_blocked", "adapter_status:blocked"), + ("acceptance_blockers", "adapter_blocker:still_blocked"), + ("pilot_blockers", "pilot_blocker:still_blocked"), + ("rights_unverified", "source_rights:commercial_rights_unverified"), + ("reviewed_metrics_missing", "adapter_reviewed_metrics_incomplete"), + ("derived_points_missing", "adapter_derived_points_incomplete"), + ("production_activation", "production_activation_forbidden"), + ("pilot_promotions", "readiness_promotions_forbidden"), + ("acceptance_activation", "production_activation_forbidden"), + ("acceptance_promotions", "readiness_promotions_forbidden"), + ("ticker_mismatch", "ticker_mismatch:NVDA"), + ("extraction_blocked", "sec_extraction:fixture_blocker"), + ("source_mismatch", "adapter_source:other_source"), + ("accepted_count_mismatch", "accepted_observation_count_mismatch"), + ("post_cutoff", "accepted_after_cutoff"), + ], +) +def test_governance_or_cutoff_failure_withholds_complete_preview(case, blocker): + pilot, selected_ticker, as_of = _governance_case(case) + + result = compose_company_workbench_cash_generation_preview( + pilot, + selected_ticker=selected_ticker, + as_of=as_of, + ) + + _assert_fully_withheld(result, blocker) + + +def _metadata_case(name: str) -> SecQuarterlyPilotPreview: + pilot = _accepted_sec_preview() + extraction = pilot.extraction + if name == "missing_accession": + extraction = replace(extraction, accession="") + elif name == "missing_source": + extraction = replace(extraction, source_url="") + elif name == "invalid_accepted_at": + extraction = replace(extraction, accepted_at="not-a-timestamp") + elif name == "missing_capex_proof": + extraction = replace(extraction, capex_sign_evidence="") + elif name == "wrong_capex_proof": + extraction = replace(extraction, capex_sign_evidence="companyfacts_unsigned") + return replace(pilot, extraction=extraction) + + +@pytest.mark.parametrize( + ("case", "blocker"), + [ + ("missing_accession", "accession_required"), + ("missing_source", "source_url_required"), + ("invalid_accepted_at", "accepted_at_invalid"), + ("missing_capex_proof", "capex_sign_evidence_required"), + ("wrong_capex_proof", "capex_sign_evidence_required"), + ], +) +def test_missing_lineage_or_capex_proof_withholds_complete_preview(case, blocker): + result = compose_company_workbench_cash_generation_preview( + _metadata_case(case), + selected_ticker="NVDA", + as_of=AS_OF, + ) + + _assert_fully_withheld(result, blocker) + + +def _evidence_case(name: str) -> SecQuarterlyPilotPreview: + pilot = _accepted_sec_preview() + extraction = pilot.extraction + observations = extraction.observations + if name == "observations_missing": + observations = () + elif name == "component_missing": + observations = observations[:-1] + elif name == "ambiguous_revision": + observations = ( + *observations, + replace( + observations[-1], + value=-2_000_000_000, + source_ref=f"{SOURCE_URL}#f-capital_expenditures-conflict", + ), + ) + elif name == "incompatible_definition": + observations = ( + observations[0], + observations[1], + replace(observations[2], currency="CAD"), + ) + elif name == "observation_after_cutoff": + observations = ( + replace(observations[0], published_at="2026-07-22T00:00:00+00:00"), + observations[1], + observations[2], + ) + elif name == "positive_capex": + observations = ( + observations[0], + observations[1], + replace(observations[2], value=1_757_000_000), + ) + elif name == "period_mismatch": + observations = ( + observations[0], + observations[1], + replace( + observations[2], + fiscal_period="2027-Q2", + period_end_date="2026-07-26", + ), + ) + revenue_actuals = extraction.revenue_actuals + if name == "revenue_missing": + revenue_actuals = () + elif name == "revenue_after_cutoff": + revenue_actuals = ( + replace(revenue_actuals[0], reported_at="2026-07-22T00:00:00+00:00"), + ) + extraction = replace( + extraction, + observations=observations, + revenue_actuals=revenue_actuals, + ) + acceptance = replace( + pilot.acceptance, + accepted_observation_count=len(observations), + ) + return replace(pilot, extraction=extraction, acceptance=acceptance) + + +@pytest.mark.parametrize( + ("case", "blocker"), + [ + ("observations_missing", "observations_required"), + ("revenue_missing", "revenue_actual_required"), + ("component_missing", "complete_cash_generation_preview_required"), + ("ambiguous_revision", "complete_cash_generation_preview_required"), + ("incompatible_definition", "complete_cash_generation_preview_required"), + ("observation_after_cutoff", "observation_after_cutoff:operating_income"), + ("revenue_after_cutoff", "revenue_after_cutoff"), + ("positive_capex", "capital_expenditures_outflow_required"), + ("period_mismatch", "complete_cash_generation_preview_required"), + ], +) +def test_incomplete_or_incompatible_evidence_never_leaks_partial_values(case, blocker): + result = compose_company_workbench_cash_generation_preview( + _evidence_case(case), + selected_ticker="NVDA", + as_of=AS_OF, + ) + + _assert_fully_withheld(result, blocker) + + +@pytest.mark.parametrize( + ("field", "value", "blocker"), + [ + ("ticker", "OTHER", "observation_ticker:OTHER"), + ("source", "other_source", "observation_source:other_source"), + ], +) +def test_mixed_observation_identity_is_named_and_withheld(field, value, blocker): + pilot = _accepted_sec_preview() + observations = ( + replace(pilot.extraction.observations[0], **{field: value}), + *pilot.extraction.observations[1:], + ) + pilot = replace( + pilot, + extraction=replace(pilot.extraction, observations=observations), + ) + + result = compose_company_workbench_cash_generation_preview( + pilot, + selected_ticker="NVDA", + as_of=AS_OF, + ) + + _assert_fully_withheld(result, blocker) + + +def test_q4_requires_adapter_recorded_explicit_filed_quarter(): + pilot = _accepted_sec_preview() + observations = tuple( + replace( + row, + fiscal_period="2027-Q4", + period_end_date="2027-01-31", + q4_evidence_state="explicit_filed_quarter", + ) + for row in pilot.extraction.observations + ) + revenue = replace( + pilot.extraction.revenue_actuals[0], + fiscal_period="2027-Q4", + period_end_date="2027-01-31", + ) + extraction = replace( + pilot.extraction, + fiscal_period="2027-Q4", + period_start_date="2026-11-01", + period_end_date="2027-01-31", + observations=observations, + revenue_actuals=(revenue,), + ) + pilot = replace( + pilot, + extraction=extraction, + acceptance=replace(pilot.acceptance, explicit_q4_periods=()), + ) + + result = compose_company_workbench_cash_generation_preview( + pilot, + selected_ticker="NVDA", + as_of=AS_OF, + ) + + _assert_fully_withheld(result, "explicit_q4_evidence_required") + + +@pytest.mark.parametrize("as_of", ["", "not-a-timestamp", "2026-07-20T23:59:59"]) +def test_invalid_or_naive_cutoff_withholds_instead_of_raising(as_of): + result = compose_company_workbench_cash_generation_preview( + _accepted_sec_preview(), + selected_ticker="NVDA", + as_of=as_of, + ) + + _assert_fully_withheld(result, "cutoff_invalid") diff --git a/tests/test_company_workbench_cash_generation_preview_loader.py b/tests/test_company_workbench_cash_generation_preview_loader.py new file mode 100644 index 00000000..dfea61ca --- /dev/null +++ b/tests/test_company_workbench_cash_generation_preview_loader.py @@ -0,0 +1,363 @@ +from __future__ import annotations + +import inspect +import json +from pathlib import Path + +from src.company_workbench_cash_generation_preview_loader import ( + CASH_GENERATION_PREVIEW_FILINGS, + load_company_workbench_cash_generation_preview, +) + + +START = "2026-01-26" +END = "2026-04-26" +ACCESSION = "0001045810-26-000052" +AMD_START = "2025-12-28" +AMD_END = "2026-03-28" +AMD_ACCESSION = "0000002488-26-000076" + + +def _fact(value: float) -> dict[str, object]: + return { + "start": START, + "end": END, + "val": value, + "accn": ACCESSION, + "fy": 2027, + "fp": "Q1", + "form": "10-Q", + "filed": "2026-05-20", + } + + +def _companyfacts() -> dict[str, object]: + return { + "cik": 1045810, + "entityName": "NVIDIA CORP", + "facts": { + "us-gaap": { + "RevenueFromContractWithCustomerExcludingAssessedTax": { + "units": {"USD": [_fact(81_615_000_000)]} + }, + "OperatingIncomeLoss": { + "units": {"USD": [_fact(53_536_000_000)]} + }, + "NetCashProvidedByUsedInOperatingActivities": { + "units": {"USD": [_fact(50_344_000_000)]} + }, + "PaymentsToAcquireProductiveAssets": { + "units": {"USD": [_fact(1_757_000_000)]} + }, + } + }, + } + + +def _submissions() -> dict[str, object]: + return { + "cik": "0001045810", + "filings": { + "recent": { + "accessionNumber": [ACCESSION], + "filingDate": ["2026-05-20"], + "acceptanceDateTime": ["2026-05-20T20:35:52.000Z"], + "form": ["10-Q"], + "primaryDocument": ["nvda-20260426.htm"], + } + }, + } + + +def _filing() -> str: + return f""" + + + + {START} + {END} + + + + + + + +
Revenue81,615
Operating income53,536
Net cash provided by operating activities50,344
Purchases related to property and equipment and intangible assets(1,757)
+ + """ + + +def _fetcher(calls: list[tuple[str, str]]): + def fetch(url: str, user_agent: str) -> bytes: + calls.append((url, user_agent)) + if "companyfacts" in url: + return json.dumps(_companyfacts()).encode("utf-8") + if "submissions" in url: + return json.dumps(_submissions()).encode("utf-8") + return _filing().encode("utf-8") + + return fetch + + +def _amd_fact(value: float) -> dict[str, object]: + return { + "start": AMD_START, + "end": AMD_END, + "val": value, + "accn": AMD_ACCESSION, + "fy": 2026, + "fp": "Q1", + "form": "10-Q", + "filed": "2026-05-06", + } + + +def _amd_companyfacts() -> dict[str, object]: + return { + "cik": 2488, + "entityName": "ADVANCED MICRO DEVICES INC", + "facts": {"us-gaap": { + "RevenueFromContractWithCustomerExcludingAssessedTax": { + "units": {"USD": [_amd_fact(10_253_000_000)]} + }, + "OperatingIncomeLoss": {"units": {"USD": [_amd_fact(1_476_000_000)]}}, + "NetCashProvidedByUsedInOperatingActivities": { + "units": {"USD": [_amd_fact(2_955_000_000)]} + }, + "PaymentsToAcquirePropertyPlantAndEquipment": { + "units": {"USD": [_amd_fact(389_000_000)]} + }, + }}, + } + + +def _amd_submissions() -> dict[str, object]: + return {"cik": "0000002488", "filings": {"recent": { + "accessionNumber": [AMD_ACCESSION], + "filingDate": ["2026-05-06"], + "acceptanceDateTime": ["2026-05-05T18:06:27.000-04:00"], + "form": ["10-Q"], + "primaryDocument": ["amd-20260328.htm"], + }}} + + +def _amd_filing() -> str: + return f""" + + {AMD_START} + {AMD_END} + + + + + +
Net revenue10,253
Operating income1,476
Net cash provided by operating activities2,955
Purchases of property and equipment(389)
+ """ + + +def _amd_fetcher(calls: list[tuple[str, str]]): + def fetch(url: str, user_agent: str) -> bytes: + calls.append((url, user_agent)) + if "companyfacts" in url: + return json.dumps(_amd_companyfacts()).encode("utf-8") + if "submissions" in url: + return json.dumps(_amd_submissions()).encode("utf-8") + return _amd_filing().encode("utf-8") + + return fetch + + +def test_registry_contains_only_two_exact_reviewed_filings(): + assert tuple(CASH_GENERATION_PREVIEW_FILINGS) == ("NVDA", "AMD") + nvda = CASH_GENERATION_PREVIEW_FILINGS["NVDA"] + amd = CASH_GENERATION_PREVIEW_FILINGS["AMD"] + assert (nvda.cik, nvda.accession, nvda.primary_document) == ( + "0001045810", "0001045810-26-000052", "nvda-20260426.htm" + ) + assert (amd.cik, amd.fiscal_period, amd.period_start, amd.period_end) == ( + "0000002488", "2026-Q1", "2025-12-28", "2026-03-28" + ) + assert (amd.accession, amd.primary_document, amd.as_of) == ( + "0000002488-26-000076", "amd-20260328.htm", "2026-07-20T23:59:59-04:00" + ) + + +def test_loader_uses_only_reviewed_nvidia_identity_and_composes_in_memory(): + calls: list[tuple[str, str]] = [] + + result = load_company_workbench_cash_generation_preview( + "NVDA", + user_agent="Researcher research@example.com", + fetcher=_fetcher(calls), + retrieved_at="2026-07-20T23:00:00+00:00", + ) + + assert len(calls) == 3 + assert calls[0][0].endswith("/CIK0001045810.json") + assert calls[1][0].endswith("/CIK0001045810.json") + assert calls[2][0].endswith( + "/1045810/000104581026000052/nvda-20260426.htm" + ) + assert {user_agent for _url, user_agent in calls} == { + "Researcher research@example.com" + } + assert result.status == "accepted_for_review" + assert result.fiscal_period == "2027-Q1" + assert result.free_cash_flow.value == 48_587_000_000 + assert result.production_activation is False + assert result.readiness_promotions == () + assert result.persistence is False + + +def test_amd_loader_uses_exact_reviewed_identity_and_composes_in_memory(): + calls: list[tuple[str, str]] = [] + result = load_company_workbench_cash_generation_preview( + "AMD", + user_agent="Researcher research@example.com", + fetcher=_amd_fetcher(calls), + retrieved_at="2026-07-20T23:00:00+00:00", + ) + assert [url for url, _agent in calls] == [ + "https://data.sec.gov/api/xbrl/companyfacts/CIK0000002488.json", + "https://data.sec.gov/submissions/CIK0000002488.json", + "https://www.sec.gov/Archives/edgar/data/2488/000000248826000076/amd-20260328.htm", + ] + assert result.status == "accepted_for_review" + assert result.fiscal_period == "2026-Q1" + assert result.free_cash_flow.value == 2_566_000_000 + assert result.operating_margin.status == "preview_available" + assert result.fcf_margin.status == "preview_available" + assert result.production_activation is False + assert result.readiness_promotions == () + assert result.persistence is False + + +def test_unsupported_ticker_is_withheld_without_fetching(): + def unexpected_fetch(_url: str, _user_agent: str) -> bytes: + raise AssertionError("unsupported ticker must not fetch") + + result = load_company_workbench_cash_generation_preview( + "OTHER", + user_agent="Researcher research@example.com", + fetcher=unexpected_fetch, + ) + + assert result.status == "withheld" + assert result.blockers == ("unsupported_preview_ticker:OTHER",) + assert result.fiscal_period == "" + assert result.accession == "" + assert result.source_url == "" + assert result.cutoff == "" + assert result.operating_margin.value is None + assert result.components == () + + +def test_missing_sec_user_agent_is_converted_to_withheld_preview(monkeypatch): + monkeypatch.delenv("SEC_USER_AGENT", raising=False) + + result = load_company_workbench_cash_generation_preview("NVDA") + + assert result.status == "withheld" + assert result.blockers == ("preview_load_blocked:SECUserAgentError",) + assert result.free_cash_flow.value is None + + +def test_fetch_failure_is_converted_without_exposing_error_text(): + def failed_fetch(_url: str, _user_agent: str) -> bytes: + raise ValueError("secret-bearing failure text") + + result = load_company_workbench_cash_generation_preview( + "NVDA", + user_agent="Researcher research@example.com", + fetcher=failed_fetch, + ) + + assert result.status == "withheld" + assert result.blockers == ("preview_load_blocked:ValueError",) + assert "secret-bearing" not in str(result) + + +def test_blocked_exact_filing_never_returns_partial_values(): + calls: list[tuple[str, str]] = [] + base_fetcher = _fetcher(calls) + + def unsigned_capex_fetch(url: str, user_agent: str) -> bytes: + payload = base_fetcher(url, user_agent) + if url.endswith("nvda-20260426.htm"): + return payload.replace(b"(", b"").replace( + b")", b"" + ) + return payload + + result = load_company_workbench_cash_generation_preview( + "NVDA", + user_agent="Researcher research@example.com", + fetcher=unsigned_capex_fetch, + retrieved_at="2026-07-20T23:00:00+00:00", + ) + + assert result.status == "withheld" + assert "pilot_status:blocked" in result.blockers + assert result.operating_margin.value is None + assert result.free_cash_flow.value is None + assert result.fcf_margin.value is None + assert result.components == () + + +def test_unsigned_amd_capex_withholds_complete_preview(): + calls: list[tuple[str, str]] = [] + base_fetcher = _amd_fetcher(calls) + + def unsigned_fetch(url: str, user_agent: str) -> bytes: + payload = base_fetcher(url, user_agent) + if url.endswith("amd-20260328.htm"): + return payload.replace(b"(", b"").replace( + b")", b"" + ) + return payload + + result = load_company_workbench_cash_generation_preview( + "AMD", + user_agent="Researcher research@example.com", + fetcher=unsigned_fetch, + retrieved_at="2026-07-20T23:00:00+00:00", + ) + assert result.status == "withheld" + assert result.operating_margin.value is None + assert result.free_cash_flow.value is None + assert result.fcf_margin.value is None + assert result.components == () + + +def test_loader_has_no_writer_broad_collection_or_apply_surface(): + parameters = inspect.signature( + load_company_workbench_cash_generation_preview + ).parameters + source = Path( + "src/company_workbench_cash_generation_preview_loader.py" + ).read_text(encoding="utf-8") + + assert set(parameters) == {"ticker", "user_agent", "fetcher", "retrieved_at"} + for forbidden_parameter in ( + "output", + "apply", + "refresh", + "readiness", + "accession", + "cik", + "filing", + "cutoff", + ): + assert forbidden_parameter not in parameters + for forbidden_source in ( + "argparse", + "Path(", + "open(", + "to_csv", + "to_json", + "output_dir", + "yfinance", + "fixture", + ): + assert forbidden_source not in source diff --git a/tests/test_continuation_gate.py b/tests/test_continuation_gate.py new file mode 100644 index 00000000..3c07a565 --- /dev/null +++ b/tests/test_continuation_gate.py @@ -0,0 +1,55 @@ +from pathlib import Path + +import pytest + +from src.continuation_gate import build_continuation_gate +from src.profile_context import CoverageCounts, ProfileContext + + +def _context(state: str) -> ProfileContext: + return ProfileContext( + profile_key="default", + profile_label="Default", + data_dir=Path("data"), + outputs_dir=Path("outputs"), + source_as_of="2026-06-26", + readiness_built_at="2026-06-07T03:00:43+00:00", + snapshot_identity="abc", + snapshot_identity_short="abc", + freshness_state=state, + freshness_message=f"Readiness is {state}.", + refresh_command="make readiness", + coverage=CoverageCounts(), + lane_source_dates=(), + snapshot_inputs=(), + ) + + +def test_stale_readiness_routes_to_no_write_preview(): + gate = build_continuation_gate(_context("stale")) + + assert gate.state == "inspection_only" + assert gate.next_safe_command == "make readiness-preview TOP_N=20" + assert gate.rebuild_command == "make readiness" + assert gate.suppress_execution is True + assert "broad refresh" in gate.stop_rule + + +def test_current_readiness_does_not_override_source_routing(): + gate = build_continuation_gate(_context("current")) + + assert gate.state == "current" + assert gate.next_safe_command == "" + assert gate.rebuild_command == "make readiness" + assert gate.suppress_execution is False + assert gate.stop_rule == "" + + +@pytest.mark.parametrize("state", ["mixed", "missing", "unexpected"]) +def test_incomplete_or_unknown_readiness_fails_closed_to_inspection(state: str): + gate = build_continuation_gate(_context(state)) + + assert gate.state == "inspection_required" + assert gate.next_safe_command == "make readiness-preview TOP_N=20" + assert gate.suppress_execution is True + assert "readiness-rebuild" in gate.stop_rule diff --git a/tests/test_dashboard_helpers.py b/tests/test_dashboard_helpers.py index 96683ed3..70d24a35 100644 --- a/tests/test_dashboard_helpers.py +++ b/tests/test_dashboard_helpers.py @@ -1,7 +1,9 @@ +from dataclasses import replace import json import os import re from pathlib import Path +from types import SimpleNamespace import src.dashboard as dashboard import src.data_health_generated_churn as generated_churn @@ -11,6 +13,10 @@ from src.research_change_monitor import ResearchChangeEvent from src.research_review_queue import ResearchReviewItem from src.public_home_workflow import public_home_current_data_coverage_cards, public_home_next_step_cards +from src.proof_readiness_reconciliation import ( + ProofReadinessReconciliationRow, + ProofReadinessReconciliationSummary, +) import pandas as pd @@ -145,6 +151,10 @@ def test_peer_read_through_cards_explain_reviewable_and_withheld_states(): "peer_ticker": "BETA", "peer_group": "cloud", "industry": "Cloud platforms", + "peer_role": "core_peer", + "relationship_rationale": "Comparable cloud platform exposure.", + "comparability_basis": "business model; customer mix; growth and margin profile", + "valuation_anchor_eligible": "yes", "source": "https://example.test/peer", "as_of_date": "2026-06-30", "peer_result": { @@ -166,6 +176,10 @@ def test_peer_read_through_cards_explain_reviewable_and_withheld_states(): assert cards[0]["title"] == "1 peer result ready for contextual review" assert "does not change" in cards[0]["body"] + assert "1 valuation anchor eligible" in cards[0]["body"] + assert frame.iloc[0]["Peer Role"] == "Core peer" + assert frame.iloc[0]["Comparability"] == "Reviewed comparable" + assert frame.iloc[0]["Valuation Anchor"] == "Eligible" assert frame.iloc[0]["Read-Through State"] == "Reviewable context" assert "recommend" not in str(cards).lower() @@ -404,8 +418,9 @@ def test_data_health_auto_refresh_status_cards_read_cached_preflight(tmp_path: P "SOURCE SETUP", "NEXT SCHEDULER STEP", ] - assert cards[2]["command"] == "make auto-refresh-runbook SCHEDULE=weekly" - assert "can run now: workflow evidence only; current source-proof queues are exhausted" in rendered + assert cards[2]["title"] == "make readiness-preview TOP_N=20" + assert cards[2]["command"] == "make readiness-preview TOP_N=20" + assert "can run now: inspection_required" in rendered assert "needs setup: fmp, alpha vantage, finnhub" in rendered assert "avoid repeating: fundamentals/share-count source ladder" in rendered assert "coverage_workflow_evidence" not in rendered @@ -483,7 +498,9 @@ def test_data_health_provider_setup_checklist_cards_use_checklist_without_secret assert "one safe smoke test" not in rendered assert "no broad coverage batch should run from setup alone" in rendered assert "provider setup only makes a source executable" in rendered - assert "do not retry fundamentals/share-count source ladder" in rendered + assert "do not retry broad_refresh, source_proof, readiness_rebuild" in rendered + assert "current gate says inspection_only" in rendered + assert "next: make readiness-preview top_n=20" in rendered assert "fundamentals_share_count_source_ladder" not in rendered assert "coverage_workflow_evidence" not in rendered assert "project-status -> provider setup -> reviewed one-ticker smoke command -> validate/preview" in rendered @@ -534,12 +551,12 @@ def test_data_health_provider_setup_checklist_cards_surface_cached_current_gate( cards = dashboard.data_health_provider_setup_checklist_cards(root=tmp_path) rendered = " ".join(str(value) for card in cards for value in card.values()).lower() - assert "can run now: workflow evidence only; current source-proof queues are exhausted" in rendered + assert "can run now: inspection_required" in rendered assert "needs setup: fmp, alpha_vantage, finnhub" in rendered assert "avoid repeating: fundamentals/share-count source ladder" in rendered assert "coverage_workflow_evidence" not in rendered assert "fundamentals_share_count_source_ladder" not in rendered - assert "wait for new provider data before repeating the source ladder" in rendered + assert "selected-profile readiness artifacts are missing" in rendered def test_data_health_provider_setup_first_answer_frame_summarizes_current_source_boundary(tmp_path, monkeypatch): @@ -577,7 +594,7 @@ def test_data_health_provider_setup_first_answer_frame_summarizes_current_source "What boundary stays true?", "What should I do next?", ] - assert "workflow evidence only; current source-proof queues are exhausted" in rendered + assert "inspection_required" in rendered assert "free public sources: usable now" in rendered assert "keyed free-tier fallbacks: configured: fmp free tier; missing: alpha vantage free tier, finnhub free tier" in rendered assert "metadata-only evidence: context only" in rendered @@ -587,6 +604,7 @@ def test_data_health_provider_setup_first_answer_frame_summarizes_current_source assert "alpha_vantage, finnhub" in rendered assert "keyed setup is not required for pilot/demo sharing" in rendered assert "fundamentals/share-count source ladder" in rendered + assert "broad_refresh, source_proof, readiness_rebuild" in rendered assert "fundamentals_share_count_source_ladder" not in rendered assert "make " not in rendered assert "one-ticker smoke stays in source setup details" in rendered @@ -2993,7 +3011,7 @@ def test_data_health_operator_flow_surfaces_auto_refresh_status_before_source_gu assert "Adds raw JSON under Sources & Gaps" not in source assert "Most users can leave this off" in source assert "Show reader tips" in source - assert 'if not public_demo_mode and selected_page != "Data Health":' in source + assert 'if operator_mode and selected_page != "Data Health":' in source assert "Show page tips" not in source assert "Adds extra explanation and review sections" in source assert "Most visitors can leave this off" in source @@ -3049,7 +3067,7 @@ def test_data_health_operator_flow_surfaces_auto_refresh_status_before_source_gu assert 'st.expander("Advanced pages"' not in source assert '"Open a research view"' in source assert '"Open a detailed page"' not in source - assert "Most visitors only need these paths" in source + assert "Choose the next research step" in source assert "Additional research views remain available" in source assert "Detailed research views remain available" not in source assert '"Page to review"' not in source @@ -3066,26 +3084,26 @@ def test_data_health_operator_flow_surfaces_auto_refresh_status_before_source_gu assert '"make status-check TOP_N=5\\nmake stock-report-md TICKER=NVDA\\nmake dashboard"' in source assert '"make status-check TOP_N=5\\nmake stock-report-md TICKER=NVDA\\nmake dashboard-smoke"' not in source assert "sidebar_quick_help_lines()" in source - assert "path_options = sidebar_path_options(initial_page)" in source + assert "path_options = workspace_path_options(initial_page, mode)" in source assert 'path_state_key = "dashboard-path-selection"' in source assert 'path_widget_key = f"{path_state_key}-{dashboard_page_slug(route_signature)}"' in source assert "key=path_widget_key" in source assert 'st.session_state["dashboard-route-signature"] = route_signature' not in source assert 'elif path_selection == PROOF_HISTORY_PATH_TITLE:\n selected_page = "Data Health"' not in source - assert "elif selected_page == PROOF_HISTORY_PATH_TITLE:" in source - assert "render_proof_history(public_mode=public_demo_mode)" in source + assert "elif content_page == PROOF_HISTORY_PATH_TITLE:" in source + assert "render_proof_history(public_mode=not operator_mode)" in source assert "render_public_shell_mode_styles()" in source assert "render_public_app_shell(selected_page)" in source assert "compact=True," in source assert "command_center_header_html(" in source assert "_header_readiness_summary()" in source - assert 'if public_demo_mode or selected_page != "Data Health":' in source - assert source.index("elif selected_page == PROOF_HISTORY_PATH_TITLE:") < source.index( - "elif selected_page == \"Universe Manager\":" + assert 'if public_demo_mode or operator_mode and selected_page != "Data Health":' in source + assert source.index("elif content_page == PROOF_HISTORY_PATH_TITLE:") < source.index( + "elif content_page == \"Universe Manager\":" ) - assert 'show_sidebar_operator_guides = not public_demo_mode and selected_page != "Data Health"' in source + assert 'show_sidebar_operator_guides = operator_mode and selected_page != "Data Health"' in source assert "if show_sidebar_operator_guides:" in source - assert 'if not public_demo_mode and selected_page == "Data Health":' in source + assert 'if operator_mode and selected_page == "Data Health":' in source assert '"Data Health operator."' in source assert "Use the lane buttons on the page. Copy-only commands stay inside evidence drawers." in source assert "Use the command center and lane buttons" not in source @@ -3288,7 +3306,7 @@ def test_data_health_default_view_prioritizes_fix_first_and_collapses_heavy_deta assert 'with st.expander("Queue outcome ledger summary", expanded=False)' not in source assert 'with st.expander("Readiness queue evidence", expanded=False)' not in source assert 'with st.expander("Data coverage proof queue detail", expanded=False)' not in source - assert "render_data_health(provider, project_status_payload, show_reason_details, public_mode=public_demo_mode)" in source + assert "render_data_health(provider, project_status_payload, show_reason_details, public_mode=not operator_mode)" in source assert 'render_section_header("Action Paths"' not in source assert 'st.expander("Optional context evidence drawer", expanded=False)' in source assert 'st.expander("Last-resort diagnostic context", expanded=False)' in source @@ -3398,13 +3416,30 @@ def test_loaded_single_stock_detail_tables_are_collapsed_after_workflow_fit(): assert 'st.dataframe(\n clean_display_frame(stock_report_key_value_frame(financials, financial_fields))' not in source +def test_company_workbench_research_record_authoring_is_research_only_and_below_the_journal_answer(): + source = Path("src/dashboard.py").read_text(encoding="utf-8") + render_start = source.index("def render_single_stock_report(") + render_end = source.index("def render_data_health(", render_start + 4) + render_source = source[render_start:render_end] + + assert "render_research_record_authoring" in render_source + assert render_source.index("research_thesis_journal_html") < render_source.index( + "render_research_record_authoring" + ) + assert render_source.index("render_research_record_authoring") < render_source.index( + "Advanced: thesis and evidence history" + ) + assert "if research_mode:" in render_source + assert "AuthoringPaths(" in render_source + + def test_public_single_stock_defers_secondary_rendering_until_detail_toggle(): source = Path("src/dashboard.py").read_text(encoding="utf-8") render_index = source.index("def render_single_stock_report(") next_function_index = source.index("\ndef render_data_health(", render_index) chunk = source[render_index:next_function_index] - public_answer_index = chunk.index("st.markdown(single_stock_public_summary_html(single_answer_frame)") + public_answer_index = chunk.index("render_single_stock_public_summary(", chunk.index("single_answer_frame")) detail_gate_index = chunk.index( "if public_mode and report_payload and not single_stock_detail_sections_visible(ticker):", public_answer_index, @@ -15834,6 +15869,140 @@ def test_proof_history_operator_console_keeps_outcomes_evidence_only(): assert "trading" not in html +def _proof_reconciliation_summary(*, conflict: bool = True) -> ProofReadinessReconciliationSummary: + state = "historical_supported_currently_blocked" if conflict else "current_supported_with_matching_proof" + row = ProofReadinessReconciliationRow( + ticker="ARCT", + lane="fundamentals", + current_field="fundamentals_ready", + current_ready=not conflict, + latest_batch_id="RB-ARCT", + latest_review_date="2026-06-26", + latest_outcome="auto_supported", + review_date_valid=True, + state=state, + reason="Historical proof conflicts with current readiness." if conflict else "Current proof matches readiness.", + proof_applicability="explicit_ticker_change", + current_blocker_code="current_canonical_row_missing" if conflict else "none", + current_blocker_fields=("free_cash_flow", "shares_outstanding", "revenue", "fcf_margin") if conflict else (), + current_blocker_detail=( + "No current canonical fundamentals row is present." if conflict else "No current blocker." + ), + next_safe_review=( + "Obtain and review a permitted source payload for the exact ticker before any import or readiness rebuild." + if conflict + else "No current blocker is reported for this lane." + ), + historical_payload_status="structured_payload_not_recorded", + historical_evidence_limit="Historical proof cannot distinguish the historical cause.", + ) + return ProofReadinessReconciliationSummary( + rows=(row,), + status_counts=((state, 1),), + conflict_counts_by_lane=(("fundamentals", 1),) if conflict else (), + proof_applicability_counts=(("explicit_ticker_change", 1),), + current_blocker_counts=(("current_canonical_row_missing" if conflict else "none", 1),), + input_status="ready", + input_message="Current inputs are available.", + ) + + +def test_proof_reconciliation_cards_warn_when_historical_support_is_currently_blocked(): + cards = dashboard.proof_readiness_reconciliation_cards( + _proof_reconciliation_summary(), + ticker="ARCT", + ) + rendered = " ".join(str(value) for card in cards for value in card.values()).lower() + + assert "historical support is not current readiness" in rendered + assert "arct" in rendered + assert "fundamentals" in rendered + assert "current saved readiness remains authoritative" in rendered + assert "current canonical row missing" in rendered + assert "observable current blocker" in rendered + assert "does not establish the historical cause" in rendered + assert "obtain and review a permitted source payload" in rendered + assert "make " not in rendered + assert all(card["command"] == "" for card in cards) + + +def test_proof_reconciliation_selected_card_does_not_infer_source_or_historical_cause(): + cards = dashboard.proof_readiness_reconciliation_cards( + _proof_reconciliation_summary(), + ticker="ARCT", + ) + selected = cards[1] + rendered = " ".join(str(value) for value in selected.values()).lower() + + assert "explicit ticker change" in rendered + assert "free cash flow" in rendered + assert "current canonical row missing" in rendered + assert "yfinance" not in rendered + assert "source rights changed" not in rendered + assert selected["command"] == "" + + +def test_proof_reconciliation_selected_card_excludes_other_ticker_lanes_blockers_and_review_text(): + summary = _proof_reconciliation_summary() + arct = summary.rows[0] + other = replace( + arct, + ticker="ARDX", + lane="peer_valuation_inputs", + current_blocker_code="ARDX_ONLY_BLOCKER", + current_blocker_fields=("ARDX_ONLY_FIELD",), + next_safe_review="ARDX_ONLY_REVIEW_TEXT", + ) + summary = replace( + summary, + rows=(arct, other), + status_counts=(("historical_supported_currently_blocked", 2),), + conflict_counts_by_lane=(("fundamentals", 1), ("peer_valuation_inputs", 1)), + current_blocker_counts=(("current_canonical_row_missing", 1), ("ARDX_ONLY_BLOCKER", 1)), + ) + + cards = dashboard.proof_readiness_reconciliation_cards(summary, ticker="ARCT") + selected = " ".join(str(value) for value in cards[1].values()) + + assert "ARCT" in selected + assert "fundamentals" in selected + assert "ARDX" not in selected + assert "peer valuation inputs" not in selected.lower() + assert "ARDX_ONLY_BLOCKER" not in selected + assert "ARDX_ONLY_FIELD" not in selected + assert "ARDX_ONLY_REVIEW_TEXT" not in selected + assert cards[1]["command"] == "" + + +def test_proof_reconciliation_cards_keep_no_conflict_state_evidence_limited(): + cards = dashboard.proof_readiness_reconciliation_cards( + _proof_reconciliation_summary(conflict=False), + ticker="ARCT", + ) + rendered = " ".join(str(value) for card in cards for value in card.values()).lower() + + assert "no historical-support/current-readiness conflict" in rendered + assert "does not prove source rights, field scope, provenance, or payload truth" in rendered + assert "make " not in rendered + + +def test_proof_history_renders_reconciliation_before_raw_ledger_details(): + source = Path("src/dashboard.py").read_text(encoding="utf-8") + render_index = source.index("def render_proof_history(") + next_function_index = source.index("\ndef ", render_index + 1) + proof_history_chunk = source[render_index:next_function_index] + + load_index = proof_history_chunk.index("load_proof_readiness_reconciliation(") + render_index = proof_history_chunk.index("proof_readiness_reconciliation_cards(", load_index) + ledger_index = proof_history_chunk.index('st.expander("Advanced: proof ledger details", expanded=False)') + + assert load_index < render_index < ledger_index + assert "render_research_desk" not in proof_history_chunk + assert "render_discover" not in proof_history_chunk + assert "render_company_workbench" not in proof_history_chunk + assert "render_monitor" not in proof_history_chunk + + def test_data_health_proof_history_lane_answers_before_detail_toggle(): source = Path("src/dashboard.py").read_text(encoding="utf-8") @@ -17274,6 +17443,9 @@ def test_stock_report_next_step_cards_route_to_fundamentals_then_peers_then_revi ] ) cards = dashboard.stock_report_next_step_cards(payload, coverage, {"peer_dataset_present": False}) + assert cards[0]["kicker"] == "RESEARCH PRIORITY" + assert cards[0]["state"] == "wait_for_evidence" + assert all(card["kicker"] != "NEXT STEP" for card in cards) assert cards[0]["title"] == "Stage fundamentals" assert cards[0]["command"] == "make focus-fundamentals TICKER=NVDA" @@ -17290,6 +17462,9 @@ def test_stock_report_next_step_cards_route_to_fundamentals_then_peers_then_revi ] ) cards = dashboard.stock_report_next_step_cards(payload, coverage, {"peer_dataset_present": False}) + assert cards[0]["kicker"] == "RESEARCH PRIORITY" + assert cards[0]["state"] == "wait_for_evidence" + assert all(card["kicker"] != "NEXT STEP" for card in cards) assert cards[0]["title"] == "Review fundamentals import file" assert cards[0]["command"] == "make imports-validate" assert "fundamentals import file" in cards[0]["body"].lower() @@ -17303,6 +17478,9 @@ def test_stock_report_next_step_cards_route_to_fundamentals_then_peers_then_revi ] ) cards = dashboard.stock_report_next_step_cards(payload, coverage, {"peer_dataset_present": False}) + assert cards[0]["kicker"] == "RESEARCH PRIORITY" + assert cards[0]["state"] == "wait_for_evidence" + assert all(card["kicker"] != "NEXT STEP" for card in cards) assert cards[0]["title"] == "Add peer mappings" assert cards[0]["command"] == "make focus-peers TICKER=NVDA" @@ -17320,6 +17498,9 @@ def test_stock_report_next_step_cards_route_to_fundamentals_then_peers_then_revi ] ) cards = dashboard.stock_report_next_step_cards(payload, coverage, {"peer_dataset_present": False}) + assert cards[0]["kicker"] == "RESEARCH PRIORITY" + assert cards[0]["state"] == "wait_for_evidence" + assert all(card["kicker"] != "NEXT STEP" for card in cards) assert cards[0]["title"] == "Review peer import file" assert cards[0]["command"] == "make imports-validate" assert "peer mapping import file" in cards[0]["body"].lower() @@ -17327,6 +17508,9 @@ def test_stock_report_next_step_cards_route_to_fundamentals_then_peers_then_revi payload["valuation_readiness"]["peer_ready"] = True cards = dashboard.stock_report_next_step_cards(payload, coverage, {"peer_dataset_present": True}) + assert cards[0]["kicker"] == "RESEARCH PRIORITY" + assert cards[0]["state"] == "review_now" + assert all(card["kicker"] != "NEXT STEP" for card in cards) assert cards[0]["title"] == "Review full report" assert cards[0]["command"] == "make stock-report-md TICKER=NVDA" assert cards[-1]["kicker"] == "OPTIONAL CONTEXT" @@ -20768,7 +20952,7 @@ def test_data_health_coverage_summary_answers_each_lane_without_recommendations( "Metadata can confirm scope and filing context, but it is not price, fundamentals, valuation, earnings, or estimates.", "Some tickers still lack enough verified local price history.", "Missing trusted fundamentals, shares, or DCF inputs keep valuation withheld.", - "Candidate peers are context only; trusted peers need source-backed mappings plus trusted peer inputs.", + "Candidate and legacy-unreviewed peers stay context-only and cannot enter peer medians.", "Trusted local earnings rows have not been reviewed for most tickers.", "Trusted local analyst-estimate rows have not been reviewed for most tickers.", "Screenshots and proof artifacts do not prove current market data.", @@ -20777,7 +20961,7 @@ def test_data_health_coverage_summary_answers_each_lane_without_recommendations( "SEC submissions metadata, local universe rows, or reviewed ticker metadata prove identity only.", "Reviewed price rows or a capped dry-run refresh prove price coverage.", "Validated fundamentals imports, preview, rejected-row review, apply decision, and rebuilt readiness.", - "Reviewed trusted peer mapping rows plus mapped-peer price, fundamentals, and valuation inputs.", + "Reviewed peer role, rationale, comparability, anchor decision, and required mapped-peer inputs.", "Trusted earnings CSV rows that pass validation, preview, and optional-context readiness.", "Trusted analyst-estimate CSV rows that pass validation, preview, and optional-context readiness.", "Current app screenshot evidence plus public-check; data freshness still comes from readiness commands.", @@ -20795,7 +20979,7 @@ def test_data_health_coverage_summary_answers_each_lane_without_recommendations( "Stop before analysis if only metadata evidence exists.", "Stop if price rows are missing, too short, stale, or unreviewed.", "Stop if any required DCF input is missing or not source-backed.", - "Stop if candidate peers are promoted to trusted peers or mapped-peer inputs are missing.", + "Stop if candidate or legacy-unreviewed peers enter peer medians or required inputs are missing.", "Stop if rows are empty, stale, or not trusted local evidence.", "Stop if estimates are absent, scraped without review, or treated as a recommendation.", "Stop if screenshots are used as proof of data freshness or source-input coverage.", @@ -20808,11 +20992,10 @@ def test_data_health_coverage_summary_answers_each_lane_without_recommendations( assert "next proof:" in rendered assert "stop:" in rendered assert "candidate peers are context only" not in rendered - assert "reviewed trusted peer mapping rows" in rendered - assert "do not promote candidate peers to trusted peers" in rendered - assert "Candidate peers are context only" in " ".join(map(str, frame["why_blocked_or_limited"])) - assert "Reviewed trusted peer mapping rows" in " ".join(map(str, frame["proof_to_unlock"])) - assert "Stop if candidate peers are promoted to trusted peers" in " ".join(map(str, frame["stop_rule"])) + assert "reviewed peer role" in rendered + assert "Candidate and legacy-unreviewed peers" in " ".join(map(str, frame["why_blocked_or_limited"])) + assert "Reviewed peer role" in " ".join(map(str, frame["proof_to_unlock"])) + assert "Stop if candidate or legacy-unreviewed peers" in " ".join(map(str, frame["stop_rule"])) assert "do not use until trusted earnings rows exist" in rendered assert "not data freshness" in rendered assert "buy" not in rendered @@ -22812,7 +22995,7 @@ def test_first_peer_mapping_unlock_frame_prioritizes_source_backed_mapping_workf assert frame.iloc[1]["Copy Command"] == "make templates" assert frame.iloc[1]["Trusted Input"] == "data/imports/peers.csv" assert "sector or industry fallback is not trusted peer data" in rendered - assert "schema guide for data/imports/peers.csv: ticker, peer_ticker, peer_group, sector, industry, source, as_of_date" in rendered + assert "schema guide for data/imports/peers.csv: ticker, peer_ticker, peer_group, sector, industry, peer_role, relationship_rationale, comparability_basis, valuation_anchor_eligible, source, as_of_date" in rendered assert "make imports-validate import_tickers= && make imports-preview import_tickers= && make imports-apply import_tickers=" in rendered assert "make readiness && make peer-mapping-queue top_n=25" in rendered assert "peer readiness should improve only after mapped rows pass validation" in rendered @@ -22952,7 +23135,7 @@ def test_peer_unlock_operator_cards_group_priorities_scope_and_next_input(): assert "dcf-ready but peer-blocked: 2" in rendered assert "meta" in rendered assert "data/imports/peers.csv" in rendered - assert "schema fields: ticker, peer_ticker, peer_group, sector, industry, source, as_of_date" in rendered + assert "schema fields: ticker, peer_ticker, peer_group, sector, industry, peer_role, relationship_rationale, comparability_basis, valuation_anchor_eligible, source, as_of_date" in rendered assert "make imports-preview" in rendered assert "dcf ready peer mapping" in rendered assert "peer trend can use mapped peer price history" in rendered @@ -23126,7 +23309,7 @@ def test_peer_input_ladder_prioritizes_active_dcf_ready_rows_and_separates_trend assert "peer valuation remains locked until peer fundamentals and valuation inputs pass readiness" in rendered assert "peer-relative premium/discount, peer valuation comparison, and peer dcf comparison stay locked" in rendered assert "do not skip from mappings to peer valuation" in rendered - assert "schema guide: ticker, peer_ticker, peer_group, sector, industry, source, as_of_date" in rendered + assert "schema guide: ticker, peer_ticker, peer_group, sector, industry, peer_role, relationship_rationale, comparability_basis, valuation_anchor_eligible, source, as_of_date" in rendered assert "sector or industry fallback can guide research context only; it is not trusted peer valuation data" in rendered assert "make focus-peers ticker=a" in rendered assert "make focus-peers ticker=meta" in rendered @@ -23652,7 +23835,7 @@ def test_data_health_peer_unlock_frame_explains_source_backed_peer_requirements( assert "do not show peer-relative valuation, peer premium/discount, or peer dcf comparison" in rendered assert "make templates" in rendered assert "add source-backed peer rows in `data/imports/peers.csv`" in rendered - assert "schema guide: ticker, peer_ticker, peer_group, sector, industry, source, as_of_date" in rendered + assert "schema guide: ticker, peer_ticker, peer_group, sector, industry, peer_role, relationship_rationale, comparability_basis, valuation_anchor_eligible, source, as_of_date" in rendered assert "make imports-validate" in rendered assert "make imports-preview" in rendered assert "make imports-apply" in rendered @@ -23705,7 +23888,7 @@ def test_data_health_peer_unlock_cards_summarize_next_row_before_table(): assert "valuation: peer valuation blocked" in rendered assert "trusted peer requirement: peer mapping, peer fundamentals" in rendered assert "boundary: do not show peer-relative valuation, peer premium/discount, or peer dcf comparison" in rendered - assert "schema guide: ticker, peer_ticker, peer_group, sector, industry, source, as_of_date" in rendered + assert "schema guide: ticker, peer_ticker, peer_group, sector, industry, peer_role, relationship_rationale, comparability_basis, valuation_anchor_eligible, source, as_of_date" in rendered assert "next proof: add source-backed peer rows, validate and preview them, apply only reviewed rows" in rendered assert "validation proof: add source-backed peer rows, validate and preview them, apply only reviewed rows" in rendered assert "make imports-validate" in rendered @@ -23788,7 +23971,10 @@ def test_data_health_peer_source_review_cards_put_source_proof_before_import(tmp assert frame.iloc[0]["Completion Status"] == "needs field fills" assert frame.iloc[0]["Import Preview Status"] == "needs field fills" assert "proposed_peer_ticker" in frame.iloc[0]["Missing Fields"] - assert frame.iloc[0]["CSV Header"] == "ticker,peer_ticker,peer_group,sector,industry,source,as_of_date" + assert frame.iloc[0]["CSV Header"] == ( + "ticker,peer_ticker,peer_group,sector,industry,peer_role,relationship_rationale," + "comparability_basis,valuation_anchor_eligible,source,as_of_date" + ) assert frame.iloc[0]["CSV Row"] == "blocked until completion-ready" assert frame.iloc[0]["Candidate Context State"] in {"still_blocked", "not_loaded"} assert frame.iloc[0]["Candidate Context Boundary"] @@ -23796,13 +23982,13 @@ def test_data_health_peer_source_review_cards_put_source_proof_before_import(tmp assert "Do not edit or apply data/imports/peers.csv until the source-review row is completion-ready." in frame.iloc[0]["Apply Boundary"] assert "make readiness" in frame.iloc[0]["Post-Apply Proof"] assert frame.iloc[0]["Ticker"] == "META" - assert "proposed_peer_ticker, peer_group, source, as_of_date" in rendered + assert "proposed_peer_ticker, peer_group, peer_role, source, as_of_date" in rendered assert "fill proposed_peer_ticker" in rendered assert "source does not name the peer relationship" in rendered assert "classification similarity alone stays fallback context" in rendered assert "candidate context" in rendered assert "does not infer peer relationships or unlock peer valuation" in rendered - assert "header: ticker,peer_ticker,peer_group,sector,industry,source,as_of_date" in rendered + assert "header: ticker,peer_ticker,peer_group,sector,industry,peer_role,relationship_rationale,comparability_basis,valuation_anchor_eligible,source,as_of_date" in rendered assert "row: blocked until completion-ready" in rendered assert "no hand-edit shortcut" in rendered assert "check duplicates, then prepare proof" in rendered @@ -26474,6 +26660,29 @@ def test_single_stock_public_summary_keeps_the_data_health_handoff_visible(): assert "Stop if peer proof is missing" in rendered +def test_single_stock_research_summary_keeps_selected_ticker_plain_text_fallback(): + frame = pd.DataFrame( + [ + { + "Ticker": "AVGO", + "Use Now": "Supported evidence is available.", + "Still Blocked": "Unsupported evidence remains withheld.", + "Context Only": "Research context only.", + "Next Safe Action": "Open Data Health.", + "Review Boundary": "Keep the review research-only.", + } + ] + ) + + rendered = dashboard.single_stock_public_summary_html(frame, target_mode="research") + unstyled_text = re.sub(r"<[^>]+>", "", rendered) + + assert "Selected ticker AVGO" in unstyled_text + assert "Selected tickerAVGO" not in unstyled_text + assert "public-ticker-summary research" in rendered + assert "?mode=research&page=data-health&ticker=AVGO" in rendered + + def test_public_single_stock_optional_context_uses_a_visitor_boundary_not_an_operator_worklist(): frame = dashboard.single_stock_one_answer_frame( { @@ -26549,7 +26758,8 @@ def test_stock_report_provenance_cards_summarize_visible_method_and_source_bound def test_single_stock_public_report_renders_one_answer_summary_before_advanced_tables(): source = Path("src/dashboard.py").read_text(encoding="utf-8") - public_answer_index = source.index("st.markdown(single_stock_public_summary_html(single_answer_frame)") + report_index = source.index("def render_single_stock_report(") + public_answer_index = source.index("render_single_stock_public_summary(", source.index("single_answer_frame", report_index)) advanced_index = source.index('st.expander("Advanced: detailed report sections", expanded=False)', public_answer_index) assert public_answer_index < advanced_index @@ -29081,13 +29291,13 @@ def test_dashboard_tab_titles_and_navigation_labels_stay_consistent(): assert "Monthly Picks page" not in navigation -def test_dashboard_public_demo_mode_query_defaults_to_visitor_view(): +def test_dashboard_query_defaults_to_personal_research_and_preserves_explicit_modes(): assert dashboard.dashboard_query_value_present("data-health") assert dashboard.dashboard_query_value_present(["operator"]) assert not dashboard.dashboard_query_value_present(None) assert not dashboard.dashboard_query_value_present("") assert not dashboard.dashboard_query_value_present([""]) - assert dashboard.dashboard_mode_from_query(None) == dashboard.PUBLIC_DEMO_MODE + assert dashboard.dashboard_mode_from_query(None) == dashboard.RESEARCH_MODE assert dashboard.dashboard_mode_from_query("public") == dashboard.PUBLIC_DEMO_MODE assert dashboard.dashboard_mode_from_query("demo") == dashboard.PUBLIC_DEMO_MODE assert dashboard.dashboard_mode_from_query(["visitor"]) == dashboard.PUBLIC_DEMO_MODE @@ -29143,8 +29353,14 @@ def test_data_health_freshness_status_marks_current_readiness_stale_when_status_ outputs_dir = tmp_path / "outputs" reports_dir.mkdir(parents=True) outputs_dir.mkdir() - (reports_dir / "ticker_readiness_report.csv").write_text("ticker\nNVDA\n", encoding="utf-8") - (reports_dir / "feature_readiness_summary.csv").write_text("metric\nprice\n", encoding="utf-8") + (reports_dir / "ticker_readiness_report.csv").write_text( + "ticker,generated_at\nNVDA,2026-01-02T00:00:00+00:00\n", + encoding="utf-8", + ) + (reports_dir / "feature_readiness_summary.csv").write_text( + "metric,generated_at\nprice,2026-01-02T00:00:00+00:00\n", + encoding="utf-8", + ) (outputs_dir / dashboard.PROJECT_STATUS_NEXT_STEPS_CSV).write_text("Step\nReview\n", encoding="utf-8") source = data_dir / "prices.csv" source.write_text("ticker,date,close\nNVDA,2026-01-01,1\n", encoding="utf-8") @@ -29165,32 +29381,32 @@ def test_data_health_freshness_status_marks_current_readiness_stale_when_status_ def test_dashboard_public_mode_hides_operator_sidebar_sections_by_default(): source = Path("src/dashboard.py").read_text(encoding="utf-8") - assert '"Public visitor mode"' in source + assert '"Workspace"' in source assert 'page_query_value = st.query_params.get("page")' in source assert 'mode_query_value = st.query_params.get("mode")' in source assert "has_explicit_page_query = dashboard_query_value_present(page_query_value)" in source assert "has_explicit_mode_query = dashboard_query_value_present(mode_query_value)" in source - assert "if has_explicit_mode_query:\n public_demo_mode = initial_mode == PUBLIC_DEMO_MODE" in source + assert "if has_explicit_mode_query:\n mode_selection = initial_mode" in source assert "selected_page_from_route_rail(" in source assert "if has_explicit_page_query:\n selected_page = initial_page" not in source - assert "public_demo_mode = st.toggle" in source - assert "path_options = sidebar_path_options(initial_page)" in source + assert "mode_selection = st.radio" in source + assert "path_options = workspace_path_options(initial_page, mode)" in source assert 'path_state_key = "dashboard-path-selection"' in source assert 'path_widget_key = f"{path_state_key}-{dashboard_page_slug(route_signature)}"' in source assert "key=path_widget_key" in source assert "route_signature = f\"{mode}:{initial_page}\"" in source assert "show_reason_details = False" in source - assert 'if not public_demo_mode and selected_page != "Data Health":' in source + assert 'if operator_mode and selected_page != "Data Health":' in source assert 'st.expander("Research view switcher"' not in source assert 'st.expander("Operator run commands"' not in source assert 'if show_sidebar_operator_guides:\n with st.expander("Advanced: operator tools"' in source - assert 'show_sidebar_operator_guides = not public_demo_mode and selected_page != "Data Health"' in source + assert 'show_sidebar_operator_guides = operator_mode and selected_page != "Data Health"' in source assert "Operator mode restores detailed boards; Data Health keeps commands inside evidence drawers." in source assert '"Data Health operator."' in source - assert 'if selected_page == "Single-Stock Report" and not public_demo_mode:' in source + assert 'if selected_page == "Single-Stock Report" and operator_mode:' in source assert "render_home_page(\n catalog," in source assert "project_status_payload=project_status_payload" in source - assert "render_data_health(provider, project_status_payload, show_reason_details, public_mode=public_demo_mode)" in source + assert "render_data_health(provider, project_status_payload, show_reason_details, public_mode=not operator_mode)" in source assert "data_health_freshness_status(BASE_DIR)" in source assert "dashboard_generated_artifact_stale_warning(BASE_DIR)" in source assert '"Generated status may be stale"' in source @@ -29200,17 +29416,17 @@ def test_public_pages_use_compact_shell_before_page_content(): source = Path("src/dashboard.py").read_text(encoding="utf-8") assert "def dashboard_output_frames_for_page(" in source - assert "output_frames = dashboard_output_frames_for_page(selected_page)" in source + assert "output_frames = dashboard_output_frames_for_page(content_page)" in source initial_catalog_index = source.index("catalog = LocalDataCatalog(BASE_DIR, data_dir=DATA_DIR, outputs_dir=OUTPUTS_DIR)") selected_page_index = source.index("selected_page = selected_page_from_route_rail(") - output_frames_index = source.index("output_frames = dashboard_output_frames_for_page(selected_page)") + output_frames_index = source.index("output_frames = dashboard_output_frames_for_page(content_page)") assert initial_catalog_index < selected_page_index < output_frames_index assert "if selected_page in {\"Data Health\", PROOF_HISTORY_PATH_TITLE}:" in source assert "return {}" in source[source.index("def dashboard_output_frames_for_page(") : output_frames_index] shell_style_index = source.index("render_public_shell_mode_styles()") shell_index = source.index("render_public_app_shell(selected_page)", shell_style_index) - dispatch_index = source.index('if selected_page == "Home":', shell_index) + dispatch_index = source.index('if research_mode and selected_page == "Research Desk":', shell_index) operator_header_index = source.index("render_app_header(\n catalog,\n output_frames,", shell_index) assert shell_style_index < shell_index < operator_header_index < dispatch_index assert "public-app-shell" in source @@ -29304,6 +29520,26 @@ def test_public_app_shell_has_compact_mobile_rules(): assert "flex-wrap: wrap" in mobile_chunk +def test_public_single_stock_phone_keeps_evidence_handoff_in_first_view(): + source = Path("src/dashboard.py").read_text(encoding="utf-8") + shell_start = source.index("def render_public_shell_mode_styles") + mobile_start = source.index("@media (max-width: 640px)", shell_start) + mobile_end = source.index("", mobile_start) + mobile_css = source[mobile_start:mobile_end] + + summary_start = mobile_css.index(".public-ticker-summary {") + summary_end = mobile_css.index("}", summary_start) + summary_rule = mobile_css[summary_start:summary_end] + action_start = mobile_css.index(".public-ticker-action .public-primary-action {") + action_end = mobile_css.index("}", action_start) + action_rule = mobile_css[action_start:action_end] + + assert "grid-template-columns: 1fr;" in summary_rule + assert "gap: 0.25rem;" in summary_rule + assert "padding: 0.125rem 0 0.5rem;" in summary_rule + assert "order: -1;" in action_rule + + def test_public_workflow_controls_reserve_accessible_touch_targets(): source = Path("src/dashboard.py").read_text(encoding="utf-8") @@ -29446,29 +29682,30 @@ def test_public_workflow_skip_link_bypasses_the_shared_public_shell(): assert "clip-path: inset(50%)" in source assert ".public-skip-link:focus" in source assert "clip-path: none" in source - assert 'return "?" + urlencode(params) + "#public-page-answer"' in source + assert 'return "#public-page-answer"' in source assert "id='public-page-answer'" in source assert "tabindex='-1'" in source - output_frames_index = source.index("output_frames = dashboard_output_frames_for_page(selected_page)") + output_frames_index = source.index("output_frames = dashboard_output_frames_for_page(content_page)") public_mode_index = source.index("if public_demo_mode:", output_frames_index) public_shell_style_index = source.index("render_public_shell_mode_styles()", public_mode_index) skip_link_index = source.index("render_public_workflow_skip_link(selected_page, st.query_params)", public_shell_style_index) shell_index = source.index("render_public_app_shell(selected_page)", skip_link_index) skip_target_index = source.index("render_public_workflow_skip_target()", shell_index) - dispatch_index = source.index('if selected_page == "Home":', skip_target_index) + dispatch_index = source.index('if research_mode and selected_page == "Research Desk":', skip_target_index) assert public_mode_index < public_shell_style_index < skip_link_index < shell_index < skip_target_index < dispatch_index skip_href = dashboard.public_workflow_skip_href( "Single-Stock Report", {"mode": "public", "page": "single-stock-report", "ticker": "NVDA", "open": "1"}, ) - assert skip_href == "?mode=public&page=single-stock-report&ticker=NVDA&open=1#public-page-answer" + assert skip_href == "#public-page-answer" html = dashboard.public_workflow_skip_link_html(skip_href) assert "Skip to page answer" in html - assert "href='?mode=public&page=single-stock-report&ticker=NVDA&open=1#public-page-answer'" in html + assert "href='#public-page-answer'" in html assert "aria-label='Skip to page answer'" in html + assert "target='_self'" in html target_html = dashboard.public_workflow_skip_target_html() assert "id='public-page-answer'" in target_html @@ -29476,6 +29713,45 @@ def test_public_workflow_skip_link_bypasses_the_shared_public_shell(): assert "Page answer begins" in target_html +def test_framework_help_and_dataframe_controls_reserve_minimum_touch_targets(): + source = Path("src/dashboard.py").read_text(encoding="utf-8") + + assert '[data-testid="stTooltipHoverTarget"] button,' in source + assert '[data-testid="stDataFrame"] button {' in source + assert "min-width: 24px !important;" in source + assert "min-height: 24px !important;" in source + + research_styles = source.index("def render_research_workspace_styles") + research_mobile = source.index("@media (max-width: 640px)", research_styles) + action_start = source.index( + ".public-ticker-summary.research .public-primary-action {", + research_mobile, + ) + action_end = source.index("}", action_start) + assert "min-height: 24px;" in source[action_start:action_end] + + +def test_public_page_answer_precedes_shared_advanced_evidence(): + source = Path("src/dashboard.py").read_text(encoding="utf-8") + + main_index = source.index("def main()") + output_frames_index = source.index("output_frames = dashboard_output_frames_for_page(content_page)", main_index) + public_mode_index = source.index("if public_demo_mode:", output_frames_index) + public_profile_index = source.index( + "render_profile_trust_strip(profile_context, compact=True, include_advanced=False)", + public_mode_index, + ) + skip_target_index = source.index("render_public_workflow_skip_target()", public_profile_index) + dispatch_index = source.index('if research_mode and selected_page == "Research Desk":', skip_target_index) + profile_details_index = source.index("render_profile_trust_details(profile_context)", dispatch_index) + change_details_index = source.index( + "render_research_change_route_summary(", + profile_details_index, + ) + + assert public_profile_index < skip_target_index < dispatch_index < profile_details_index < change_details_index + + def test_operator_workflow_skip_link_has_a_main_content_target(): source = Path("src/dashboard.py").read_text(encoding="utf-8") @@ -29484,7 +29760,7 @@ def test_operator_workflow_skip_link_has_a_main_content_target(): {"mode": "operator", "page": "data-health", "lane": "fundamentals", "drawer": "queue"}, mode="operator", ) - assert operator_href == "?mode=operator&page=data-health&lane=fundamentals&drawer=queue#public-page-answer" + assert operator_href == "#public-page-answer" main_index = source.index("def main()") sidebar_index = source.index("with st.sidebar:", main_index) @@ -29492,11 +29768,11 @@ def test_operator_workflow_skip_link_has_a_main_content_target(): "render_public_workflow_skip_link(initial_page, st.query_params, mode=OPERATOR_DEMO_MODE)", sidebar_index, ) - output_frames_index = source.index("output_frames = dashboard_output_frames_for_page(selected_page)", operator_link_index) + output_frames_index = source.index("output_frames = dashboard_output_frames_for_page(content_page)", operator_link_index) operator_branch_index = source.index("else:", output_frames_index) app_header_index = source.index("render_app_header(", operator_branch_index) skip_target_index = source.index("render_public_workflow_skip_target()", app_header_index) - dispatch_index = source.index('if selected_page == "Home":', skip_target_index) + dispatch_index = source.index('if research_mode and selected_page == "Research Desk":', skip_target_index) assert sidebar_index < operator_link_index < output_frames_index < operator_branch_index < app_header_index < skip_target_index < dispatch_index @@ -29565,16 +29841,16 @@ def test_public_data_health_bootstrap_clears_before_the_shared_public_shell(): source = Path("src/dashboard.py").read_text(encoding="utf-8") assert "def render_public_route_bootstrap(" in source - assert "bootstrap_placeholder = render_public_route_bootstrap(initial_page, initial_mode)" in source - bootstrap_index = source.index("bootstrap_placeholder = render_public_route_bootstrap(initial_page, initial_mode)") + assert "render_public_route_bootstrap(initial_page, initial_mode)" in source + bootstrap_index = source.index("bootstrap_placeholder = (") sidebar_index = source.index("with st.sidebar:", bootstrap_index) public_mode_index = source.index("if public_demo_mode:", sidebar_index) clear_index = source.index("if bootstrap_placeholder is not None:", public_mode_index) shell_index = source.index("render_public_app_shell(selected_page)", clear_index) - data_health_branch_index = source.index('elif selected_page == "Data Health":', shell_index) - data_health_render_index = source.index("render_data_health(provider, project_status_payload, show_reason_details, public_mode=public_demo_mode)") - proof_history_branch_index = source.index("elif selected_page == PROOF_HISTORY_PATH_TITLE:", data_health_render_index) - proof_render_index = source.index("render_proof_history(public_mode=public_demo_mode)", proof_history_branch_index) + data_health_branch_index = source.index('elif content_page == "Data Health":', shell_index) + data_health_render_index = source.index("render_data_health(provider, project_status_payload, show_reason_details, public_mode=not operator_mode)") + proof_history_branch_index = source.index("elif content_page == PROOF_HISTORY_PATH_TITLE:", data_health_render_index) + proof_render_index = source.index("render_proof_history(public_mode=not operator_mode)", proof_history_branch_index) assert bootstrap_index < sidebar_index < public_mode_index < clear_index < shell_index < data_health_branch_index < data_health_render_index assert "bootstrap_placeholder = None" in source[clear_index:shell_index] assert data_health_render_index < proof_history_branch_index < proof_render_index @@ -29593,10 +29869,10 @@ def test_public_data_health_bootstrap_clears_before_the_shared_public_shell(): def test_public_route_bootstrap_clears_before_the_actual_shell_to_avoid_duplicate_public_chrome(): source = Path("src/dashboard.py").read_text(encoding="utf-8") - public_mode_index = source.index("if public_demo_mode:", source.index("output_frames = dashboard_output_frames_for_page(selected_page)")) + public_mode_index = source.index("if public_demo_mode:", source.index("output_frames = dashboard_output_frames_for_page(content_page)")) clear_index = source.index("if bootstrap_placeholder is not None:", public_mode_index) shell_index = source.index("render_public_app_shell(selected_page)", clear_index) - dispatch_index = source.index('if selected_page == "Home":', shell_index) + dispatch_index = source.index('if research_mode and selected_page == "Research Desk":', shell_index) assert public_mode_index < clear_index < shell_index < dispatch_index assert "bootstrap_placeholder.empty()" in source[clear_index:shell_index] @@ -29739,7 +30015,7 @@ def test_advanced_pages_use_one_readiness_boundary_instead_of_a_repeated_generic def test_generic_operator_research_pages_keep_only_primary_summary_cards_before_the_chart_and_table(): source = Path("src/dashboard.py").read_text(encoding="utf-8") - render_index = source.index("def render_output_tab(") + render_index = source.index("def _render_legacy_output_tab(") next_function_index = source.index("\ndef ", render_index + 1) output_chunk = source[render_index:next_function_index] primary_summary_index = output_chunk.index("render_signal_cards(output_tab_summary_cards(title, frame)[:3])") @@ -29754,7 +30030,7 @@ def test_generic_operator_research_pages_keep_only_primary_summary_cards_before_ def test_specialized_operator_pages_use_their_dedicated_readiness_answer_before_generic_summary_cards(): source = Path("src/dashboard.py").read_text(encoding="utf-8") - render_index = source.index("def render_output_tab(") + render_index = source.index("def _render_legacy_output_tab(") next_function_index = source.index("\ndef ", render_index + 1) output_chunk = source[render_index:next_function_index] special_case_index = output_chunk.index('if title == "Momentum Leaders":') @@ -29779,7 +30055,7 @@ def test_final_watchlist_keeps_the_decision_reading_ladder_in_advanced_details() def test_monthly_picks_collapses_repeated_method_and_quality_cards_after_the_first_answer(): source = Path("src/dashboard.py").read_text(encoding="utf-8") - render_index = source.index("def render_monthly_picks(") + render_index = source.index("def _render_monthly_picks_legacy_output(") next_function_index = source.index("\ndef ", render_index + 1) monthly_chunk = source[render_index:next_function_index] primary_cards_index = monthly_chunk.index("monthly_primary_cards") @@ -29975,18 +30251,100 @@ def test_direct_public_single_stock_route_renders_loading_contract_before_provid assert loading_placeholder_index < loading_contract_index < coverage_lookup_index < clear_loading_index -def test_direct_public_single_stock_route_renders_fast_saved_answer_before_report_build(): +def test_direct_public_single_stock_route_renders_fast_summary_before_loading_and_provider_work(): source = Path("src/dashboard.py").read_text(encoding="utf-8") render_index = source.index("def render_single_stock_report(") next_function_index = source.index("\ndef render_data_health(", render_index) report_chunk = source[render_index:next_function_index] fast_snapshot_index = report_chunk.index("fast_snapshot = single_stock_fast_readiness_snapshot(ticker)") - fast_cards_index = report_chunk.index("render_signal_cards(single_stock_quick_read_cards(fast_snapshot)", fast_snapshot_index) - loading_placeholder_index = report_chunk.index("single_stock_loading_placeholder = st.empty()", fast_cards_index) - open_report_index = report_chunk.index("open_selected_report()", loading_placeholder_index) + fast_frame_index = report_chunk.index("fast_answer_frame = single_stock_one_answer_frame(fast_snapshot)", fast_snapshot_index) + fast_summary_index = report_chunk.index("render_single_stock_public_summary(", fast_frame_index) + loading_placeholder_index = report_chunk.index("single_stock_loading_placeholder = st.empty()", fast_summary_index) + coverage_lookup_index = report_chunk.index("coverage = pd.DataFrame(provider.get_ticker_dataset_coverage(ticker))") + direct_open_index = report_chunk.index("if query_open_review and not report_payload:", coverage_lookup_index) + open_report_index = report_chunk.index("open_selected_report()", direct_open_index) + + assert ( + fast_snapshot_index + < fast_frame_index + < fast_summary_index + < loading_placeholder_index + < coverage_lookup_index + < direct_open_index + < open_report_index + ) + assert "render_signal_cards(single_stock_quick_read_cards(fast_snapshot)" not in report_chunk + assert "research_mode=research_mode" in report_chunk[fast_frame_index:loading_placeholder_index] + assert "selected_answer_target=selected_answer_target" in report_chunk[fast_frame_index:loading_placeholder_index] + + +def test_single_stock_public_summary_uses_selected_target_when_supplied(monkeypatch): + calls = [] + target = SimpleNamespace(markdown=lambda body, **kwargs: calls.append(("target", body, kwargs))) + monkeypatch.setattr( + dashboard.st, + "markdown", + lambda body, **kwargs: calls.append(("global", body, kwargs)), + ) + + dashboard.render_single_stock_public_summary( + dashboard.single_stock_one_answer_frame( + { + "ticker": "NVDA", + "status": "partial", + "asset_type": "company", + "price_ready": True, + "dcf_status": "blocked", + "peer_ready": False, + "earnings_ready": False, + "analyst_estimates_ready": False, + } + ), + research_mode=True, + selected_answer_target=target, + ) + + assert [kind for kind, _, _ in calls] == ["target"] + assert "mode=research&page=data-health&ticker=NVDA" in calls[0][1] + + +def test_single_stock_report_routes_fast_and_final_answers_through_optional_target(): + source = Path("src/dashboard.py").read_text(encoding="utf-8") + render_index = source.index("def render_single_stock_report(") + next_function_index = source.index("\ndef render_data_health(", render_index) + report_chunk = source[render_index:next_function_index] + signature = report_chunk[: report_chunk.index(") -> None:")] + + assert "selected_answer_target=None" in signature + assert report_chunk.count("render_single_stock_public_summary(") == 2 + assert report_chunk.count("selected_answer_target=selected_answer_target") == 2 + assert "single_stock_public_summary_html(" not in report_chunk + + +def test_fast_public_single_stock_summary_keeps_answer_order_and_data_health_handoff(): + fast_snapshot = { + "ticker": "NVDA", + "status": "partial", + "asset_type": "company", + "price_ready": True, + "dcf_status": "ready", + "peer_ready": False, + "earnings_ready": False, + "analyst_estimates_ready": False, + } - assert fast_snapshot_index < fast_cards_index < loading_placeholder_index < open_report_index + rendered = dashboard.single_stock_public_summary_html( + dashboard.single_stock_one_answer_frame(fast_snapshot) + ) + + selected_index = rendered.index("Selected ticker") + use_now_index = rendered.index("Use now") + withheld_index = rendered.index("Still withheld") + handoff_index = rendered.index("Open Data Health") + + assert selected_index < use_now_index < withheld_index < handoff_index + assert "?mode=public&page=data-health&ticker=NVDA" in rendered def test_single_stock_public_selector_uses_one_primary_ticker_control(): @@ -30361,7 +30719,7 @@ def test_single_stock_public_page_uses_simplified_review_sections(): readable_now_index = source.index("What can be read now", review_intro_index) one_answer_index = source.index("single_stock_one_answer_frame(report_one_answer_snapshot)", readable_now_index) first_answer_index = source.index("stock_report_first_answer_frame(report_payload)", one_answer_index) - public_cards_index = source.index("single_stock_public_summary_html(single_answer_frame)", first_answer_index) + public_cards_index = source.index("render_single_stock_public_summary(", first_answer_index) quick_read_index = source.index('with st.expander("Advanced quick-read context", expanded=False)', public_cards_index) public_quick_read_cards_index = source.index( "if public_mode:\n render_signal_cards(at_a_glance_cards, show_commands=False)", @@ -33637,6 +33995,46 @@ def test_single_stock_journal_stays_compact_and_advanced_history_is_collapsed(): assert render_source.index("research_thesis_journal_html") < render_source.index("Detailed report stays closed.") +def test_company_workbench_cash_generation_uses_no_supplemental_file_and_keeps_evidence_advanced(): + source = Path("src/dashboard.py").read_text(encoding="utf-8") + makefile = Path("Makefile").read_text(encoding="utf-8") + render_index = source.index("def render_single_stock_report(") + render_end = source.index("\ndef render_data_health(", render_index) + render_source = source[render_index:render_end] + + business_trend_index = render_source.index('st.markdown("## Business Trend")') + primary_cards_index = render_source.index("quarterly_trend_cards(trend_packet)", business_trend_index) + evidence_drawer_index = render_source.index( + 'st.expander("Advanced: quarterly trend evidence", expanded=False)', + primary_cards_index, + ) + evidence_rows_index = render_source.index("quarterly_trend_rows(trend_packet)", evidence_drawer_index) + + assert business_trend_index < primary_cards_index < evidence_drawer_index < evidence_rows_index + assert "trend_packet.canonical_rejected_rows" in render_source + assert "Canonical row" in render_source + assert "quarterly_cash_generation.csv" not in source + assert "quarterly_business_observations.csv" not in source + assert "quarterly-cash-generation" not in makefile + assert "cash-generation-report" not in makefile + + +def test_optional_commercial_evidence_blockers_stay_under_advanced_surfaces(): + source = Path("src/dashboard.py").read_text(encoding="utf-8") + render_index = source.index("def render_single_stock_report(") + render_end = source.index("\ndef render_data_health(", render_index) + render_source = source[render_index:render_end] + + valuation = render_source.index('st.expander("Advanced: historical valuation evidence", expanded=False)') + valuation_blockers = render_source.index("valuation_regime.commercial_blockers", valuation) + forward = render_source.index('st.expander("Advanced: Forward View evidence", expanded=False)') + catalyst_blockers = render_source.index("catalyst_timeline.commercial_blockers", forward) + thesis = render_source.index('st.expander("Advanced: thesis and evidence history", expanded=False)') + outcome_blockers = render_source.index("outcome_status.commercial_blockers", thesis) + + assert valuation < valuation_blockers < forward < catalyst_blockers < thesis < outcome_blockers + + def test_scenario_lab_input_from_report_preserves_source_backed_fields(): payload = { "ticker": "SYN1", @@ -33830,3 +34228,4 @@ def test_stock_selector_comparison_uses_three_ticker_limit_and_profile_scoped_jo assert "build_research_comparison(" in render_source assert "research_comparison_frame(" in render_source assert render_source.index('st.expander("Advanced: selected review tray", expanded=False)') < render_source.index("build_research_comparison(") + assert dashboard.dashboard_mode_label(dashboard.RESEARCH_MODE) == "Personal research mode" diff --git a/tests/test_dashboard_navigation.py b/tests/test_dashboard_navigation.py index 6fd10841..1135fc2e 100644 --- a/tests/test_dashboard_navigation.py +++ b/tests/test_dashboard_navigation.py @@ -131,16 +131,89 @@ def test_public_workflow_stop_rules_keep_research_only_boundary_visible(): assert "trusting rows" not in data_health_stop -def test_dashboard_navigation_mode_defaults_public_unless_advanced_context(): +def test_dashboard_navigation_mode_defaults_research_unless_explicit_or_advanced_context(): advanced = ["Overview", "Monthly Picks", "Universe Manager"] assert nav.dashboard_mode_from_query("public", "Home", advanced) == nav.PUBLIC_DEMO_MODE assert nav.dashboard_mode_from_query("operator", "Home", advanced) == nav.OPERATOR_DEMO_MODE - assert nav.dashboard_mode_from_query("", "Home", advanced) == nav.PUBLIC_DEMO_MODE + assert nav.dashboard_mode_from_query("", "Home", advanced) == nav.RESEARCH_MODE assert nav.dashboard_mode_from_query("", "Universe Manager", advanced) == nav.OPERATOR_DEMO_MODE assert nav.dashboard_mode_label(nav.OPERATOR_DEMO_MODE) == "Operator mode" +def test_legacy_research_utilities_have_an_exact_operator_only_contract(): + assert nav.LEGACY_RESEARCH_UTILITY_PAGES == ( + "Monthly Picks", + "Momentum Leaders", + "Portfolio Review", + "Value / Re-rating", + "Final Watchlist", + ) + + for page in nav.LEGACY_RESEARCH_UTILITY_PAGES: + assert nav.legacy_research_utility_label(page) == f"Legacy utility · {page}" + assert nav.workspace_page_for_mode(page, nav.PUBLIC_DEMO_MODE) == "Home" + assert nav.workspace_page_for_mode(page, nav.RESEARCH_MODE) == "Research Desk" + assert nav.workspace_page_for_mode(page, nav.OPERATOR_DEMO_MODE) == page + + assert nav.legacy_research_utility_label("Data Health") == "Data Health" + assert nav.workspace_page_for_mode("Data Health", nav.RESEARCH_MODE) == "Data Health" + + +def test_legacy_aliases_remain_available_for_operator_compatibility(): + pages = nav.PUBLIC_PATH_PAGE_TITLES + list(nav.LEGACY_RESEARCH_UTILITY_PAGES) + + assert nav.dashboard_page_from_query("monthly-picks", pages) == "Monthly Picks" + assert nav.dashboard_page_from_query("portfolio-review", pages) == "Portfolio Review" + assert nav.dashboard_page_from_query("final-watchlist", pages) == "Final Watchlist" + + +def test_dashboard_navigation_supports_personal_research_mode_without_changing_public_paths(): + advanced = ["Overview", "Monthly Picks", "Universe Manager"] + + assert nav.RESEARCH_MODE == "research" + assert nav.RESEARCH_PATH_PAGE_TITLES == [ + "Research Desk", + "Discover", + "Company Workbench", + "Monitor", + ] + assert nav.dashboard_mode_from_query("", "Home", advanced) == nav.RESEARCH_MODE + assert nav.dashboard_mode_from_query("research", "Research Desk", advanced) == nav.RESEARCH_MODE + assert nav.dashboard_mode_from_query("public", "Home", advanced) == nav.PUBLIC_DEMO_MODE + assert nav.dashboard_mode_from_query("operator", "Home", advanced) == nav.OPERATOR_DEMO_MODE + assert nav.dashboard_mode_label(nav.RESEARCH_MODE) == "Personal research mode" + assert nav.PUBLIC_PATH_PAGE_TITLES == [ + "Home", + "Stock Selector", + "Single-Stock Report", + "Data Health", + "Proof History", + ] + + +def test_research_navigation_keeps_evidence_pages_secondary_and_supports_deep_links(): + pages = nav.PUBLIC_PATH_PAGE_TITLES + nav.RESEARCH_PATH_PAGE_TITLES + ["Data Health", "Proof History"] + + assert nav.dashboard_page_from_query("research-desk", pages) == "Research Desk" + assert nav.dashboard_page_from_query("discover", pages) == "Discover" + assert nav.dashboard_page_from_query("company-workbench", pages) == "Company Workbench" + assert nav.dashboard_page_from_query("monitor", pages) == "Monitor" + assert nav.research_path_options("Data Health") == nav.RESEARCH_PATH_PAGE_TITLES + ["Data Health"] + assert nav.research_path_options("Proof History") == nav.RESEARCH_PATH_PAGE_TITLES + ["Proof History"] + assert nav.research_path_options("Research Desk") == nav.RESEARCH_PATH_PAGE_TITLES + assert nav.research_path_label("Research Desk") == "Research Desk" + assert nav.research_path_label("Discover") == "Discover" + assert nav.research_path_label("Company Workbench") == "Company Workbench" + assert nav.research_path_label("Monitor") == "Monitor" + assert nav.route_rail_query_update( + selected_page="Company Workbench", + initial_page="Research Desk", + mode=nav.RESEARCH_MODE, + allowed_pages=nav.RESEARCH_PATH_PAGE_TITLES, + ) == {"mode": "research", "page": "company-workbench"} + + def test_dashboard_navigation_query_wins_only_until_route_rail_changes(): assert ( nav.selected_page_from_route_rail( diff --git a/tests/test_dashboard_render_smoke.py b/tests/test_dashboard_render_smoke.py index 83970960..1f616500 100644 --- a/tests/test_dashboard_render_smoke.py +++ b/tests/test_dashboard_render_smoke.py @@ -1,4 +1,12 @@ from pathlib import Path +from unittest.mock import patch + +from streamlit.testing.v1 import AppTest + +from src.company_workbench_cash_generation_preview import ( + CashGenerationPreviewMetric, + CompanyWorkbenchCashGenerationPreview, +) def test_public_routes_render_without_exceptions_and_keep_core_markers(): @@ -15,3 +23,305 @@ def test_public_routes_render_without_exceptions_and_keep_core_markers(): ] assert all(result.exceptions == () for result in results) assert all(result.missing_markers == () for result in results) + assert all(result.forbidden_markers == () for result in results) + assert all(result.expanded_advanced == () for result in results) + + +def test_research_routes_render_without_exceptions_and_keep_answer_first_markers(): + from src.dashboard_render_smoke import RESEARCH_RENDER_ROUTES, render_public_routes + + assert [route.name for route in RESEARCH_RENDER_ROUTES] == [ + "Research Desk", + "Discover", + "Company Workbench", + "Monitor", + "Research Data Health", + "Research Proof History", + ] + results = render_public_routes(Path("."), routes=RESEARCH_RENDER_ROUTES) + + assert all(result.exceptions == () for result in results) + assert all(result.missing_markers == () for result in results) + assert all(result.forbidden_markers == () for result in results) + assert all(result.expanded_advanced == () for result in results) + + +def test_authoring_composer_renders_once_only_in_closed_research_company_workbench(): + workbench = AppTest.from_file("src/dashboard.py", default_timeout=120) + workbench.query_params.update( + {"mode": "research", "page": "company-workbench", "ticker": "NVDA", "open": "1"} + ) + workbench.run(timeout=120) + + assert not workbench.exception + composer_expanders = [ + item for item in workbench.expander if item.label == "Add a reviewed research record" + ] + assert len(composer_expanders) == 1 + assert not composer_expanders[0].proto.expanded + assert not any( + item.proto.expanded for item in workbench.expander if item.label.startswith("Advanced:") + ) + + public_report = AppTest.from_file("src/dashboard.py", default_timeout=120) + public_report.query_params.update( + {"mode": "public", "page": "single-stock-report", "ticker": "NVDA", "open": "1"} + ) + public_report.run(timeout=120) + + assert not public_report.exception + assert not any(item.label == "Add a reviewed research record" for item in public_report.expander) + + operator_report = AppTest.from_file("src/dashboard.py", default_timeout=120) + operator_report.query_params.update( + {"mode": "operator", "page": "single-stock-report", "ticker": "NVDA", "open": "1"} + ) + operator_report.run(timeout=120) + + assert not operator_report.exception + assert not any(item.label == "Add a reviewed research record" for item in operator_report.expander) + + +def test_monitor_renders_research_discipline_after_weekly_summary_without_ranking(): + from src.dashboard_render_smoke import DashboardRenderRoute, render_public_routes + + route = DashboardRenderRoute( + name="Monitor Research Discipline Review", + query_params=(("mode", "research"), ("page", "monitor")), + required_markers=( + "WEEKLY RESEARCH SUMMARY", + "Research Discipline Review", + "Research change monitor", + "Advanced: Research Discipline evidence", + "Research-only", + ), + ) + + result = render_public_routes(Path("."), routes=(route,))[0] + rendered = "\n".join(result.rendered_blocks) + + assert result.exceptions == () + assert rendered.index("WEEKLY RESEARCH SUMMARY") < rendered.index("Research Discipline Review") + assert rendered.index("Research Discipline Review") < rendered.index("Research change monitor") + assert "company rank" not in rendered.lower() + assert "expected return" not in rendered.lower() + + +def test_avgo_company_workbench_renders_one_authoritative_peer_task(): + from src.dashboard_render_smoke import DashboardRenderRoute, render_public_routes + + route = DashboardRenderRoute( + name="AVGO Company Workbench task arbitration", + query_params=( + ("mode", "research"), + ("page", "company-workbench"), + ("ticker", "AVGO"), + ("open", "1"), + ), + required_markers=("Company Workbench", "Research-only"), + ) + + result = render_public_routes(Path("."), routes=(route,))[0] + rendered = "\n".join(result.rendered_blocks) + task_blocks = tuple(block for block in result.rendered_blocks if "ONE NEXT TASK" in block) + change_blocks = tuple(block for block in result.rendered_blocks if "EVIDENCE CHANGE" in block) + + assert result.exceptions == () + assert len(change_blocks) == 1 + assert "No unresolved source-backed change is queued for this company." in change_blocks[0] + assert "no queued change" in change_blocks[0] + assert "snapshot evidence only" not in change_blocks[0] + assert len(task_blocks) == 1 + assert task_blocks[0].count("ONE NEXT TASK") == 1 + assert "
Add peer mappings
" in task_blocks[0] + assert rendered.count("FORWARD-VIEW LANE UNBLOCK") == 1 + assert "NEXT RESEARCH TASK" not in rendered + + +def test_avgo_company_workbench_renders_one_selected_answer_with_ticker_handoff(): + from src.dashboard_render_smoke import DashboardRenderRoute, render_public_routes + + route = DashboardRenderRoute( + name="AVGO Company Workbench answer handoff", + query_params=( + ("mode", "research"), + ("page", "company-workbench"), + ("ticker", "AVGO"), + ("open", "1"), + ), + required_markers=("Company Workbench", "Open Data Health", "Research-only"), + ) + + result = render_public_routes(Path("."), routes=(route,))[0] + rendered = "\n".join(result.rendered_blocks) + + assert result.exceptions == () + assert rendered.count("aria-label='Selected ticker answer'") == 1 + assert "?mode=research&page=data-health&ticker=AVGO" in rendered + + +def test_avgo_company_workbench_renders_one_six_lane_decision_lab_after_selected_answer(): + from src.dashboard_render_smoke import DashboardRenderRoute, render_public_routes + + route = DashboardRenderRoute( + name="AVGO Company Workbench Decision Lab", + query_params=( + ("mode", "research"), + ("page", "company-workbench"), + ("ticker", "AVGO"), + ("open", "1"), + ), + required_markers=( + "Use now", + "What Changed", + "Research Decision Lab", + "PLAN", + "EVIDENCE", + "INVALIDATION", + "SCENARIO", + "REVIEW TRIGGER", + "LEARNING", + "NEXT PROCESS STEP", + "Research Conclusion", + "Next Research Task", + ), + ) + + result = render_public_routes(Path("."), routes=(route,))[0] + rendered = "\n".join(result.rendered_blocks) + + assert result.exceptions == () + assert result.missing_markers == () + assert result.expanded_advanced == () + assert rendered.count("Research Decision Lab") == 1 + assert rendered.count("NEXT PROCESS STEP") == 1 + assert rendered.index("Use now") < rendered.index("Research Decision Lab") + assert rendered.index("Research Decision Lab") < rendered.index("Research Conclusion") + + +def test_research_render_smoke_output_names_the_contract_and_failures(): + from src.dashboard_render_smoke import DashboardRenderResult, render_dashboard_smoke + + rendered = render_dashboard_smoke( + [ + DashboardRenderResult( + name="Company Workbench", + exceptions=(), + missing_markers=(), + forbidden_markers=("ArrowInvalid",), + expanded_advanced=("Advanced Evidence",), + ) + ], + contract_name="Research dashboard render smoke", + ) + + assert rendered.startswith("Research dashboard render smoke") + assert "forbidden markers: ArrowInvalid" in rendered + assert "expanded advanced sections: Advanced Evidence" in rendered + + +def test_explicit_amd_cash_preview_route_renders_accepted_answer_without_network(): + from src.dashboard_render_smoke import DashboardRenderRoute, render_public_routes + + preview = CompanyWorkbenchCashGenerationPreview( + ticker="AMD", + fiscal_period="2026-Q1", + status="accepted_for_review", + message="Accepted SEC evidence supports a cash-generation review preview.", + operating_margin=CashGenerationPreviewMetric( + "operating_margin", "preview_available", 1_476_000_000 / 10_253_000_000, + "2026-Q1", (), "" + ), + free_cash_flow=CashGenerationPreviewMetric( + "free_cash_flow", "preview_available", 2_566_000_000, "2026-Q1", (), "" + ), + fcf_margin=CashGenerationPreviewMetric( + "fcf_margin", "preview_available", 2_566_000_000 / 10_253_000_000, + "2026-Q1", (), "" + ), + blockers=(), + withheld_metrics=(), + accession="0000002488-26-000076", + source_url="https://www.sec.gov/Archives/edgar/data/2488/000000248826000076/amd-20260328.htm", + accepted_at="2026-05-05T22:06:27+00:00", + cutoff="2026-07-21T03:59:59+00:00", + capex_sign_evidence="explicit_filed_table_outflow", + components=(), + ) + route = DashboardRenderRoute( + name="AMD Company Workbench cash preview", + query_params=( + ("mode", "research"), + ("page", "company-workbench"), + ("ticker", "AMD"), + ("open", "1"), + ("cash_preview", "1"), + ), + required_markers=( + "Cash-generation review preview", + "not production evidence", + "14.4%", + "2,566,000,000", + "25.0%", + ), + ) + + with patch( + "src.company_workbench_cash_generation_preview_loader." + "load_company_workbench_cash_generation_preview", + return_value=preview, + ): + result = render_public_routes(Path("."), routes=(route,))[0] + + assert result.exceptions == () + assert result.missing_markers == () + assert result.forbidden_markers == () + assert result.expanded_advanced == () + + +def test_normal_company_workbench_route_never_loads_cash_preview(): + from src.dashboard_render_smoke import DashboardRenderRoute, render_public_routes + + route = DashboardRenderRoute( + name="Normal Company Workbench", + query_params=( + ("mode", "research"), + ("page", "company-workbench"), + ("ticker", "NVDA"), + ("open", "1"), + ), + required_markers=("Company Workbench", "Business Trend", "Research-only"), + ) + + with patch( + "src.company_workbench_cash_generation_preview_loader." + "load_company_workbench_cash_generation_preview", + side_effect=AssertionError("normal Workbench must not load SEC preview"), + ): + result = render_public_routes(Path("."), routes=(route,))[0] + + assert result.exceptions == () + assert result.missing_markers == () + + +def test_normal_amd_company_workbench_route_never_loads_cash_preview(): + from src.dashboard_render_smoke import DashboardRenderRoute, render_public_routes + + route = DashboardRenderRoute( + name="Normal AMD Company Workbench", + query_params=( + ("mode", "research"), + ("page", "company-workbench"), + ("ticker", "AMD"), + ("open", "1"), + ), + required_markers=("Company Workbench", "Business Trend", "Research-only"), + ) + with patch( + "src.company_workbench_cash_generation_preview_loader." + "load_company_workbench_cash_generation_preview", + side_effect=AssertionError("normal AMD Workbench must not load cash preview"), + ): + result = render_public_routes(Path("."), routes=(route,))[0] + assert result.exceptions == () + assert result.missing_markers == () diff --git a/tests/test_data_health_peer_unlock.py b/tests/test_data_health_peer_unlock.py index 07b52753..4bf9229b 100644 --- a/tests/test_data_health_peer_unlock.py +++ b/tests/test_data_health_peer_unlock.py @@ -52,7 +52,7 @@ def test_peer_unlock_operator_cards_group_priorities_scope_and_next_input(): assert "dcf-ready but peer-blocked: 2" in rendered assert "meta" in rendered assert "data/imports/peers.csv" in rendered - assert "schema fields: ticker, peer_ticker, peer_group, sector, industry, source, as_of_date" in rendered + assert "schema fields: ticker, peer_ticker, peer_group, sector, industry, peer_role, relationship_rationale, comparability_basis, valuation_anchor_eligible, source, as_of_date" in rendered assert "make imports-preview" in rendered assert "dcf ready peer mapping" in rendered assert "peer trend can use mapped peer price history" in rendered diff --git a/tests/test_data_update.py b/tests/test_data_update.py index 1abe6014..b29e562e 100644 --- a/tests/test_data_update.py +++ b/tests/test_data_update.py @@ -3,6 +3,9 @@ from pathlib import Path import pandas as pd +import pytest + +import src.data_update as data_update from src.data_update import ( AlphaVantageDailyPriceSource, @@ -23,12 +26,19 @@ update_local_price_data, validate_price_imports, ) +from src.commercial_source_rights import build_source_rights_registry from src.provider_env import reset_provider_environment_cache class FakePriceSource: - def __init__(self, payloads: dict[str, pd.DataFrame | None]) -> None: + def __init__( + self, + payloads: dict[str, pd.DataFrame | None], + *, + source_id: str = "fake_price_source", + ) -> None: self.payloads = payloads + self.source_id = source_id self.calls: list[str] = [] def fetch_history(self, ticker: str) -> tuple[pd.DataFrame, list[str]]: @@ -89,6 +99,32 @@ def __init__(self, date, open, high, low, close, volume) -> None: self.volume = volume +def _commercial_price_registry( + source_id: str, + *, + commercial_use: str = "approved", + supported_fields: list[str] | None = None, +): + return build_source_rights_registry( + [ + { + "source_id": source_id, + "display_name": f"{source_id} test source", + "permitted_use": "reviewed_price_research", + "commercial_use": commercial_use, + "redistribution": "derived_data_only", + "storage_limits": "reviewed rows only", + "attribution": "durable source reference required", + "rate_limits": "test fixture only", + "authentication": "test fixture only", + "expected_freshness": "fixture timestamp", + "supported_fields": supported_fields or ["prices"], + "fallback_priority": 1, + } + ] + ) + + def test_stooq_source_reports_api_key_page_without_parser_failure(): def opener(_url: str, timeout: int): assert timeout == 20 @@ -364,7 +400,7 @@ def test_ibkr_price_source_exposes_no_trading_methods(): def test_price_source_ladder_tries_stooq_after_yahoo_has_no_rows(): - yahoo = FakePriceSource({"META": None}) + yahoo = FakePriceSource({"META": None}, source_id="yahoo") stooq = FakePriceSource( { "META": pd.DataFrame( @@ -381,7 +417,8 @@ def test_price_source_ladder_tries_stooq_after_yahoo_has_no_rows(): } ] ) - } + }, + source_id="stooq", ) frame, warnings = PriceSourceLadder([("yahoo", yahoo), ("stooq", stooq)]).fetch_history("META") @@ -396,7 +433,7 @@ def test_price_source_ladder_tries_stooq_after_yahoo_has_no_rows(): def test_update_local_price_data_records_auto_price_ladder_provider(tmp_path: Path): (tmp_path / "data").mkdir() (tmp_path / "config.yaml").write_text(Path("config.yaml").read_text(), encoding="utf-8") - yahoo = FakePriceSource({"META": None}) + yahoo = FakePriceSource({"META": None}, source_id="yahoo") stooq = FakePriceSource( { "META": pd.DataFrame( @@ -413,7 +450,8 @@ def test_update_local_price_data_records_auto_price_ladder_provider(tmp_path: Pa } ] ) - } + }, + source_id="stooq", ) source = PriceSourceLadder([("yahoo", yahoo), ("stooq", stooq)]) @@ -465,6 +503,182 @@ def test_make_price_source_auto_adds_configured_ibkr_before_keyed_price_fallback assert source.provider_names == ["stooq", "yahoo", "ibkr", "fmp"] +def test_price_sources_expose_exact_source_ids(): + assert StooqDailyPriceSource.source_id == "stooq" + assert YahooChartDailyPriceSource.source_id == "yahoo" + assert FMPDailyPriceSource.source_id == "fmp" + assert AlphaVantageDailyPriceSource.source_id == "alpha_vantage" + assert FinnhubDailyPriceSource.source_id == "finnhub" + assert IBKRDailyPriceSource.source_id == "ibkr" + + +def test_price_source_ladder_rejects_label_that_does_not_match_exact_source_id(): + source = FakePriceSource({}, source_id="stooq") + + with pytest.raises(ValueError, match="price source label/source_id mismatch"): + PriceSourceLadder([("yahoo", source)]) + + +def test_make_price_source_blocks_unapproved_exact_provider_in_commercial_mode(): + registry = _commercial_price_registry("stooq", commercial_use="unverified") + + with pytest.raises(RuntimeError, match="commercial_price_source_review_required.*stooq"): + make_price_source("stooq", commercial_mode=True, rights_registry=registry) + + +def test_make_price_source_blocks_provider_without_registered_price_scope(): + registry = _commercial_price_registry("stooq", supported_fields=["revenue"]) + + with pytest.raises(RuntimeError, match="commercial_price_scope_review_required.*stooq"): + make_price_source("stooq", commercial_mode=True, rights_registry=registry) + + +def test_make_price_source_commercial_auto_keeps_only_independently_eligible_legs(monkeypatch): + for key in ("FMP_API_KEY", "ALPHA_VANTAGE_API_KEY", "FINNHUB_API_KEY", "IBKR_HOST", "IBKR_PORT", "IBKR_CLIENT_ID"): + monkeypatch.delenv(key, raising=False) + reset_provider_environment_cache() + registry = _commercial_price_registry("stooq") + + source = make_price_source("auto", commercial_mode=True, rights_registry=registry) + + assert isinstance(source, PriceSourceLadder) + assert source.provider_names == ["stooq"] + assert source.source_ids == ("stooq",) + + +def test_make_price_source_commercial_auto_fails_when_no_leg_is_eligible(monkeypatch): + for key in ("FMP_API_KEY", "ALPHA_VANTAGE_API_KEY", "FINNHUB_API_KEY", "IBKR_HOST", "IBKR_PORT", "IBKR_CLIENT_ID"): + monkeypatch.delenv(key, raising=False) + reset_provider_environment_cache() + registry = _commercial_price_registry("unrelated_source") + + with pytest.raises(RuntimeError, match="commercial_price_source_review_required.*stooq.*yahoo"): + make_price_source("auto", commercial_mode=True, rights_registry=registry) + + +def test_commercial_price_update_blocks_missing_identity_before_fetch_or_output(tmp_path: Path): + class MissingIdentitySource: + def __init__(self) -> None: + self.calls = 0 + + def fetch_history(self, _ticker: str): + self.calls += 1 + return pd.DataFrame(), [] + + source = MissingIdentitySource() + + with pytest.raises(RuntimeError, match="commercial_price_source_id_required"): + update_local_price_data( + tmp_path, + source=source, + tickers=["META"], + commercial_mode=True, + rights_registry=_commercial_price_registry("approved_prices"), + ) + + assert source.calls == 0 + assert not (tmp_path / "data" / "prices.csv").exists() + assert not (tmp_path / "outputs" / "price_update_status.csv").exists() + + +def test_commercial_price_update_blocks_unapproved_source_before_fetch_or_output(tmp_path: Path): + source = FakePriceSource({"META": None}, source_id="unverified_prices") + + with pytest.raises(RuntimeError, match="commercial_price_source_review_required.*unverified_prices"): + update_local_price_data( + tmp_path, + source=source, + tickers=["META"], + commercial_mode=True, + rights_registry=_commercial_price_registry( + "unverified_prices", commercial_use="unverified" + ), + ) + + assert source.calls == [] + assert not (tmp_path / "data" / "prices.csv").exists() + assert not (tmp_path / "outputs" / "price_update_status.csv").exists() + + +def test_commercial_price_update_allows_approved_scoped_source(tmp_path: Path): + (tmp_path / "data").mkdir() + (tmp_path / "config.yaml").write_text(Path("config.yaml").read_text(), encoding="utf-8") + source = FakePriceSource( + { + "META": pd.DataFrame( + [ + { + "date": pd.Timestamp("2026-01-03"), + "ticker": "META", + "open": 100.0, + "high": 102.0, + "low": 99.0, + "close": 101.0, + "adj_close": 101.0, + "volume": 12345, + } + ] + ) + }, + source_id="approved_prices", + ) + + result = update_local_price_data( + tmp_path, + source=source, + tickers=["META"], + commercial_mode=True, + rights_registry=_commercial_price_registry("approved_prices"), + ) + + assert result.tickers_updated == ["META"] + assert result.path.exists() + assert result.status_path is not None and result.status_path.exists() + + +def test_commercial_price_update_blocks_changed_identity_before_mutation(tmp_path: Path): + (tmp_path / "data").mkdir() + (tmp_path / "config.yaml").write_text(Path("config.yaml").read_text(), encoding="utf-8") + + class ChangedIdentitySource(FakePriceSource): + def fetch_history(self, ticker: str): + frame, warnings = super().fetch_history(ticker) + self.source_id = "unreviewed_after_fetch" + return frame, warnings + + source = ChangedIdentitySource( + { + "META": pd.DataFrame( + [ + { + "date": pd.Timestamp("2026-01-03"), + "ticker": "META", + "open": 100.0, + "high": 102.0, + "low": 99.0, + "close": 101.0, + "adj_close": 101.0, + "volume": 12345, + } + ] + ) + }, + source_id="approved_prices", + ) + + with pytest.raises(RuntimeError, match="commercial_price_source_changed"): + update_local_price_data( + tmp_path, + source=source, + tickers=["META"], + commercial_mode=True, + rights_registry=_commercial_price_registry("approved_prices"), + ) + + assert not (tmp_path / "data" / "prices.csv").exists() + assert not (tmp_path / "outputs" / "price_update_status.csv").exists() + + def test_price_refresh_cli_accepts_direct_finnhub_provider(tmp_path: Path, monkeypatch): (tmp_path / "data").mkdir() (tmp_path / "outputs").mkdir() @@ -1213,19 +1427,71 @@ def _write_price_import_fixture(root: Path) -> None: encoding="utf-8", ) (import_dir / "prices.csv").write_text( - "date,ticker,open,high,low,close,volume,adjusted_close,source,as_of_date,notes,extra\n" - "2026-01-02,nvda,100,103,99,102,1500,102,manual,2026-01-03,updated,row-extra\n" - "2026-01-03,NVDA,102,104,101,103,1600,103,manual,2026-01-03,new,row-extra\n" - "2026-01-02,NVDA,100,103,99,102,1500,102,manual,2026-01-03,duplicate,row-extra\n" - "2026-01-04,BAD,10,9,11,10,100,10,manual,2026-01-03,bad-high-low,row-extra\n", + "date,ticker,open,high,low,close,volume,adjusted_close,source,source_ref,retrieved_at,as_of_date,notes,extra\n" + "2026-01-02,nvda,100,103,99,102,1500,102,manual,https://example.test/NVDA/2026-01-02,2026-01-03T23:00:00Z,2026-01-03,updated,row-extra\n" + "2026-01-03,NVDA,102,104,101,103,1600,103,manual,https://example.test/NVDA/2026-01-03,2026-01-04T23:00:00Z,2026-01-03,new,row-extra\n" + "2026-01-02,NVDA,100,103,99,102,1500,102,manual,https://example.test/NVDA/2026-01-02,2026-01-03T23:00:00Z,2026-01-03,duplicate,row-extra\n" + "2026-01-04,BAD,10,9,11,10,100,10,manual,https://example.test/BAD/2026-01-04,2026-01-05T23:00:00Z,2026-01-03,bad-high-low,row-extra\n", encoding="utf-8", ) +def _price_rights_registry(): + return build_source_rights_registry( + [ + { + "source_id": "approved_prices", + "display_name": "Approved Prices", + "permitted_use": "reviewed_price_research", + "commercial_use": "approved", + "redistribution": "derived_data_only", + "storage_limits": "reviewed rows only", + "attribution": "durable source reference required", + "rate_limits": "manual reviewed export", + "authentication": "reviewed account", + "expected_freshness": "payload timestamp", + "supported_fields": ["prices"], + "fallback_priority": 1, + }, + { + "source_id": "approved_fundamentals", + "display_name": "Approved Fundamentals", + "permitted_use": "reviewed_fundamentals_research", + "commercial_use": "approved", + "redistribution": "derived_data_only", + "storage_limits": "reviewed rows only", + "attribution": "durable source reference required", + "rate_limits": "manual reviewed export", + "authentication": "reviewed account", + "expected_freshness": "filing driven", + "supported_fields": ["revenue"], + "fallback_priority": 2, + }, + { + "source_id": "unverified_prices", + "display_name": "Unverified Prices", + "permitted_use": "research_only", + "commercial_use": "unverified", + "redistribution": "not_permitted", + "storage_limits": "local research only", + "attribution": "source attribution required", + "rate_limits": "manual reviewed export", + "authentication": "none", + "expected_freshness": "not guaranteed", + "supported_fields": ["prices"], + "fallback_priority": 90, + }, + ] + ) + + def test_price_import_validation_valid_fixture_and_duplicates(tmp_path: Path): _write_price_import_fixture(tmp_path) - summary = validate_price_imports(tmp_path) + summary = validate_price_imports( + tmp_path, + review_cutoff="2026-01-05T00:00:00Z", + ) assert summary["status"] == "valid_with_warnings" assert summary["valid_rows"] == 2 @@ -1234,6 +1500,149 @@ def test_price_import_validation_valid_fixture_and_duplicates(tmp_path: Path): assert "extra" in summary["unknown_columns"] assert "price import draft" not in " ".join(summary["warnings"]).lower() assert "invalid price import file row" in " ".join(summary["warnings"]).lower() + assert summary["lineage_status"] == "lineage_complete" + assert summary["lineage_complete_rows"] == 2 + assert summary["lineage_review_required_rows"] == 0 + assert summary["lineage_missing_fields"] == [] + assert set(summary["valid_frame"]["source_ref"]) == { + "https://example.test/NVDA/2026-01-02", + "https://example.test/NVDA/2026-01-03", + } + assert set(summary["valid_frame"]["retrieved_at"]) == { + "2026-01-03T23:00:00+00:00", + "2026-01-04T23:00:00+00:00", + } + assert summary["commercial_rights_status"] == "rights_review_required" + assert summary["rights_approved_rows"] == 0 + assert summary["rights_review_required_rows"] == 2 + assert summary["rights_status_counts"] == {"unknown_source": 2} + assert summary["price_scope_status"] == "price_scope_review_required" + assert summary["price_scope_complete_rows"] == 0 + assert summary["price_scope_review_required_rows"] == 2 + assert summary["source_review_rows"] == [ + { + "source_id": "manual", + "row_count": 2, + "rights_status": "unknown_source", + "commercial_rights_approved": False, + "price_scope_complete": False, + "blockers": ["commercial_rights:unknown_source", "registered_price_scope_incomplete"], + } + ] + + +def test_price_import_validation_keeps_technical_validity_independent_from_lineage(tmp_path: Path): + data_dir = tmp_path / "data" + import_dir = data_dir / "imports" + import_dir.mkdir(parents=True) + (tmp_path / "config.yaml").write_text(Path("config.yaml").read_text(), encoding="utf-8") + (import_dir / "prices.csv").write_text( + "date,ticker,open,high,low,close,volume,adjusted_close,source,source_ref,retrieved_at\n" + "2026-01-02,NVDA,100,103,99,102,1500,102,manual,,not-a-timestamp\n", + encoding="utf-8", + ) + + summary = validate_price_imports(tmp_path) + + assert summary["status"] == "valid_with_warnings" + assert summary["valid_rows"] == 1 + assert summary["lineage_status"] == "lineage_review_required" + assert summary["lineage_complete_rows"] == 0 + assert summary["lineage_review_required_rows"] == 1 + assert summary["lineage_missing_fields"] == ["retrieved_at", "source_ref"] + assert summary["valid_frame"].iloc[0]["source"] == "manual" + assert pd.isna(summary["valid_frame"].iloc[0]["source_ref"]) + assert summary["valid_frame"].iloc[0]["retrieved_at"] == "" + assert "lineage review" in " ".join(summary["warnings"]).lower() + + +def test_price_import_validation_keeps_rights_and_scope_independent(tmp_path: Path): + data_dir = tmp_path / "data" + import_dir = data_dir / "imports" + import_dir.mkdir(parents=True) + (tmp_path / "config.yaml").write_text(Path("config.yaml").read_text(), encoding="utf-8") + (import_dir / "prices.csv").write_text( + "date,ticker,open,high,low,close,volume,source,source_ref,retrieved_at\n" + "2026-01-02,NVDA,100,103,99,102,1500,approved_prices,https://example.test/approved,2026-01-03T23:00:00Z\n" + "2026-01-02,MSFT,200,203,199,202,2500,unverified_prices,https://example.test/unverified,2026-01-03T23:00:00Z\n" + "2026-01-02,META,300,303,299,302,3500,approved_fundamentals,https://example.test/fundamentals,2026-01-03T23:00:00Z\n" + "2026-01-02,GOOG,400,403,399,402,4500,,https://example.test/missing,2026-01-03T23:00:00Z\n" + "2026-01-02,BAD,10,9,11,10,100,approved_prices,https://example.test/invalid,2026-01-03T23:00:00Z\n", + encoding="utf-8", + ) + + summary = validate_price_imports(tmp_path, rights_registry=_price_rights_registry()) + + assert summary["status"] == "valid_with_warnings" + assert summary["valid_rows"] == 4 + assert summary["commercial_rights_status"] == "mixed_rights" + assert summary["rights_approved_rows"] == 2 + assert summary["rights_review_required_rows"] == 2 + assert summary["rights_status_counts"] == { + "approved": 2, + "commercial_rights_unverified": 1, + "unknown_source": 1, + } + assert summary["price_scope_status"] == "mixed_price_scope" + assert summary["price_scope_complete_rows"] == 2 + assert summary["price_scope_review_required_rows"] == 2 + assert [row["source_id"] for row in summary["source_review_rows"]] == [ + "", + "approved_fundamentals", + "approved_prices", + "unverified_prices", + ] + assert summary["source_review_rows"][0]["rights_status"] == "unknown_source" + assert summary["source_review_rows"][0]["price_scope_complete"] is False + assert summary["source_review_rows"][1]["commercial_rights_approved"] is True + assert summary["source_review_rows"][1]["price_scope_complete"] is False + assert summary["source_review_rows"][2]["blockers"] == [] + assert summary["source_review_rows"][3]["blockers"] == [ + "commercial_rights:commercial_rights_unverified" + ] + assert "BAD" not in {row["ticker"] for row in summary["valid_frame"].to_dict(orient="records")} + + +def test_price_import_validation_keeps_yfinance_technical_status_valid(tmp_path: Path): + import_dir = tmp_path / "data" / "imports" + import_dir.mkdir(parents=True) + (tmp_path / "config.yaml").write_text(Path("config.yaml").read_text(), encoding="utf-8") + (import_dir / "prices.csv").write_text( + "date,ticker,open,high,low,close,volume,source,source_ref,retrieved_at\n" + "2026-01-02,NVDA,100,103,99,102,1500,yfinance,https://example.test/yfinance,2026-01-03T23:00:00Z\n", + encoding="utf-8", + ) + + summary = validate_price_imports(tmp_path) + + assert summary["status"] == "valid" + assert summary["warnings"] == [] + assert summary["lineage_status"] == "lineage_complete" + assert summary["commercial_rights_status"] == "rights_review_required" + assert summary["rights_status_counts"] == {"commercial_rights_unverified": 1} + assert summary["price_scope_status"] == "price_scope_complete" + assert summary["commercial_evidence_warnings"] == [ + "Commercial rights review required for 1 valid staged price row(s)." + ] + + +def test_price_import_preview_inherits_injected_rights_and_scope_review(tmp_path: Path): + _write_price_import_fixture(tmp_path) + staged_path = tmp_path / "data" / "imports" / "prices.csv" + staged = pd.read_csv(staged_path) + staged.loc[:, "source"] = "approved_prices" + staged.to_csv(staged_path, index=False) + + preview = preview_price_import_merge(tmp_path, rights_registry=_price_rights_registry()) + + assert preview["new_rows"] == 1 + assert preview["updated_rows"] == 1 + assert preview["commercial_rights_status"] == "rights_approved" + assert preview["rights_approved_rows"] == 2 + assert preview["rights_review_required_rows"] == 0 + assert preview["price_scope_status"] == "price_scope_complete" + assert preview["price_scope_complete_rows"] == 2 + assert preview["price_scope_review_required_rows"] == 0 def test_price_import_validation_missing_file_uses_plain_import_file_language(tmp_path: Path): @@ -1270,12 +1679,187 @@ def test_preview_price_import_merge_reports_new_updated_and_skipped(tmp_path: Pa assert preview["updated_rows"] == 1 assert preview["skipped_rows"] == 2 assert preview["unchanged_rows"] == 0 + assert preview["lineage_status"] == "lineage_complete" + assert preview["lineage_complete_rows"] == 2 + assert preview["lineage_review_required_rows"] == 0 + assert preview["commercial_rights_status"] == "rights_review_required" + assert preview["price_scope_status"] == "price_scope_review_required" + + +def test_commercial_price_apply_blocks_before_backup_or_canonical_write(tmp_path: Path): + _write_price_import_fixture(tmp_path) + canonical_path = tmp_path / "data" / "prices.csv" + before = canonical_path.read_bytes() + + result = apply_price_import_merge( + tmp_path, + commercial_mode=True, + review_cutoff="2026-01-05T00:00:00Z", + ) + + assert result["applied"] is False + assert result["apply_status"] == "commercial_evidence_review_required" + assert result["apply_blockers"] == [ + "commercial_rights_review_required", + "registered_price_scope_review_required", + ] + assert result["backup_path"] is None + assert canonical_path.read_bytes() == before + assert not (tmp_path / "data" / "backups").exists() + + +def test_commercial_price_apply_blocks_incomplete_lineage_independently(tmp_path: Path): + _write_price_import_fixture(tmp_path) + staged_path = tmp_path / "data" / "imports" / "prices.csv" + staged = pd.read_csv(staged_path) + staged.loc[:, "source"] = "approved_prices" + staged.loc[staged["ticker"].astype(str).str.upper() == "NVDA", "source_ref"] = "" + staged.to_csv(staged_path, index=False) + + result = apply_price_import_merge( + tmp_path, + commercial_mode=True, + rights_registry=_price_rights_registry(), + review_cutoff="2026-01-05T00:00:00Z", + ) + + assert result["applied"] is False + assert result["apply_blockers"] == ["price_lineage_review_required"] + assert result["commercial_rights_status"] == "rights_approved" + assert result["price_scope_status"] == "price_scope_complete" + + +def test_commercial_price_apply_blocks_missing_registered_price_scope(tmp_path: Path): + _write_price_import_fixture(tmp_path) + staged_path = tmp_path / "data" / "imports" / "prices.csv" + staged = pd.read_csv(staged_path) + staged.loc[:, "source"] = "approved_fundamentals" + staged.to_csv(staged_path, index=False) + + result = apply_price_import_merge( + tmp_path, + commercial_mode=True, + rights_registry=_price_rights_registry(), + review_cutoff="2026-01-05T00:00:00Z", + ) + + assert result["applied"] is False + assert result["apply_blockers"] == ["registered_price_scope_review_required"] + assert result["commercial_rights_status"] == "rights_approved" + assert result["price_scope_status"] == "price_scope_review_required" + + +def test_commercial_price_apply_allows_complete_approved_batch(tmp_path: Path): + _write_price_import_fixture(tmp_path) + staged_path = tmp_path / "data" / "imports" / "prices.csv" + staged = pd.read_csv(staged_path) + staged.loc[:, "source"] = "approved_prices" + staged.to_csv(staged_path, index=False) + + result = apply_price_import_merge( + tmp_path, + commercial_mode=True, + rights_registry=_price_rights_registry(), + review_cutoff="2026-01-05T00:00:00Z", + ) + + assert result["applied"] is True + assert result["apply_status"] == "applied" + assert result["apply_blockers"] == [] + assert result["backup_path"] is not None + assert result["commercial_rights_status"] == "rights_approved" + assert result["price_scope_status"] == "price_scope_complete" + + +@pytest.mark.parametrize( + ("retrieved_at", "cutoff", "blocker"), + [ + ("2026-01-03T23:00:00", "2026-01-05T00:00:00Z", "retrieved_at_timezone_required"), + ("2026-01-02T23:59:59Z", "2026-01-05T00:00:00Z", "retrieved_before_observation_available"), + ("2026-01-05T00:00:01Z", "2026-01-05T00:00:00Z", "retrieved_after_review_cutoff"), + ], +) +def test_staged_price_preview_and_apply_share_temporal_blockers_without_mutation( + tmp_path: Path, + retrieved_at: str, + cutoff: str, + blocker: str, +): + _write_price_import_fixture(tmp_path) + staged_path = tmp_path / "data" / "imports" / "prices.csv" + staged = pd.read_csv(staged_path) + staged.loc[:, "source"] = "approved_prices" + staged.loc[:, "retrieved_at"] = retrieved_at + staged.to_csv(staged_path, index=False) + canonical_path = tmp_path / "data" / "prices.csv" + before = canonical_path.read_bytes() + + preview = preview_price_import_merge( + tmp_path, + rights_registry=_price_rights_registry(), + review_cutoff=cutoff, + ) + result = apply_price_import_merge( + tmp_path, + commercial_mode=True, + rights_registry=_price_rights_registry(), + review_cutoff=cutoff, + ) + + assert preview["price_temporal_status"] == "temporal_review_required" + assert blocker in preview["price_temporal_blocker_counts"] + assert result["applied"] is False + assert result["apply_blockers"][0] == "price_temporal_review_required" + assert canonical_path.read_bytes() == before + assert not (tmp_path / "data" / "backups").exists() + + +def test_price_apply_uses_one_validated_staged_frame_and_atomic_replace(tmp_path: Path, monkeypatch): + _write_price_import_fixture(tmp_path) + staged_path = tmp_path / "data" / "imports" / "prices.csv" + staged = pd.read_csv(staged_path) + staged.loc[:, "source"] = "approved_prices" + staged.to_csv(staged_path, index=False) + read_calls = 0 + replace_calls: list[tuple[Path, Path]] = [] + original_read = data_update._read_price_import + original_replace = data_update.os.replace + + def _read_once(path: Path): + nonlocal read_calls + read_calls += 1 + return original_read(path) + + def _replace(source: Path, destination: Path): + replace_calls.append((Path(source), Path(destination))) + return original_replace(source, destination) + + monkeypatch.setattr(data_update, "_read_price_import", _read_once) + monkeypatch.setattr(data_update.os, "replace", _replace) + + result = apply_price_import_merge( + tmp_path, + commercial_mode=True, + rights_registry=_price_rights_registry(), + review_cutoff="2026-01-05T00:00:00Z", + ) + + assert result["applied"] is True + assert read_calls == 1 + assert len(replace_calls) == 1 + temporary, destination = replace_calls[0] + assert temporary.parent == destination.parent + assert destination == tmp_path / "data" / "prices.csv" def test_apply_price_import_merge_backs_up_and_never_deletes_rows(tmp_path: Path): _write_price_import_fixture(tmp_path) - result = apply_price_import_merge(tmp_path) + result = apply_price_import_merge( + tmp_path, + commercial_mode=False, + review_cutoff="2026-01-05T00:00:00Z", + ) prices = pd.read_csv(tmp_path / "data" / "prices.csv") assert result["applied"] is True @@ -1285,3 +1869,11 @@ def test_apply_price_import_merge_backs_up_and_never_deletes_rows(tmp_path: Path assert set(prices["ticker"]) == {"NVDA", "MSFT"} updated = prices.loc[(prices["ticker"] == "NVDA") & (prices["date"] == "2026-01-02")].iloc[0] assert updated["close"] == 102 + assert updated["source_ref"] == "https://example.test/NVDA/2026-01-02" + assert updated["retrieved_at"] == "2026-01-03T23:00:00+00:00" + new_row = prices.loc[(prices["ticker"] == "NVDA") & (prices["date"] == "2026-01-03")].iloc[0] + assert new_row["source_ref"] == "https://example.test/NVDA/2026-01-03" + assert new_row["retrieved_at"] == "2026-01-04T23:00:00+00:00" + msft = prices.loc[prices["ticker"] == "MSFT"].iloc[0] + assert pd.isna(msft["source_ref"]) + assert pd.isna(msft["retrieved_at"]) diff --git a/tests/test_dcf_price_lineage.py b/tests/test_dcf_price_lineage.py new file mode 100644 index 00000000..05352fd3 --- /dev/null +++ b/tests/test_dcf_price_lineage.py @@ -0,0 +1,316 @@ +import pandas as pd +import pytest + +from src.commercial_source_rights import SourceRights +from src.dcf_price_lineage import review_dcf_price_lineage + + +def _readiness_row(ticker: str, *, dcf_ready: bool = False, fundamentals_ready: bool = False) -> dict[str, object]: + return { + "ticker": ticker, + "dcf_ready": dcf_ready, + "fundamentals_ready": fundamentals_ready, + } + + +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_market_data", + commercial_use=commercial_use, + redistribution="derived_data_only", + storage_limits="reviewed local rows", + attribution="required", + rate_limits="provider terms", + authentication="provider specific", + expected_freshness="daily", + supported_fields=supported_fields, + fallback_priority=1, + ) + + +def _price_row( + ticker: str, + date: str, + close: object, + *, + source: object = "approved_prices", + source_ref: object = "https://example.test/price-row", + retrieved_at: object = "2026-01-04T23:00:00Z", +) -> dict[str, object]: + return { + "ticker": ticker, + "date": date, + "close": close, + "source": source, + "source_ref": source_ref, + "retrieved_at": retrieved_at, + } + + +def test_review_selects_only_false_to_true_dcf_promotions_and_can_complete(): + saved = pd.DataFrame( + [ + _readiness_row("AAA"), + _readiness_row("BBB", dcf_ready=True), + _readiness_row("CCC"), + ] + ) + proposed = pd.DataFrame( + [ + _readiness_row("AAA", dcf_ready=True), + _readiness_row("BBB", dcf_ready=True), + _readiness_row("CCC", fundamentals_ready=True), + ] + ) + prices = pd.DataFrame( + [ + _price_row("AAA", "2026-01-02", 10.0, source_ref="https://example.test/AAA/2026-01-02"), + _price_row("AAA", "2026-01-03", 11.0, source_ref="https://example.test/AAA/2026-01-03"), + _price_row("BBB", "2026-01-03", 20.0), + _price_row("CCC", "2026-01-03", 30.0), + ] + ) + + review = review_dcf_price_lineage( + saved, + proposed, + prices, + rights_registry={"approved_prices": _rights("approved_prices")}, + review_cutoff="2026-01-05T00:00:00Z", + top_n=5, + ) + + assert review.status == "price_lineage_review_complete" + assert review.promotion_count == 1 + assert review.usable_latest_row_count == 1 + assert review.missing_latest_row_count == 0 + assert review.ambiguous_latest_row_count == 0 + assert review.lineage_complete_count == 1 + assert review.rights_approved_count == 1 + assert review.field_scope_complete_count == 1 + assert review.source_counts == (("approved_prices", 1),) + assert review.rights_status_counts == (("approved", 1),) + assert len(review.evidence_rows) == 1 + evidence = review.evidence_rows[0] + assert evidence.ticker == "AAA" + assert evidence.observation_date == "2026-01-03" + assert evidence.valid_row_count == 2 + assert evidence.latest_row_count == 1 + assert evidence.source_reference == "https://example.test/AAA/2026-01-03" + assert evidence.missing_provenance_fields == () + assert evidence.missing_supported_fields == () + assert evidence.blockers == () + + +@pytest.mark.parametrize( + ("prices", "blocker"), + [ + (pd.DataFrame(), "missing_latest_price_row"), + (pd.DataFrame([_price_row("AAA", "not-a-date", 10.0)]), "missing_latest_price_row"), + (pd.DataFrame([_price_row("AAA", "2026-01-03", 0)]), "missing_latest_price_row"), + ], +) +def test_review_fails_closed_when_no_usable_latest_row(prices: pd.DataFrame, blocker: str): + saved = pd.DataFrame([_readiness_row("AAA")]) + proposed = pd.DataFrame([_readiness_row("AAA", dcf_ready=True)]) + + review = review_dcf_price_lineage( + saved, + proposed, + prices, + rights_registry={"approved_prices": _rights("approved_prices")}, + ) + + assert review.status == "price_lineage_review_required" + assert review.usable_latest_row_count == 0 + assert review.missing_latest_row_count == 1 + assert review.lineage_complete_count == 0 + assert review.rights_approved_count == 0 + assert review.field_scope_complete_count == 0 + assert review.evidence_rows[0].source_id == "" + assert review.evidence_rows[0].rights_status == "not_evaluated_missing_evidence" + assert review.evidence_rows[0].blockers == (blocker,) + + +def test_review_fails_closed_on_duplicate_latest_price_rows(): + saved = pd.DataFrame([_readiness_row("AAA")]) + proposed = pd.DataFrame([_readiness_row("AAA", dcf_ready=True)]) + prices = pd.DataFrame( + [ + _price_row("AAA", "2026-01-02", 9.0), + _price_row("AAA", "2026-01-03", 10.0), + _price_row("AAA", "2026-01-03", 10.0), + ] + ) + + review = review_dcf_price_lineage( + saved, + proposed, + prices, + rights_registry={"approved_prices": _rights("approved_prices")}, + ) + + assert review.ambiguous_latest_row_count == 1 + assert review.missing_latest_row_count == 0 + assert review.evidence_rows[0].source_id == "" + assert review.evidence_rows[0].latest_row_count == 2 + assert review.evidence_rows[0].rights_status == "not_evaluated_ambiguous_evidence" + assert review.evidence_rows[0].blockers == ("ambiguous_latest_price_row",) + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("source", ""), + ("source_ref", ""), + ("retrieved_at", ""), + ], +) +def test_review_reports_each_missing_provenance_field_independently(field: str, value: str): + saved = pd.DataFrame([_readiness_row("AAA")]) + proposed = pd.DataFrame([_readiness_row("AAA", dcf_ready=True)]) + row = _price_row("AAA", "2026-01-03", 10.0) + row[field] = value + + review = review_dcf_price_lineage( + saved, + proposed, + pd.DataFrame([row]), + rights_registry={"approved_prices": _rights("approved_prices")}, + review_cutoff="2026-01-05T00:00:00Z", + ) + + evidence = review.evidence_rows[0] + assert review.status == "price_lineage_review_required" + assert review.lineage_complete_count == 0 + assert field in evidence.missing_provenance_fields + assert f"missing_provenance:{field}" in evidence.blockers + + +@pytest.mark.parametrize( + ("retrieved_at", "cutoff", "blocker"), + [ + ("2026-01-03T23:00:00", "2026-01-05T00:00:00Z", "retrieved_at_timezone_required"), + ("2026-01-03T23:59:59Z", "2026-01-05T00:00:00Z", "retrieved_before_observation_available"), + ("2026-01-05T00:00:01Z", "2026-01-05T00:00:00Z", "retrieved_after_review_cutoff"), + ("2026-01-04T23:00:00Z", None, "review_cutoff_required"), + ], +) +def test_review_uses_shared_price_temporal_gate( + retrieved_at: str, + cutoff: str | None, + blocker: str, +): + saved = pd.DataFrame([_readiness_row("AAA")]) + proposed = pd.DataFrame([_readiness_row("AAA", dcf_ready=True)]) + prices = pd.DataFrame( + [_price_row("AAA", "2026-01-03", 10.0, retrieved_at=retrieved_at)] + ) + + review = review_dcf_price_lineage( + saved, + proposed, + prices, + rights_registry={"approved_prices": _rights("approved_prices")}, + review_cutoff=cutoff, + ) + + evidence = review.evidence_rows[0] + assert review.status == "price_lineage_review_required" + assert blocker in evidence.blockers + + +@pytest.mark.parametrize( + ("source_id", "registry", "rights_status"), + [ + ("unknown_prices", {}, "unknown_source"), + ("approved_prices; other", {"approved_prices": _rights("approved_prices")}, "unknown_source"), + ( + "research_prices", + {"research_prices": _rights("research_prices", commercial_use="unverified")}, + "commercial_rights_unverified", + ), + ], +) +def test_review_evaluates_exact_source_without_inference( + source_id: str, + registry: dict[str, SourceRights], + rights_status: str, +): + saved = pd.DataFrame([_readiness_row("AAA")]) + proposed = pd.DataFrame([_readiness_row("AAA", dcf_ready=True)]) + + review = review_dcf_price_lineage( + saved, + proposed, + pd.DataFrame([_price_row("AAA", "2026-01-03", 10.0, source=source_id)]), + rights_registry=registry, + review_cutoff="2026-01-05T00:00:00Z", + ) + + assert review.rights_approved_count == 0 + assert review.evidence_rows[0].source_id == source_id + assert review.evidence_rows[0].rights_status == rights_status + assert f"commercial_rights:{rights_status}" in review.evidence_rows[0].blockers + + +def test_review_keeps_approved_rights_separate_from_registered_price_scope(): + saved = pd.DataFrame([_readiness_row("AAA")]) + proposed = pd.DataFrame([_readiness_row("AAA", dcf_ready=True)]) + + review = review_dcf_price_lineage( + saved, + proposed, + pd.DataFrame([_price_row("AAA", "2026-01-03", 10.0)]), + rights_registry={"approved_prices": _rights("approved_prices", supported_fields=("revenue",))}, + review_cutoff="2026-01-05T00:00:00Z", + ) + + assert review.status == "price_lineage_review_required" + assert review.rights_approved_count == 1 + assert review.field_scope_complete_count == 0 + assert review.evidence_rows[0].missing_supported_fields == ("prices",) + assert "registered_price_scope_incomplete" in review.evidence_rows[0].blockers + + +def test_review_caps_evidence_without_changing_totals_and_validates_top_n(): + tickers = ["AAA", "BBB", "CCC"] + saved = pd.DataFrame([_readiness_row(ticker) for ticker in tickers]) + proposed = pd.DataFrame([_readiness_row(ticker, dcf_ready=True) for ticker in tickers]) + prices = pd.DataFrame([_price_row(ticker, "2026-01-03", 10.0) for ticker in tickers]) + + review = review_dcf_price_lineage( + saved, + proposed, + prices, + rights_registry={"approved_prices": _rights("approved_prices")}, + review_cutoff="2026-01-05T00:00:00Z", + top_n=1, + ) + + assert review.promotion_count == 3 + assert review.usable_latest_row_count == 3 + assert review.lineage_complete_count == 3 + assert len(review.evidence_rows) == 1 + assert review.evidence_rows[0].ticker == "AAA" + + with pytest.raises(ValueError, match="top_n must be at least 1"): + review_dcf_price_lineage(saved, proposed, prices, rights_registry={}, top_n=0) + + +def test_review_reports_no_dcf_promotions_without_inspecting_prices(): + saved = pd.DataFrame([_readiness_row("AAA")]) + proposed = pd.DataFrame([_readiness_row("AAA", fundamentals_ready=True)]) + + review = review_dcf_price_lineage(saved, proposed, pd.DataFrame(), rights_registry={}, top_n=5) + + assert review.status == "no_dcf_promotions" + assert review.promotion_count == 0 + assert review.evidence_rows == () diff --git a/tests/test_diff_hygiene.py b/tests/test_diff_hygiene.py index f8f23dd4..4acabbb7 100644 --- a/tests/test_diff_hygiene.py +++ b/tests/test_diff_hygiene.py @@ -2,6 +2,7 @@ import importlib.util import json +import subprocess import sys from pathlib import Path @@ -27,6 +28,42 @@ def test_diff_hygiene_classifies_generated_csv_churn_separately(): assert module.classify_path("outputs/decision_proof_queue.md") == "generated_csv_churn" +def test_pr_range_hygiene_inspects_committed_range_not_clean_worktree(tmp_path: Path): + module = load_diff_hygiene_module() + repo = tmp_path / "repo" + repo.mkdir() + + def git(*args: str) -> str: + result = subprocess.run( + ["git", *args], cwd=repo, check=True, capture_output=True, text=True + ) + return result.stdout.strip() + + git("init", "-b", "main") + git("config", "user.name", "Range Test") + git("config", "user.email", "range@example.invalid") + (repo / "README.md").write_text("base\n", encoding="utf-8") + git("add", "README.md") + git("commit", "-m", "base") + base_sha = git("rev-parse", "HEAD") + generated = repo / "data" / "reports" / "readiness.json" + generated.parent.mkdir(parents=True) + generated.write_text("{}\n", encoding="utf-8") + git("add", "data/reports/readiness.json") + git("commit", "-m", "generated") + head_sha = git("rev-parse", "HEAD") + + assert git("status", "--porcelain") == "" + entries = module.load_range_status(repo, base_sha, head_sha) + report = module.build_range_check_report(entries, base_sha, head_sha) + + assert entries == [module.StatusEntry("A", "data/reports/readiness.json")] + assert module.range_hygiene_has_blockers(entries) is True + assert "Pull Request Range Hygiene Check" in report + assert "data/reports/readiness.json" in report + assert "failed" in report.lower() + + def test_diff_hygiene_keeps_markdown_reports_as_reviewable_examples(): module = load_diff_hygiene_module() @@ -39,6 +76,7 @@ def test_diff_hygiene_classifies_product_files_as_commit_candidates(): for path in ( ".gitignore", + ".github/workflows/commercial-research-beta.yml", ".streamlit/config.toml", ".streamlit/secrets.toml.example", "README.md", @@ -46,6 +84,7 @@ def test_diff_hygiene_classifies_product_files_as_commit_candidates(): "requirements.txt", "config/hosted_demo.env.example", "config/provider_keys.env.example", + "config/source_rights.yml", "docs/DIFF_HYGIENE_AUDIT.md", "src/dashboard.py", "tests/test_launchers.py", diff --git a/tests/test_earnings_consensus_collector.py b/tests/test_earnings_consensus_collector.py new file mode 100644 index 00000000..67703d39 --- /dev/null +++ b/tests/test_earnings_consensus_collector.py @@ -0,0 +1,740 @@ +import csv +import json +from dataclasses import asdict, replace +from pathlib import Path + +import pytest + +from src.earnings_consensus_collector import ( + FIELDS, + ProspectiveConsensusRecord, + append_reviewed_batch, + append_reviewed_snapshot, + collection_plan, + load_snapshots, + main, + preview_collection, + preview_collection_batch, +) +from src.commercial_source_rights import build_source_rights_registry + + +def _record(**overrides) -> ProspectiveConsensusRecord: + values = { + "schema_version": "earnings-consensus-prospective-v1", + "snapshot_id": "snap-001", + "ticker": "NVDA", + "fiscal_period": "2027-Q1", + "snapshot_at": "2026-07-18T05:00:00Z", + "retrieved_at": "2026-07-18T05:00:01Z", + "source": "reviewed_csv", + "source_ref": "file://reviewed/NVDA/2027-Q1/20260718", + "revenue_consensus": "100", + "eps_consensus": "1.00", + "revenue_currency": "USD", + "revenue_unit_scale": "1000000", + "revenue_basis": "reported", + "eps_currency": "USD", + "eps_basis": "gaap", + "eps_share_basis": "diluted", + "eps_operations_basis": "reported", + "split_adjustment_basis": "as_reported", + "expected_report_date": "2026-08-20", + "review_state": "reviewed", + "supersedes_snapshot_id": "", + } + values.update(overrides) + return ProspectiveConsensusRecord(**values) + + +def _rights_registry(*, source_id: str = "licensed_consensus", commercial_use: str = "approved", supported_fields=None): + return build_source_rights_registry( + [ + { + "source_id": source_id, + "display_name": "Licensed consensus fixture", + "permitted_use": "test_only", + "commercial_use": commercial_use, + "redistribution": "test_only", + "storage_limits": "temporary test paths only", + "attribution": "fixture", + "rate_limits": "not applicable", + "authentication": "not applicable", + "expected_freshness": "fixture cutoff", + "supported_fields": supported_fields or ["revenue_consensus", "eps_consensus"], + "fallback_priority": 1, + } + ] + ) + + +def _write_records(path: Path, records: tuple[ProspectiveConsensusRecord, ...]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", newline="", encoding="utf-8") as handle: + writer = csv.DictWriter(handle, fieldnames=FIELDS) + writer.writeheader() + writer.writerows(asdict(record) for record in records) + + +def _revision(record: ProspectiveConsensusRecord, *, snapshot_id: str = "snap-002") -> ProspectiveConsensusRecord: + return replace( + record, + snapshot_id=snapshot_id, + snapshot_at="2026-07-25T05:00:00Z", + retrieved_at="2026-07-25T05:00:01Z", + source_ref=f"file://reviewed/{record.ticker}/{record.fiscal_period}/20260725", + revenue_consensus="102", + supersedes_snapshot_id=record.snapshot_id, + ) + + +def _append_with_preview( + ledger: Path, + records: tuple[ProspectiveConsensusRecord, ...], + *, + cutoff: str = "2026-08-02T00:00:00Z", + confirm_reviewed: bool = True, + commercial_mode: bool | None = False, + rights_registry=None, +) -> Path: + existing = load_snapshots(ledger) + preview = preview_collection_batch( + existing, + records, + as_of=cutoff, + commercial_mode=commercial_mode, + rights_registry=rights_registry, + ) + return append_reviewed_batch( + ledger, + records, + confirm_reviewed=confirm_reviewed, + commercial_mode=commercial_mode, + rights_registry=rights_registry, + review_cutoff=cutoff, + preview_receipt=preview.preview_receipt, + ) + + +def _record_cli_args( + input_path: Path, + ledger: Path, + records: tuple[ProspectiveConsensusRecord, ...], + *, + cutoff: str = "2026-08-02T00:00:00Z", +) -> list[str]: + preview = preview_collection_batch(load_snapshots(ledger), records, as_of=cutoff) + return [ + "record", + "--input", + str(input_path), + "--ledger", + str(ledger), + "--as-of", + cutoff, + "--preview-receipt", + preview.preview_receipt, + "--confirm-reviewed", + ] + + +def test_preview_detects_duplicate_and_preserves_revision_lineage(): + first = _record() + duplicate = preview_collection((first,), first, as_of="2026-07-18T06:00:00Z") + revision = preview_collection( + (first,), + replace( + first, + snapshot_id="snap-002", + snapshot_at="2026-07-25T05:00:00Z", + retrieved_at="2026-07-25T05:00:01Z", + source_ref="file://reviewed/NVDA/2027-Q1/20260725", + revenue_consensus="102", + supersedes_snapshot_id="snap-001", + ), + as_of="2026-07-25T06:00:00Z", + ) + + assert duplicate.state == "duplicate" + assert revision.state == "reviewable_revision" + assert revision.write_allowed is True + + +def test_preview_rejects_post_cutoff_and_cooldown_recollection(): + first = _record() + post_cutoff = preview_collection( + (), + replace( + first, + snapshot_at="2026-07-19T05:00:00Z", + retrieved_at="2026-07-19T05:00:01Z", + ), + as_of="2026-07-18T06:00:00Z", + ) + cooldown = preview_collection( + (first,), + replace( + first, + snapshot_id="snap-002", + snapshot_at="2026-07-18T06:00:00Z", + retrieved_at="2026-07-18T06:00:01Z", + source_ref="file://reviewed/NVDA/2027-Q1/20260718-2", + supersedes_snapshot_id="snap-001", + ), + as_of="2026-07-18T07:00:00Z", + cooldown_hours=24, + ) + + assert post_cutoff.state == "rejected" + assert "cutoff" in post_cutoff.reason + assert cooldown.state == "cooldown" + assert cooldown.write_allowed is False + + +def test_append_requires_explicit_review_confirmation_and_never_overwrites(tmp_path: Path): + ledger = tmp_path / "snapshots.csv" + with pytest.raises(ValueError, match="confirm_reviewed"): + append_reviewed_snapshot(ledger, _record(), confirm_reviewed=False) + + _append_with_preview(ledger, (_record(),)) + with pytest.raises(ValueError, match="duplicate"): + _append_with_preview(ledger, (_record(),)) + + assert ledger.read_text(encoding="utf-8").count("snap-001") == 1 + + +def test_collection_plan_is_scheduler_ready_but_does_not_collect(): + plan = collection_plan( + tickers=("NVDA", "AMD"), + as_of="2026-07-18T05:00:00Z", + cadence="weekly", + ) + + assert plan.mode == "plan_only" + assert plan.collection_performed is False + assert plan.tickers == ("AMD", "NVDA") + assert plan.next_action == "Run a provider probe or reviewed CSV preview at the planned cutoff." + + +def test_collector_rejects_invalid_period_contract(): + preview = preview_collection( + (), + _record(fiscal_period="next-quarter"), + as_of="2026-07-18T06:00:00Z", + rights_registry=_rights_registry(source_id="reviewed_csv"), + ) + assert preview.state == "rejected" + assert "fiscal_period" in preview.reason + assert preview.commercial_evidence_ready is True + assert preview.commercial_write_allowed is False + + +def test_preview_reports_independent_revenue_and_eps_commercial_scope(): + registry = _rights_registry(supported_fields=["revenue_consensus"]) + eps_registry = _rights_registry(supported_fields=["eps_consensus"]) + revenue_only = preview_collection( + (), + _record(source="licensed_consensus", eps_consensus=""), + as_of="2026-07-18T06:00:00Z", + rights_registry=registry, + ) + mixed = preview_collection( + (), + _record(source="licensed_consensus"), + as_of="2026-07-18T06:00:00Z", + rights_registry=registry, + ) + eps_only = preview_collection( + (), + _record(source="licensed_consensus", revenue_consensus=""), + as_of="2026-07-18T06:00:00Z", + rights_registry=eps_registry, + ) + + assert revenue_only.required_supported_fields == ("revenue_consensus",) + assert revenue_only.missing_supported_fields == () + assert revenue_only.commercial_rights_approved is True + assert revenue_only.commercial_evidence_ready is True + assert revenue_only.commercial_write_allowed is True + assert eps_only.required_supported_fields == ("eps_consensus",) + assert eps_only.missing_supported_fields == () + assert eps_only.commercial_write_allowed is True + assert mixed.required_supported_fields == ("revenue_consensus", "eps_consensus") + assert mixed.missing_supported_fields == ("eps_consensus",) + assert mixed.write_allowed is True + assert mixed.commercial_write_allowed is False + assert mixed.commercial_blockers == ("registered_consensus_scope_missing:eps_consensus",) + + +def test_preview_fails_closed_for_unknown_unverified_and_composite_exact_sources(): + unverified_registry = _rights_registry(commercial_use="unverified") + unverified = preview_collection( + (), + _record(source="licensed_consensus"), + as_of="2026-07-18T06:00:00Z", + rights_registry=unverified_registry, + ) + composite = preview_collection( + (), + _record(source="licensed_consensus + reviewed_csv"), + as_of="2026-07-18T06:00:00Z", + rights_registry=_rights_registry(), + ) + + assert unverified.rights_status == "commercial_rights_unverified" + assert unverified.write_allowed is True + assert unverified.commercial_write_allowed is False + assert unverified.commercial_blockers[0] == "commercial_rights:commercial_rights_unverified" + assert composite.rights_status == "unknown_source" + assert composite.commercial_rights_approved is False + assert composite.missing_supported_fields == ("revenue_consensus", "eps_consensus") + assert composite.commercial_write_allowed is False + + +def test_commercial_append_blocks_before_filesystem_mutation(tmp_path: Path, monkeypatch): + ledger = tmp_path / "missing-parent" / "snapshots.csv" + monkeypatch.setenv("COMMERCIAL_RESEARCH_MODE", "1") + + with pytest.raises(ValueError, match="commercial_evidence_review_required"): + _append_with_preview( + ledger, + (_record(),), + commercial_mode=None, + rights_registry=_rights_registry(), + ) + + assert not ledger.parent.exists() + + +def test_approved_commercial_append_uses_exact_metric_scopes(tmp_path: Path): + ledger = tmp_path / "snapshots.csv" + record = _record(source="licensed_consensus") + + _append_with_preview( + ledger, + (record,), + commercial_mode=True, + rights_registry=_rights_registry(), + ) + + assert load_snapshots(ledger) == (record,) + + +def test_record_preflights_whole_batch_before_any_append(tmp_path: Path): + input_path = tmp_path / "input.csv" + ledger = tmp_path / "ledger.csv" + first = _record() + _write_records(input_path, (first, first)) + + with pytest.raises(ValueError, match="duplicate"): + main(_record_cli_args(input_path, ledger, (first, first))) + + assert not ledger.exists() + + +def test_record_cli_appends_only_the_exact_reviewed_preview(tmp_path: Path): + input_path = tmp_path / "input.csv" + ledger = tmp_path / "ledger.csv" + proposed = (_record(),) + _write_records(input_path, proposed) + + main(_record_cli_args(input_path, ledger, proposed)) + + assert load_snapshots(ledger) == proposed + + +def test_append_requires_cutoff_and_preview_receipt_before_mutation(tmp_path: Path): + ledger = tmp_path / "missing-parent" / "ledger.csv" + + with pytest.raises(ValueError, match="review_cutoff"): + append_reviewed_batch(ledger, (_record(),), confirm_reviewed=True) + with pytest.raises(ValueError, match="preview_receipt"): + append_reviewed_batch( + ledger, + (_record(),), + confirm_reviewed=True, + review_cutoff="2026-07-18T06:00:00Z", + ) + + assert not ledger.parent.exists() + + +def test_preview_uses_ordered_virtual_ledger_for_revision(tmp_path: Path, capsys): + input_path = tmp_path / "input.csv" + first = _record() + _write_records(input_path, (first, _revision(first))) + + main( + [ + "preview", + "--input", + str(input_path), + "--ledger", + str(tmp_path / "missing.csv"), + "--as-of", + "2026-07-26T00:00:00Z", + ] + ) + payload = json.loads(capsys.readouterr().out) + + assert payload.get("state") == "reviewable_batch" + assert [row["state"] for row in payload["rows"]] == ["reviewable_new", "reviewable_revision"] + assert payload["technical_write_allowed"] is True + + +def test_preview_detects_intra_batch_duplicate(tmp_path: Path, capsys): + input_path = tmp_path / "input.csv" + first = _record() + _write_records(input_path, (first, first)) + + main( + [ + "preview", + "--input", + str(input_path), + "--ledger", + str(tmp_path / "missing.csv"), + "--as-of", + "2026-07-26T00:00:00Z", + ] + ) + payload = json.loads(capsys.readouterr().out) + + assert payload.get("state") == "rejected_batch" + assert [row["state"] for row in payload["rows"]] == ["reviewable_new", "duplicate"] + assert payload["technical_write_allowed"] is False + + +def test_preview_does_not_reorder_reversed_revision_chain(tmp_path: Path, capsys): + input_path = tmp_path / "input.csv" + first = _record() + revision = _revision(first) + _write_records(input_path, (revision, first)) + + main( + [ + "preview", + "--input", + str(input_path), + "--ledger", + str(tmp_path / "missing.csv"), + "--as-of", + "2026-07-26T00:00:00Z", + ] + ) + payload = json.loads(capsys.readouterr().out) + + assert payload.get("state") == "rejected_batch" + assert payload["rows"][0]["state"] == "rejected" + assert "supersedes_snapshot_id does not exist" in payload["rows"][0]["reason"] + assert payload["rows"][1]["state"] == "reviewable_new" + + +def test_record_rejects_empty_batch_without_creating_destination(tmp_path: Path): + input_path = tmp_path / "input.csv" + ledger = tmp_path / "missing-parent" / "ledger.csv" + _write_records(input_path, ()) + + with pytest.raises(ValueError, match="empty_batch"): + main(_record_cli_args(input_path, ledger, ())) + + assert not ledger.parent.exists() + + +def test_batch_append_preserves_existing_bytes_when_later_row_is_invalid(tmp_path: Path): + ledger = tmp_path / "ledger.csv" + _append_with_preview(ledger, (_record(),)) + original_bytes = ledger.read_bytes() + first = _record( + snapshot_id="snap-010", + ticker="AMD", + source_ref="file://reviewed/AMD/2027-Q1/20260718", + ) + conflict = replace( + first, + snapshot_id="snap-011", + snapshot_at="2026-07-19T05:00:00Z", + retrieved_at="2026-07-19T05:00:01Z", + source_ref="file://reviewed/AMD/2027-Q1/20260719", + ) + + with pytest.raises(ValueError, match="later same-period"): + _append_with_preview(ledger, (first, conflict)) + + assert ledger.read_bytes() == original_bytes + + +def test_batch_append_blocks_later_commercial_rights_failure_before_mutation(tmp_path: Path): + ledger = tmp_path / "missing-parent" / "ledger.csv" + first = _record(source="licensed_consensus") + revision = replace(_revision(first), source="unregistered_consensus") + + with pytest.raises(ValueError, match="batch_commercial_evidence_review_required"): + _append_with_preview( + ledger, + (first, revision), + commercial_mode=True, + rights_registry=_rights_registry(), + ) + + assert not ledger.parent.exists() + + +def test_batch_append_records_valid_ordered_research_revision_chain(tmp_path: Path): + ledger = tmp_path / "ledger.csv" + first = _record() + revision = _revision(first) + + _append_with_preview(ledger, (first, revision), commercial_mode=False) + + assert load_snapshots(ledger) == (first, revision) + + +def test_batch_append_records_valid_ordered_commercial_revision_chain(tmp_path: Path): + ledger = tmp_path / "ledger.csv" + first = _record(source="licensed_consensus") + revision = _revision(first) + + _append_with_preview( + ledger, + (first, revision), + commercial_mode=True, + rights_registry=_rights_registry(), + ) + + assert load_snapshots(ledger) == (first, revision) + + +def test_batch_append_rejects_empty_sequence_without_mutation(tmp_path: Path): + ledger = tmp_path / "missing-parent" / "ledger.csv" + + with pytest.raises(ValueError, match="empty_batch"): + _append_with_preview(ledger, ()) + + assert not ledger.parent.exists() + + +def test_existing_ledger_rows_are_validated_with_row_numbers_before_status(tmp_path: Path): + ledger = tmp_path / "ledger.csv" + _write_records(ledger, (_record(expected_report_date="not-a-date"),)) + + with pytest.raises(ValueError, match=r"ledger row 2:.*expected_report_date"): + main(["status", "--ledger", str(ledger)]) + + +def test_existing_ledger_rejects_duplicate_ids_and_evidence_identities(tmp_path: Path): + duplicate_id = tmp_path / "duplicate-id.csv" + duplicate_identity = tmp_path / "duplicate-identity.csv" + first = _record() + _write_records( + duplicate_id, + ( + first, + replace( + first, + ticker="AMD", + source_ref="file://reviewed/AMD/2027-Q1/20260718", + ), + ), + ) + _write_records( + duplicate_identity, + (first, replace(first, snapshot_id="snap-duplicate-evidence")), + ) + + with pytest.raises(ValueError, match="duplicate snapshot_id"): + load_snapshots(duplicate_id) + with pytest.raises(ValueError, match="duplicate snapshot identity"): + load_snapshots(duplicate_identity) + + +@pytest.mark.parametrize( + ("rows", "reason"), + [ + ( + lambda first: ( + first, + replace( + _revision(first), + supersedes_snapshot_id="missing-parent", + ), + ), + "missing parent", + ), + ( + lambda first: ( + first, + _revision(first), + replace( + _revision(first, snapshot_id="snap-003"), + snapshot_at="2026-08-01T05:00:00Z", + retrieved_at="2026-08-01T05:00:01Z", + source_ref="file://reviewed/NVDA/2027-Q1/20260801", + ), + ), + "fork", + ), + ( + lambda first: ( + replace(first, supersedes_snapshot_id="snap-002"), + replace(_revision(first), supersedes_snapshot_id="snap-001"), + ), + "cycle", + ), + ( + lambda first: ( + first, + replace( + _revision(first), + snapshot_at="2026-07-17T05:00:00Z", + retrieved_at="2026-07-17T05:00:01Z", + ), + ), + "later than parent", + ), + ( + lambda first: ( + first, + replace( + first, + snapshot_id="snap-second-root", + snapshot_at="2026-07-25T05:00:00Z", + retrieved_at="2026-07-25T05:00:01Z", + source_ref="file://reviewed/NVDA/2027-Q1/20260725", + revenue_consensus="102", + ), + ), + "one root", + ), + ], +) +def test_existing_ledger_requires_one_linear_revision_chain(tmp_path: Path, rows, reason: str): + ledger = tmp_path / "ledger.csv" + _write_records(ledger, rows(_record())) + + with pytest.raises(ValueError, match=reason): + load_snapshots(ledger) + + +def test_preview_rejects_superseding_a_non_leaf_snapshot(): + first = _record() + second = _revision(first) + proposed = replace( + _revision(first, snapshot_id="snap-003"), + snapshot_at="2026-08-01T05:00:00Z", + retrieved_at="2026-08-01T05:00:01Z", + source_ref="file://reviewed/NVDA/2027-Q1/20260801", + ) + + preview = preview_collection( + (first, second), + proposed, + as_of="2026-08-01T06:00:00Z", + ) + + assert preview.state == "rejected" + assert "current leaf" in preview.reason + + +def test_record_is_bound_to_exact_preview_cutoff_input_and_ledger_state(tmp_path: Path): + cutoff = "2026-07-18T06:00:00Z" + ledger = tmp_path / "ledger.csv" + proposed = (_record(),) + preview = preview_collection_batch( + (), + proposed, + as_of=cutoff, + commercial_mode=False, + ) + + assert preview.review_cutoff == "2026-07-18T06:00:00+00:00" + assert len(preview.input_digest) == 64 + assert len(preview.ledger_digest) == 64 + assert len(preview.preview_receipt) == 64 + + with pytest.raises(ValueError, match="preview receipt mismatch"): + append_reviewed_batch( + ledger, + proposed, + confirm_reviewed=True, + commercial_mode=False, + review_cutoff="2026-07-18T07:00:00Z", + preview_receipt=preview.preview_receipt, + ) + assert not ledger.exists() + + append_reviewed_batch( + ledger, + proposed, + confirm_reviewed=True, + commercial_mode=False, + review_cutoff=cutoff, + preview_receipt=preview.preview_receipt, + ) + assert load_snapshots(ledger) == proposed + + +def test_changed_existing_ledger_invalidates_preview_receipt_without_mutation(tmp_path: Path): + cutoff = "2026-07-26T00:00:00Z" + ledger = tmp_path / "ledger.csv" + first = _record() + proposed = (_revision(first),) + preview = preview_collection_batch( + (first,), + proposed, + as_of=cutoff, + commercial_mode=False, + ) + append_reviewed_batch( + ledger, + (first,), + confirm_reviewed=True, + commercial_mode=False, + review_cutoff="2026-07-18T06:00:00Z", + preview_receipt=preview_collection_batch( + (), + (first,), + as_of="2026-07-18T06:00:00Z", + commercial_mode=False, + ).preview_receipt, + ) + original = ledger.read_bytes() + append_reviewed_batch( + ledger, + ( + replace( + proposed[0], + snapshot_id="intervening", + source_ref="file://reviewed/NVDA/2027-Q1/intervening", + ), + ), + confirm_reviewed=True, + commercial_mode=False, + review_cutoff=cutoff, + preview_receipt=preview_collection_batch( + (first,), + ( + replace( + proposed[0], + snapshot_id="intervening", + source_ref="file://reviewed/NVDA/2027-Q1/intervening", + ), + ), + as_of=cutoff, + commercial_mode=False, + ).preview_receipt, + ) + changed = ledger.read_bytes() + + with pytest.raises(ValueError, match="preview receipt mismatch"): + append_reviewed_batch( + ledger, + proposed, + confirm_reviewed=True, + commercial_mode=False, + review_cutoff=cutoff, + preview_receipt=preview.preview_receipt, + ) + + assert changed != original + assert ledger.read_bytes() == changed diff --git a/tests/test_earnings_consensus_sources.py b/tests/test_earnings_consensus_sources.py new file mode 100644 index 00000000..5be42611 --- /dev/null +++ b/tests/test_earnings_consensus_sources.py @@ -0,0 +1,533 @@ +from pathlib import Path +import json +import os +import subprocess +import sys + +import pytest + +from src.commercial_source_rights import build_source_rights_registry +from src.earnings_consensus_sources import ( + consensus_source_statuses, + load_source_review_csv, + render_source_validation_result, + validate_source_rows, +) + + +REVIEW_CUTOFF = "2026-07-18T06:00:00Z" +HISTORICAL_REVIEW_CSV = ( + "ticker,fiscal_period,snapshot_at,retrieved_at,source_ref,revenue_consensus,eps_consensus,history_scope," + "revenue_currency,revenue_unit_scale,revenue_basis,eps_currency,eps_basis,eps_share_basis,eps_operations_basis,split_adjustment_basis\n" + "NVDA,2027-Q1,2026-07-18T05:00:00Z,2026-07-18T05:00:01Z,fixture://consensus/NVDA/2027-Q1,1,,point_in_time," + "USD,1,reported,USD,gaap,diluted,reported,as_reported\n" +) + + +def _rights_registry( + *, + source_id: str = "licensed_consensus", + commercial_use: str = "approved", + supported_fields: tuple[str, ...] | None = None, +): + return build_source_rights_registry( + [ + { + "source_id": source_id, + "display_name": "Licensed consensus fixture", + "permitted_use": "test_only", + "commercial_use": commercial_use, + "redistribution": "test_only", + "storage_limits": "temporary in-memory tests only", + "attribution": "fixture", + "rate_limits": "not applicable", + "authentication": "not applicable", + "expected_freshness": "fixture cutoff", + "supported_fields": list( + supported_fields or ("revenue_consensus", "eps_consensus") + ), + "fallback_priority": 1, + } + ] + ) + + +def _current_row(**overrides: object) -> dict[str, object]: + row: dict[str, object] = { + "ticker": "NVDA", + "fiscal_period": "2027-Q1", + "snapshot_at": "2026-07-18T05:00:00Z", + "retrieved_at": "2026-07-18T05:00:01Z", + "source_ref": "provider://consensus/current/NVDA/2027-Q1", + "revenue_consensus": "1", + "eps_consensus": "1", + "history_scope": "current_only", + } + row.update(overrides) + return row + + +def _historical_row(**overrides: object) -> dict[str, object]: + row = _current_row( + history_scope="point_in_time", + source_ref="provider://consensus/history/NVDA/2027-Q1/20260718", + revenue_currency="USD", + revenue_unit_scale="1", + revenue_basis="reported", + eps_currency="USD", + eps_basis="gaap", + eps_share_basis="diluted", + eps_operations_basis="reported", + split_adjustment_basis="as_reported", + ) + row.update(overrides) + return row + + +def test_load_source_review_csv_preserves_rows_without_writing(tmp_path: Path): + input_path = tmp_path / "reviewed.csv" + input_path.write_text( + "ticker,fiscal_period,snapshot_at,retrieved_at,source_ref,revenue_consensus,eps_consensus,history_scope\n" + "NVDA,2027-Q1,2026-07-18T05:00:00Z,2026-07-18T05:00:01Z,fixture://NVDA,1,2,current_only\n", + encoding="utf-8", + ) + before = input_path.read_bytes() + + rows = load_source_review_csv(input_path) + + assert rows == ( + { + "ticker": "NVDA", + "fiscal_period": "2027-Q1", + "snapshot_at": "2026-07-18T05:00:00Z", + "retrieved_at": "2026-07-18T05:00:01Z", + "source_ref": "fixture://NVDA", + "revenue_consensus": "1", + "eps_consensus": "2", + "history_scope": "current_only", + }, + ) + assert input_path.read_bytes() == before + assert {path.name for path in tmp_path.iterdir()} == {"reviewed.csv"} + + +@pytest.mark.parametrize( + ("contents", "message"), + [ + ("", "header row"), + ("ticker,ticker\nNVDA,NVDA\n", "non-blank unique column names"), + ("ticker,,fiscal_period\nNVDA,x,2027-Q1\n", "non-blank unique column names"), + ("ticker\nNVDA,extra\n", "more values than the header"), + ], +) +def test_load_source_review_csv_rejects_ambiguous_shapes( + tmp_path: Path, + contents: str, + message: str, +): + input_path = tmp_path / "reviewed.csv" + input_path.write_text(contents, encoding="utf-8") + + with pytest.raises(ValueError, match=message): + load_source_review_csv(input_path) + + +def test_load_source_review_csv_reports_unreadable_input(tmp_path: Path): + with pytest.raises(ValueError, match="cannot read consensus source review CSV"): + load_source_review_csv(tmp_path / "missing.csv") + with pytest.raises(ValueError, match="cannot read consensus source review CSV"): + load_source_review_csv(tmp_path) + + +def test_load_source_review_csv_accepts_header_only_as_empty_review(tmp_path: Path): + input_path = tmp_path / "reviewed.csv" + input_path.write_text("ticker,fiscal_period\n", encoding="utf-8") + + rows = load_source_review_csv(input_path) + result = validate_source_rows( + "reviewed_csv", + rows, + as_of=REVIEW_CUTOFF, + ) + + assert rows == () + assert result.state == "still_blocked" + assert result.accepted_count == 0 + assert result.auto_apply is False + + +def test_render_source_validation_result_keeps_technical_and_commercial_evidence_separate(): + result = validate_source_rows( + "licensed_consensus", + [ + _historical_row(eps_consensus=""), + _current_row(retrieved_at="2026-07-18T06:00:01Z"), + ], + as_of=REVIEW_CUTOFF, + rights_registry=_rights_registry(supported_fields=("revenue_consensus",)), + ) + + rendered = render_source_validation_result(result) + + assert "Consensus Source Review" in rendered + assert "state: historical_evidence_reviewable" in rendered + assert "accepted_count: 1" in rendered + assert "rejected_count: 1" in rendered + assert "commercial_evidence_ready: true" in rendered + assert "- row 2: retrieved_at is after review cutoff" in rendered + assert "- row 1: required=revenue_consensus; missing=none; ready=true; blockers=none" in rendered + assert "auto_apply: false" in rendered + assert "collection preview remains a separate reviewed gate" in rendered + + +def _run_source_cli(*args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, "-m", "src.earnings_consensus_sources", *args], + check=False, + capture_output=True, + text=True, + env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"}, + ) + + +def test_source_review_cli_renders_supplied_rows_without_artifacts(tmp_path: Path): + input_path = tmp_path / "reviewed.csv" + input_path.write_text(HISTORICAL_REVIEW_CSV, encoding="utf-8") + before = input_path.read_bytes() + + result = _run_source_cli( + "--review-csv", + str(input_path), + "--provider", + "sec_companyfacts", + "--as-of", + REVIEW_CUTOFF, + ) + + assert result.returncode == 0 + assert "state: historical_evidence_reviewable" in result.stdout + assert "rights_status: approved" in result.stdout + assert "registered_consensus_scope_missing:revenue_consensus" in result.stdout + assert input_path.read_bytes() == before + assert {path.name for path in tmp_path.iterdir()} == {"reviewed.csv"} + + +def test_source_review_cli_json_matches_result_contract(tmp_path: Path): + input_path = tmp_path / "reviewed.csv" + input_path.write_text(HISTORICAL_REVIEW_CSV, encoding="utf-8") + + result = _run_source_cli( + "--review-csv", + str(input_path), + "--provider", + "sec_companyfacts", + "--as-of", + REVIEW_CUTOFF, + "--json", + ) + + payload = json.loads(result.stdout) + assert result.returncode == 0 + assert payload["provider"] == "sec_companyfacts" + assert payload["review_cutoff"] == REVIEW_CUTOFF + assert payload["historical_snapshot_count"] == 1 + assert payload["commercial_rights_approved"] is True + assert payload["commercial_evidence_ready"] is False + assert payload["auto_apply"] is False + + +@pytest.mark.parametrize( + "args", + [ + ["--review-csv", "missing.csv", "--as-of", REVIEW_CUTOFF], + ["--review-csv", "missing.csv", "--provider", "reviewed_csv"], + ["--provider", "reviewed_csv", "--as-of", REVIEW_CUTOFF], + [ + "--review-csv", + "missing.csv", + "--reviewed-csv", + "other.csv", + "--provider", + "reviewed_csv", + "--as-of", + REVIEW_CUTOFF, + ], + ], +) +def test_source_review_cli_requires_complete_review_mode(args: list[str]): + result = _run_source_cli(*args) + + assert result.returncode != 0 + assert "error:" in result.stderr + + +def test_source_review_cli_rejects_invalid_cutoff(tmp_path: Path): + input_path = tmp_path / "reviewed.csv" + input_path.write_text("ticker,fiscal_period\n", encoding="utf-8") + + result = _run_source_cli( + "--review-csv", + str(input_path), + "--provider", + "reviewed_csv", + "--as-of", + "not-a-cutoff", + ) + + assert result.returncode != 0 + assert "review cutoff" in result.stderr + + +def test_source_status_uses_deterministic_order_and_fails_closed_without_keys(tmp_path: Path): + statuses = consensus_source_statuses(env={}, generic_csv=tmp_path / "missing.csv") + + assert [row.provider for row in statuses] == ["alpha_vantage", "fmp", "finnhub", "reviewed_csv"] + assert [row.status for row in statuses[:3]] == ["external_key_required"] * 3 + assert statuses[-1].status == "external_data_required" + assert all(row.auto_apply is False for row in statuses) + + +def test_current_only_estimate_payload_is_candidate_context_not_history(): + result = validate_source_rows( + "alpha_vantage", + [_current_row()], + as_of=REVIEW_CUTOFF, + ) + + assert result.accepted_count == 1 + assert result.review_cutoff == REVIEW_CUTOFF + assert result.state == "candidate_context_only" + assert result.historical_snapshot_count == 0 + assert result.rights_status == "unknown_source" + assert result.commercial_rights_approved is False + assert result.commercial_ready_count == 0 + assert result.commercial_review_required_count == 1 + assert result.commercial_evidence_ready is False + assert result.commercial_review_rows[0].missing_supported_fields == ( + "revenue_consensus", + "eps_consensus", + ) + assert result.commercial_review_rows[0].commercial_blockers == ( + "commercial_rights:unknown_source", + "registered_consensus_scope_missing:revenue_consensus", + "registered_consensus_scope_missing:eps_consensus", + ) + assert result.auto_apply is False + + +def test_caller_cannot_supply_a_source_rights_label(): + with pytest.raises(TypeError, match="rights_status"): + validate_source_rows( + "alpha_vantage", + (), + as_of=REVIEW_CUTOFF, + rights_status="approved_for_project_use", # type: ignore[call-arg] + ) + + +def test_historical_rows_require_source_and_comparability_fields(): + result = validate_source_rows( + "reviewed_csv", + [{"ticker": "NVDA", "fiscal_period": "2026-Q4", "history_scope": "point_in_time"}], + as_of=REVIEW_CUTOFF, + ) + + assert result.accepted_count == 0 + assert result.rejected_count == 1 + assert "snapshot_at" in result.rejected_rows[0]["reason"] + assert "revenue_currency" in result.rejected_rows[0]["reason"] + assert result.commercial_ready_count == 0 + assert result.commercial_review_required_count == 0 + assert result.commercial_review_rows == () + + +def test_historical_rows_are_reviewable_and_not_declared_ready(): + result = validate_source_rows( + "licensed_consensus", + [_historical_row()], + as_of=REVIEW_CUTOFF, + rights_registry=_rights_registry(), + ) + + assert result.accepted_count == 1 + assert result.historical_snapshot_count == 1 + assert result.state == "historical_evidence_reviewable" + assert result.rights_status == "approved" + assert result.commercial_evidence_ready is True + assert result.commercial_ready_count == 1 + assert result.commercial_review_required_count == 0 + + +def test_source_rows_require_only_populated_revenue_and_eps_scope(): + revenue_registry = _rights_registry(supported_fields=("revenue_consensus",)) + eps_registry = _rights_registry(supported_fields=("eps_consensus",)) + revenue_only = validate_source_rows( + "licensed_consensus", + [_current_row(eps_consensus="")], + as_of=REVIEW_CUTOFF, + rights_registry=revenue_registry, + ) + eps_only = validate_source_rows( + "licensed_consensus", + [_current_row(revenue_consensus="")], + as_of=REVIEW_CUTOFF, + rights_registry=eps_registry, + ) + mixed = validate_source_rows( + "licensed_consensus", + [_current_row()], + as_of=REVIEW_CUTOFF, + rights_registry=revenue_registry, + ) + + assert revenue_only.commercial_review_rows[0].required_supported_fields == ( + "revenue_consensus", + ) + assert revenue_only.commercial_review_rows[0].missing_supported_fields == () + assert revenue_only.commercial_evidence_ready is True + assert eps_only.commercial_review_rows[0].required_supported_fields == ( + "eps_consensus", + ) + assert eps_only.commercial_review_rows[0].missing_supported_fields == () + assert eps_only.commercial_evidence_ready is True + assert mixed.commercial_rights_approved is True + assert mixed.commercial_review_rows[0].required_supported_fields == ( + "revenue_consensus", + "eps_consensus", + ) + assert mixed.commercial_review_rows[0].missing_supported_fields == ("eps_consensus",) + assert mixed.commercial_review_required_count == 1 + assert mixed.commercial_evidence_ready is False + assert mixed.commercial_blockers == ( + "registered_consensus_scope_missing:eps_consensus", + ) + + +def test_source_rows_do_not_split_or_infer_a_composite_provider(): + result = validate_source_rows( + "licensed_consensus + reviewed_csv", + [_current_row()], + as_of=REVIEW_CUTOFF, + rights_registry=_rights_registry(), + ) + + assert result.provider == "licensed_consensus + reviewed_csv" + assert result.accepted_count == 1 + assert result.rights_status == "unknown_source" + assert result.commercial_rights_approved is False + assert result.commercial_review_rows[0].missing_supported_fields == ( + "revenue_consensus", + "eps_consensus", + ) + assert result.commercial_evidence_ready is False + + +def test_source_rows_reject_invalid_fiscal_period_even_when_fields_are_present(): + row = _historical_row(fiscal_period="next-quarter", eps_consensus="") + result = validate_source_rows("reviewed_csv", [row], as_of=REVIEW_CUTOFF) + + assert result.rejected_count == 1 + assert "fiscal_period" in result.rejected_rows[0]["reason"] + + +def test_source_rows_require_a_valid_review_cutoff(): + with pytest.raises(ValueError, match="review cutoff"): + validate_source_rows("reviewed_csv", (), as_of="not-a-cutoff") + + +@pytest.mark.parametrize("scope", ["", "historical", "latest"]) +def test_source_rows_require_an_explicit_supported_history_scope(scope: str): + result = validate_source_rows( + "licensed_consensus", + [_current_row(history_scope=scope)], + as_of=REVIEW_CUTOFF, + rights_registry=_rights_registry(), + ) + + assert result.accepted_count == 0 + assert result.state == "still_blocked" + assert "history_scope must be current_only or point_in_time" in result.rejected_rows[0]["reason"] + assert result.commercial_rights_approved is True + assert result.commercial_ready_count == 0 + assert result.commercial_review_rows == () + + +def test_source_rows_reject_snapshot_after_retrieval(): + result = validate_source_rows( + "licensed_consensus", + [_current_row(snapshot_at="2026-07-18T05:00:02Z")], + as_of=REVIEW_CUTOFF, + rights_registry=_rights_registry(), + ) + + assert result.accepted_count == 0 + assert "snapshot_at cannot be after retrieved_at" in result.rejected_rows[0]["reason"] + assert result.commercial_rights_approved is True + assert result.commercial_ready_count == 0 + assert result.commercial_review_rows == () + + +def test_source_rows_reject_snapshot_after_review_cutoff(): + result = validate_source_rows( + "licensed_consensus", + [ + _current_row( + snapshot_at="2026-07-18T06:00:01Z", + retrieved_at="2026-07-18T06:00:02Z", + ) + ], + as_of=REVIEW_CUTOFF, + rights_registry=_rights_registry(), + ) + + assert result.accepted_count == 0 + assert "snapshot_at is after review cutoff" in result.rejected_rows[0]["reason"] + assert result.commercial_review_rows == () + + +def test_source_rows_reject_retrieval_after_review_cutoff(): + result = validate_source_rows( + "licensed_consensus", + [_current_row(retrieved_at="2026-07-18T06:00:01Z")], + as_of=REVIEW_CUTOFF, + rights_registry=_rights_registry(), + ) + + assert result.accepted_count == 0 + assert "retrieved_at is after review cutoff" in result.rejected_rows[0]["reason"] + assert result.commercial_review_rows == () + + +def test_source_rows_accept_timestamp_equality_at_retrieval_and_cutoff(): + result = validate_source_rows( + "licensed_consensus", + [ + _current_row( + snapshot_at=REVIEW_CUTOFF, + retrieved_at=REVIEW_CUTOFF, + ) + ], + as_of=REVIEW_CUTOFF, + rights_registry=_rights_registry(), + ) + + assert result.accepted_count == 1 + assert result.state == "candidate_context_only" + assert result.commercial_ready_count == 1 + + +def test_source_rows_preserve_original_row_numbers_after_temporal_rejection(): + result = validate_source_rows( + "licensed_consensus", + [ + _current_row(retrieved_at="2026-07-18T06:00:01Z"), + _current_row(source_ref="provider://consensus/current/NVDA/2027-Q1/second"), + ], + as_of=REVIEW_CUTOFF, + rights_registry=_rights_registry(), + ) + + assert result.accepted_count == 1 + assert result.rejected_count == 1 + assert result.rejected_rows[0]["row_number"] == 1 + assert result.commercial_review_rows[0].row_number == 2 diff --git a/tests/test_earnings_nowcast_backtest.py b/tests/test_earnings_nowcast_backtest.py index 24f9b6df..c6f43fe8 100644 --- a/tests/test_earnings_nowcast_backtest.py +++ b/tests/test_earnings_nowcast_backtest.py @@ -1,5 +1,7 @@ from __future__ import annotations +from dataclasses import replace + import pytest from src.earnings_nowcast_backtest import ( @@ -175,3 +177,221 @@ def test_calibration_reports_each_bin_and_human_readable_failed_gate_details(): assert status.calibration_bins[0].meets_minimum_size is True assert status.failed_gate_details["minimum_100_events"].startswith("20 valid events") assert status.failed_gate_details["must_improve_constant_rate_benchmark"] + + +def test_post_report_retrieval_is_reported_as_leakage_and_excluded(): + snapshots = [ + replace( + _consensus()[0], + retrieved_at="2026-04-21T00:00:00Z", + ) + ] + + report = walk_forward_backtest(_history_with_target(), snapshots, NowcastConfig()) + + assert report.verdict == "failed" + assert report.valid_event_count == 0 + assert report.leakage_failures + assert "retrieved after target report" in report.leakage_failures[0] + + +def test_conflicting_same_timestamp_consensus_revisions_are_excluded(): + original = _consensus()[0] + conflicting = replace(original, source_ref="fixture://revision", revenue_consensus=999.0) + + report = walk_forward_backtest( + _history_with_target(), + [original, conflicting], + NowcastConfig(), + ) + + assert report.valid_event_count == 0 + assert report.exclusion_reasons["ambiguous_consensus_revision"] == 1 + + +def test_stale_consensus_snapshot_is_excluded_by_explicit_age_policy(): + stale = replace( + _consensus()[0], + snapshot_at="2025-12-01T00:00:00Z", + retrieved_at="2025-12-01T00:01:00Z", + ) + + report = walk_forward_backtest( + _history_with_target(), + [stale], + NowcastConfig(), + maximum_snapshot_age_days=90, + ) + + assert report.valid_event_count == 0 + assert report.exclusion_reasons["stale_consensus_snapshot"] == 1 + + +def test_benchmark_non_improvement_is_an_explicit_failed_gate(): + perfect_consensus = replace(_consensus()[0], revenue_consensus=112.0, eps_consensus=1.0) + + report = walk_forward_backtest( + _history_with_target(), + [perfect_consensus], + NowcastConfig(), + minimum_backtest_events=1, + ) + + assert report.verdict == "failed" + assert "revenue_model_did_not_improve_consensus" in report.benchmark_failures + assert "eps_model_did_not_improve_consensus" in report.benchmark_failures + + +def test_backtest_withholds_unverified_target_and_prior_year_eps_outcomes(): + rows = _history_with_target() + rows[-1] = replace( + rows[-1], split_adjustment_basis="companyfacts_split_basis_unverified" + ) + rows[4] = replace( + rows[4], split_adjustment_basis="companyfacts_split_basis_unverified" + ) + + report = walk_forward_backtest(rows, _consensus(), NowcastConfig()) + event = report.events[0] + + assert event.revenue_actual == 112.0 + assert event.eps_actual is None + assert event.prior_year_eps is None + assert report.revenue_mae is not None + assert report.eps_mae is None + assert "consensus_eps_mae" not in report.benchmark_metrics + assert "prior_year_eps_mae" not in report.benchmark_metrics + + +def test_backtest_excludes_eps_only_target_when_split_basis_is_unverified(): + rows = _history_with_target() + rows[-1] = replace( + rows[-1], + revenue_actual=None, + split_adjustment_basis="companyfacts_split_basis_unverified", + ) + + report = walk_forward_backtest(rows, _consensus(), NowcastConfig()) + + assert report.event_count == 0 + assert report.exclusion_reasons["no_comparable_target_actual"] == 1 + + +def test_identical_target_duplicates_produce_one_fiscal_period_event(): + rows = _history_with_target() + rows.append(replace(rows[-1])) + + report = walk_forward_backtest(rows, [_consensus()[0]], NowcastConfig()) + + assert report.event_count == 1 + assert tuple(event.fiscal_period for event in report.events) == ("2026-Q1",) + + +@pytest.mark.parametrize( + ("conflicting_field", "conflicting_value", "expected_revenue", "expected_eps"), + [ + ("revenue_actual", 999.0, None, 1.0), + ("eps_actual", 9.99, 112.0, None), + ], +) +def test_conflicting_target_metric_is_excluded_independently( + conflicting_field, + conflicting_value, + expected_revenue, + expected_eps, +): + rows = _history_with_target() + target = rows[-1] + other_field = "eps_actual" if conflicting_field == "revenue_actual" else "revenue_actual" + conflict = replace( + target, + **{ + conflicting_field: conflicting_value, + other_field: None, + "source_ref": f"fixture://conflict/{conflicting_field}", + }, + ) + + report = walk_forward_backtest([*rows, conflict], [_consensus()[0]], NowcastConfig()) + + assert report.event_count == 1 + assert report.events[0].revenue_actual == expected_revenue + assert report.events[0].eps_actual == expected_eps + + +def test_explicit_target_revision_chains_select_each_metric_canonically(): + rows = _history_with_target() + target = rows[-1] + revenue_revision = replace( + target, + revenue_actual=120.0, + eps_actual=None, + source_ref="fixture://revision/2026-Q1/revenue", + retrieved_at="2026-04-20T22:00:00Z", + supersedes_source_ref=target.source_ref, + ) + eps_revision = replace( + target, + revenue_actual=None, + eps_actual=1.2, + source_ref="fixture://revision/2026-Q1/eps", + retrieved_at="2026-04-20T23:00:00Z", + supersedes_source_ref=target.source_ref, + ) + + report = walk_forward_backtest( + [*rows, revenue_revision, eps_revision], + [_consensus()[0]], + NowcastConfig(), + ) + + assert report.event_count == 1 + assert report.events[0].revenue_actual == 120.0 + assert report.events[0].eps_actual == 1.2 + + +def test_prior_year_benchmarks_use_canonical_revisions_in_any_input_order(): + rows = _history_with_target() + prior = rows[4] + revision = replace( + prior, + revenue_actual=125.0, + eps_actual=1.25, + source_ref="fixture://revision/2025-Q1", + reported_at="2025-04-21T21:00:00Z", + retrieved_at="2025-04-21T21:01:00Z", + supersedes_source_ref=prior.source_ref, + ) + + appended = walk_forward_backtest([*rows, revision], [_consensus()[0]], NowcastConfig()) + prepended = walk_forward_backtest([revision, *rows], [_consensus()[0]], NowcastConfig()) + + assert appended.events[0].prior_year_revenue == 125.0 + assert appended.events[0].prior_year_eps == 1.25 + assert prepended.events[0].prior_year_revenue == 125.0 + assert prepended.events[0].prior_year_eps == 1.25 + + +def test_prior_year_benchmark_excludes_revision_reported_after_event_cutoff(): + rows = _history_with_target() + prior = rows[4] + future_revision = replace( + prior, + revenue_actual=125.0, + eps_actual=1.25, + source_ref="fixture://revision/2025-Q1/future", + reported_at="2026-02-01T21:00:00Z", + retrieved_at="2026-02-01T21:01:00Z", + supersedes_source_ref=prior.source_ref, + ) + + report = walk_forward_backtest( + [*rows, future_revision], + [_consensus()[0]], + NowcastConfig(), + ) + + assert report.events[0].as_of_timestamp == "2026-01-31T23:59:59+00:00" + assert report.events[0].prior_year_revenue == 96.0 + assert report.events[0].prior_year_eps == 0.8 + assert report.leakage_failures == () diff --git a/tests/test_earnings_nowcast_cohort.py b/tests/test_earnings_nowcast_cohort.py new file mode 100644 index 00000000..ebe7ebf5 --- /dev/null +++ b/tests/test_earnings_nowcast_cohort.py @@ -0,0 +1,49 @@ +from pathlib import Path + +from src.earnings_nowcast_cohort import build_cohort_readiness + + +FIXTURE_DIR = Path("tests/fixtures/earnings_nowcast_onboarding") + + +def test_cohort_board_keeps_metric_and_evidence_gates_separate(): + rows = build_cohort_readiness( + FIXTURE_DIR, + tickers=("SYN1", "MISS"), + as_of="2026-01-31T23:59:59Z", + ) + + ready, missing = rows + assert ready.ticker == "SYN1" + assert ready.latest_actual_period == "2025-Q4" + assert ready.forecast_period == "2026-Q1" + assert ready.revenue_history_count == 5 + assert ready.eps_history_count == 5 + assert ready.q4_revenue_count == 2 + assert ready.q4_eps_count == 2 + assert ready.revenue_ready is True + assert ready.eps_ready is True + assert ready.consensus_snapshot_count == 1 + assert ready.state == "baseline_ready" + assert ready.backtest_event_count == 0 + assert ready.calibration_event_count == 0 + + assert missing.ticker == "MISS" + assert missing.state == "blocked" + assert missing.latest_actual_period == "" + assert missing.blocker == "quarterly_actuals_missing" + assert "source-backed quarterly actuals" in missing.next_action + + +def test_cohort_board_accepts_explicit_evaluation_counts_without_promoting_probability(): + row = build_cohort_readiness( + FIXTURE_DIR, + tickers=("SYN1",), + as_of="2026-01-31T23:59:59Z", + backtest_counts={"SYN1": 12}, + calibration_counts={"SYN1": 4}, + )[0] + + assert row.backtest_event_count == 12 + assert row.calibration_event_count == 4 + assert row.probability_state == "awaiting_calibration_evidence" diff --git a/tests/test_earnings_nowcast_model.py b/tests/test_earnings_nowcast_model.py index 81245249..2cbf3d78 100644 --- a/tests/test_earnings_nowcast_model.py +++ b/tests/test_earnings_nowcast_model.py @@ -5,6 +5,7 @@ import pytest from src.earnings_nowcast_contract import ConsensusSnapshot, NowcastState, QuarterlyActual +from src import earnings_nowcast_model from src.earnings_nowcast_model import NowcastConfig, build_baseline_nowcast, classify_consensus_gap @@ -96,13 +97,42 @@ def test_blocked_eps_never_renders_a_numeric_eps_forecast(): assert result.eps_gap_pct is None -def test_missing_prior_year_target_quarter_fails_instead_of_guessing_seasonality(): +def test_missing_prior_year_target_quarter_is_withheld_as_a_continuity_gap(): rows = [row for row in _actuals() if row.fiscal_period != "2025-Q1"] - with pytest.raises(ValueError, match="prior-year target quarter"): + with pytest.raises(ValueError, match="quarter_history_gap"): build_baseline_nowcast(rows, _consensus(), CUTOFF, NowcastConfig(minimum_history_quarters=4)) +def test_missing_q4_never_passes_q3_to_q1_into_sequential_growth(monkeypatch: pytest.MonkeyPatch): + rows = [row for row in _actuals() if row.fiscal_period != "2025-Q4"] + rows.append( + QuarterlyActual( + ticker="SYN1", + fiscal_period="2026-Q1", + period_end_date="2026-03-31", + reported_at="2026-01-16T21:00:00Z", + revenue_actual=104.0, + eps_actual=1.3, + source="synthetic_test_fixture", + source_ref="fixture://actual/2026-Q1", + retrieved_at="2026-01-16T21:01:00Z", + ) + ) + sequential_inputs: list[list[float]] = [] + + def capture_sequential_growth(values: list[float]) -> list[float]: + sequential_inputs.append(values) + return [0.01] + + monkeypatch.setattr(earnings_nowcast_model, "_sequential_growth", capture_sequential_growth) + + with pytest.raises(ValueError, match="quarter_history_gap"): + build_baseline_nowcast(rows, replace(_consensus(), fiscal_period="2026-Q2"), CUTOFF) + + assert sequential_inputs == [] + + def test_post_cutoff_actual_fails_closed(): rows = _actuals() rows.append( diff --git a/tests/test_earnings_nowcast_onboarding.py b/tests/test_earnings_nowcast_onboarding.py index 28f1a75a..5171db89 100644 --- a/tests/test_earnings_nowcast_onboarding.py +++ b/tests/test_earnings_nowcast_onboarding.py @@ -119,10 +119,10 @@ def test_validation_accepts_source_backed_rows_without_writing_reports(tmp_path) item["value"] for item in result["accepted_rows"] if item["file"] == "consensus_snapshots.csv" ) assert consensus_value.source_ref == CONSENSUS["source_ref"] - assert list(input_dir.iterdir()) == [ - input_dir / "quarterly_actuals.csv", - input_dir / "consensus_snapshots.csv", - ] + assert {path.name for path in input_dir.iterdir()} == { + "quarterly_actuals.csv", + "consensus_snapshots.csv", + } def test_validation_rejects_missing_source_reference_and_post_cutoff_rows(tmp_path): diff --git a/tests/test_earnings_nowcast_readiness.py b/tests/test_earnings_nowcast_readiness.py index 6731ca3b..ffe83783 100644 --- a/tests/test_earnings_nowcast_readiness.py +++ b/tests/test_earnings_nowcast_readiness.py @@ -5,7 +5,7 @@ import pytest from src.earnings_nowcast_contract import ConsensusSnapshot, FreshnessState, NowcastState, QuarterlyActual -from src.earnings_nowcast_readiness import assess_nowcast_readiness, readiness_payload +from src.earnings_nowcast_readiness import assess_nowcast_readiness, canonicalize_actuals, readiness_payload CUTOFF = "2026-01-31T23:59:59Z" @@ -221,6 +221,43 @@ def test_duplicate_fiscal_period_does_not_satisfy_minimum_history(): assert "quarterly_actual_history" in result.missing_evidence +def test_missing_q4_withholds_both_metrics_instead_of_treating_q3_to_q1_as_sequential(): + rows = [row for row in _actuals() if row.fiscal_period != "2024-Q4"] + + result = assess_nowcast_readiness( + ticker="SYN1", + fiscal_period="2026-Q1", + as_of_timestamp=CUTOFF, + actuals=rows, + consensus=[_consensus()], + ) + + assert result.revenue_ready is False + assert result.eps_ready is False + assert "quarter_history_gap" in result.missing_evidence + + +def test_source_ids_include_only_the_contiguous_metric_windows(): + oldest = replace( + _actuals()[0], + fiscal_period="2024-Q3", + period_end_date="2024-09-30", + reported_at="2024-11-01T21:00:00Z", + retrieved_at="2024-11-01T21:00:00Z", + source_ref="fixture://actual/2024-Q3", + ) + + result = assess_nowcast_readiness( + ticker="SYN1", + fiscal_period="2026-Q1", + as_of_timestamp=CUTOFF, + actuals=[oldest, *_actuals()], + consensus=[_consensus()], + ) + + assert "fixture://actual/2024-Q3" not in result.source_ids + + def test_conflicting_quarterly_revenue_blocks_only_revenue(): rows = _actuals() rows.append( @@ -275,6 +312,73 @@ def test_explicit_pre_cutoff_revision_supersedes_prior_value(): assert "conflicting_quarterly_actuals" not in result.missing_evidence +def test_complete_multi_step_revision_chain_selects_latest_reported_row(): + rows = _actuals() + original = rows[-1] + middle = replace( + original, + reported_at="2026-01-16T21:00:00Z", + retrieved_at="2026-01-16T21:01:00Z", + revenue_actual=105.0, + eps_actual=1.25, + source_ref="fixture://revision/middle", + supersedes_source_ref=original.source_ref, + ) + latest = replace( + original, + reported_at="2026-01-17T21:00:00Z", + retrieved_at="2026-01-17T21:01:00Z", + revenue_actual=110.0, + eps_actual=1.3, + source_ref="fixture://revision/latest", + supersedes_source_ref=middle.source_ref, + ) + + result = assess_nowcast_readiness( + ticker="SYN1", + fiscal_period="2026-Q1", + as_of_timestamp=CUTOFF, + actuals=[*rows, middle, latest], + consensus=[_consensus()], + ) + + assert result.revenue_ready is True + assert result.eps_ready is True + assert "fixture://revision/latest" in result.source_ids + assert "fixture://revision/middle" not in result.conflict_source_ids + + +def test_metric_empty_revision_nodes_preserve_eps_chain_traversal(): + rows = _actuals() + original = rows[-1] + revenue_only_middle = replace( + original, + reported_at="2026-01-16T21:00:00Z", + retrieved_at="2026-01-16T21:01:00Z", + revenue_actual=105.0, + eps_actual=None, + source_ref="fixture://revision/revenue-only-middle", + supersedes_source_ref=original.source_ref, + ) + eps_only_latest = replace( + original, + reported_at="2026-01-17T21:00:00Z", + retrieved_at="2026-01-17T21:01:00Z", + revenue_actual=None, + eps_actual=1.3, + source_ref="fixture://revision/eps-only-latest", + supersedes_source_ref=revenue_only_middle.source_ref, + ) + + canonical = canonicalize_actuals( + [*rows, revenue_only_middle, eps_only_latest], + _consensus(), + ) + + assert canonical.eps_conflict_source_ids == () + assert canonical.eps_rows[-1].source_ref == "fixture://revision/eps-only-latest" + + def test_revision_cannot_hide_a_second_unresolved_conflicting_source(): rows = _actuals() original = rows[-1] @@ -348,3 +452,123 @@ def test_incompatible_metric_definitions_fail_only_the_affected_metric( assert getattr(result, ready_metric) is True assert getattr(result, blocked_metric) is False assert reason in result.missing_evidence + + +def test_split_basis_change_withholds_eps_but_keeps_revenue_ready(): + rows = _actuals() + rows[0] = replace(rows[0], split_adjustment_basis="pre_split") + consensus = replace(_consensus(), split_adjustment_basis="post_split_2024_06_10") + + result = assess_nowcast_readiness( + ticker="SYN1", + fiscal_period="2026-Q1", + as_of_timestamp=CUTOFF, + actuals=rows, + consensus=[consensus], + ) + + assert result.revenue_ready is True + assert result.eps_ready is False + assert "incompatible_eps_definition" in result.missing_evidence + + +def test_companyfacts_unverified_split_basis_withholds_only_eps(): + rows = [ + replace( + row, + eps_actual=(row.eps_actual / 10 if index == 0 else row.eps_actual), + split_adjustment_basis="companyfacts_split_basis_unverified", + ) + for index, row in enumerate(_actuals()) + ] + + result = assess_nowcast_readiness( + ticker="SYN1", + fiscal_period="2026-Q1", + as_of_timestamp=CUTOFF, + actuals=rows, + consensus=[_consensus()], + ) + + assert result.revenue_ready is True + assert result.eps_ready is False + assert "incompatible_eps_definition" in result.missing_evidence + + +def test_matching_companyfacts_unverified_split_basis_still_withholds_only_eps(): + rows = [ + replace(row, split_adjustment_basis="companyfacts_split_basis_unverified") + for row in _actuals() + ] + consensus = replace( + _consensus(), + split_adjustment_basis="companyfacts_split_basis_unverified", + ) + + result = assess_nowcast_readiness( + ticker="SYN1", + fiscal_period="2026-Q1", + as_of_timestamp=CUTOFF, + actuals=rows, + consensus=[consensus], + ) + + assert result.revenue_ready is True + assert result.eps_ready is False + assert "incompatible_eps_definition" in result.missing_evidence + + +def test_pre_cutoff_split_adjusted_revisions_restore_eps_but_post_cutoff_revisions_do_not(): + from src.earnings_nowcast_readiness import contiguous_metric_window + + rows = _actuals() + consensus = replace(_consensus(), split_adjustment_basis="post_split_2024_06_10") + pre_cutoff_revisions = [ + replace( + row, + revenue_actual=None, + reported_at="2026-01-16T21:00:00Z", + retrieved_at="2026-01-16T21:01:00Z", + source_ref=f"fixture://revision/{row.fiscal_period}", + split_adjustment_basis="post_split_2024_06_10", + supersedes_source_ref=row.source_ref, + ) + for row in rows + ] + + pre_cutoff = assess_nowcast_readiness( + ticker="SYN1", + fiscal_period="2026-Q1", + as_of_timestamp=CUTOFF, + actuals=[*rows, *pre_cutoff_revisions], + consensus=[consensus], + ) + post_cutoff = assess_nowcast_readiness( + ticker="SYN1", + fiscal_period="2026-Q1", + as_of_timestamp=CUTOFF, + actuals=[ + *rows, + *[ + replace( + revision, + reported_at="2026-02-01T21:00:00Z", + retrieved_at="2026-02-01T21:01:00Z", + ) + for revision in pre_cutoff_revisions + ], + ], + consensus=[consensus], + ) + + assert pre_cutoff.revenue_ready is True + assert pre_cutoff.eps_ready is True + assert any(source_id.startswith("fixture://revision/") for source_id in pre_cutoff.source_ids) + canonical = canonicalize_actuals([*rows, *pre_cutoff_revisions], consensus) + assert tuple( + row.source_ref + for row in contiguous_metric_window(canonical.eps_rows, "2026-Q1", "eps", minimum_quarters=5) + ) == tuple(revision.source_ref for revision in pre_cutoff_revisions) + assert post_cutoff.eps_ready is False + assert "post_cutoff_evidence" in post_cutoff.missing_evidence + assert "incompatible_eps_definition" in post_cutoff.missing_evidence diff --git a/tests/test_earnings_nowcast_report.py b/tests/test_earnings_nowcast_report.py index 5da1fd34..6606c62c 100644 --- a/tests/test_earnings_nowcast_report.py +++ b/tests/test_earnings_nowcast_report.py @@ -1,5 +1,6 @@ from __future__ import annotations +import csv import json import shutil import subprocess @@ -27,6 +28,50 @@ def test_fixture_packet_is_reproducible_and_never_claims_real_company_evidence() assert first["calibration"]["probability_available"] is False +def test_real_company_packet_requires_exact_fiscal_period(tmp_path): + input_root = tmp_path / "earnings_nowcast" + shutil.copytree(FIXTURE_ROOT, input_root) + for path in input_root.glob("*.csv"): + path.write_text( + path.read_text(encoding="utf-8") + .replace("SYN1", "AAA") + .replace("synthetic_test_fixture", "licensed_consensus"), + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="fiscal_period is required"): + build_nowcast_packet(input_root, ticker="AAA", as_of_timestamp=CUTOFF) + + +def test_exact_fiscal_period_selects_latest_pre_cutoff_revision(tmp_path): + fixture_copy = tmp_path / "earnings_nowcast" + shutil.copytree(FIXTURE_ROOT, fixture_copy) + consensus_path = fixture_copy / "consensus_snapshots.csv" + with consensus_path.open(newline="", encoding="utf-8") as handle: + rows = list(csv.DictReader(handle)) + base = next(row for row in rows if row["ticker"] == "SYN1") + rows.extend( + [ + {**base, "snapshot_at": "2026-01-30T00:00:00Z", "eps_consensus": "9.25"}, + {**base, "snapshot_at": "2026-02-01T00:00:00Z", "eps_consensus": "99.0"}, + ] + ) + with consensus_path.open("w", newline="", encoding="utf-8") as handle: + writer = csv.DictWriter(handle, fieldnames=rows[0].keys()) + writer.writeheader() + writer.writerows(rows) + + packet = build_nowcast_packet( + fixture_copy, + ticker="SYN1", + fiscal_period=base["fiscal_period"], + as_of_timestamp=CUTOFF, + ) + + assert packet["fiscal_period"] == base["fiscal_period"] + assert packet["forecast"]["consensus_eps"] == 9.25 + + def test_fixture_packet_contains_readiness_forecast_provenance_and_signal_boundaries(): packet = build_nowcast_packet(FIXTURE_ROOT, ticker="SYN1", as_of_timestamp=CUTOFF) diff --git a/tests/test_earnings_nowcast_sec_actuals.py b/tests/test_earnings_nowcast_sec_actuals.py new file mode 100644 index 00000000..1970ed9e --- /dev/null +++ b/tests/test_earnings_nowcast_sec_actuals.py @@ -0,0 +1,1695 @@ +from __future__ import annotations + +import csv +import json +import time +from dataclasses import replace +from pathlib import Path + +import pytest + +import src.earnings_nowcast_sec_actuals as sec_actuals +from src.earnings_nowcast_contract import ( + PRIMARY_SPLIT_BASIS_UNVERIFIED, + ConsensusSnapshot, + QuarterlyActual, + eps_split_basis_verified, +) +from src.earnings_nowcast_readiness import assess_nowcast_readiness +from src.earnings_nowcast_sec_actuals import ( + ExtractionAuditRow, + ExtractionResult, + extract_explicit_q4_actual, + extract_q1_q3_lineage, + link_quarter_revisions, + normalize_sec_duration_facts, + stage_sec_quarterly_actuals, + write_sec_actuals_stage, +) +from src.providers.sec_submissions import FiledExhibit + + +CUTOFF = "2026-06-30T23:59:59Z" +RETRIEVED_AT = "2026-06-26T12:00:00Z" + + +Q4_EXHIBIT = FiledExhibit( + document_type="EX-99.1", + document_name="earnings-release.htm", + source_ref="https://www.sec.gov/Archives/edgar/data/123456/000012345626000001/earnings-release.htm", +) + + +Q4_RELEASE_HTML = """ +

Fourth Quarter Fiscal 2025 Summary

+ + + + + +
Q4 FY25
Period endedJanuary 26, 2025
Revenue$39,331 million
GAAP diluted earnings per share$0.89
+

All per-share amounts are retrospectively adjusted for the ten-for-one split effective June 7, 2024.

+""" + + +def test_extract_explicit_q4_actual_reads_filed_result_table(): + result = extract_explicit_q4_actual( + "SYN1", + Q4_EXHIBIT, + Q4_RELEASE_HTML, + fiscal_period="2025-Q4", + filed_at="2026-02-25T00:00:00Z", + retrieved_at=RETRIEVED_AT, + ) + + assert len(result.rows) == 1 + assert result.rows[0].fiscal_period == "2025-Q4" + assert result.rows[0].revenue_actual == 39_331_000_000 + assert result.rows[0].eps_actual == 0.89 + assert result.rows[0].period_end_date == "2025-01-26" + assert result.rows[0].split_adjustment_basis == "split_adjusted_2024_06_07" + + +def test_extract_explicit_q4_actual_reads_the_column_labeled_q4(): + result = extract_explicit_q4_actual( + "SYN1", + Q4_EXHIBIT, + """ +

Fourth Quarter Fiscal 2025 Summary

+ + + + + +
Fiscal 2025Q4 FY25
Period endedJanuary 28, 2024January 26, 2025
Revenue$160 billion$39,331 million
GAAP diluted earnings per share$3.00$0.89
+ """, + fiscal_period="2025-Q4", + filed_at="2026-02-25T00:00:00Z", + retrieved_at=RETRIEVED_AT, + ) + + assert result.rows[0].revenue_actual == 39_331_000_000 + assert result.rows[0].eps_actual == 0.89 + + +def test_extract_explicit_q4_actual_uses_explicit_table_level_revenue_scale(): + result = extract_explicit_q4_actual( + "SYN1", + Q4_EXHIBIT, + """ +

Fourth Quarter Fiscal 2025 Summary

+ + + + + +
Dollars in millionsQ4 FY25
Period endedJanuary 26, 2025
Revenue$39,331
GAAP diluted earnings per share$0.89
+ """, + fiscal_period="2025-Q4", + filed_at="2026-02-25T00:00:00Z", + retrieved_at=RETRIEVED_AT, + ) + + assert result.rows[0].revenue_actual == 39_331_000_000 + + +def test_extract_explicit_q4_actual_uses_caption_table_level_revenue_scale(): + result = extract_explicit_q4_actual( + "SYN1", + Q4_EXHIBIT, + """ +

Fourth Quarter Fiscal 2025 Summary

+ + + + + + +
Dollars in millions
Q4 FY25
Period endedJanuary 26, 2025
Revenue$39,331
GAAP diluted earnings per share$0.89
+ """, + fiscal_period="2025-Q4", + filed_at="2026-02-25T00:00:00Z", + retrieved_at=RETRIEVED_AT, + ) + + assert result.rows[0].revenue_actual == 39_331_000_000 + + +def test_extract_explicit_q4_actual_rejects_revenue_without_an_unambiguous_scale(): + result = extract_explicit_q4_actual( + "SYN1", + Q4_EXHIBIT, + """ +

Fourth Quarter Fiscal 2025 Summary

+ + + + + +
Q4 FY25
Period endedJanuary 26, 2025
Revenue$39,331
GAAP diluted earnings per share$0.89
+ """, + fiscal_period="2025-Q4", + filed_at="2026-02-25T00:00:00Z", + retrieved_at=RETRIEVED_AT, + ) + + assert result.rows[0].revenue_actual is None + assert result.rows[0].eps_actual == 0.89 + assert "revenue_scale_missing" in {row.state for row in result.audit_rows} + + +def test_extract_explicit_q4_actual_requires_source_backed_period_end(): + result = extract_explicit_q4_actual( + "SYN1", + Q4_EXHIBIT, + """ +

Fourth Quarter Fiscal 2025 Summary

+ + +
Q4 FY25
Revenue$39,331 million
GAAP diluted earnings per share$0.89
+ """, + fiscal_period="2025-Q4", + filed_at="2026-02-25T00:00:00Z", + retrieved_at=RETRIEVED_AT, + ) + + assert result.rows == () + assert "period_end_missing" in {row.state for row in result.audit_rows} + + +def test_extract_explicit_q4_actual_does_not_borrow_period_end_from_non_result_table(): + result = extract_explicit_q4_actual( + "SYN1", + Q4_EXHIBIT, + """ +

Fourth Quarter Fiscal 2025 Summary

+ + + + +
Q4 FY25
Revenue$39,331 million
GAAP diluted earnings per share$0.89
+ + + + +
Q4 FY25
Period endedJanuary 26, 2025
Cash and cash equivalents$8,000 million
+ """, + fiscal_period="2025-Q4", + filed_at="2026-02-25T00:00:00Z", + retrieved_at=RETRIEVED_AT, + ) + + assert result.rows == () + assert "period_end_missing" in {row.state for row in result.audit_rows} + + +def test_extract_explicit_q4_actual_does_not_borrow_period_end_across_metric_tables(): + result = extract_explicit_q4_actual( + "SYN1", + Q4_EXHIBIT, + """ +

Fourth Quarter Fiscal 2025 Summary

+ + + +
Dollars in millionsQ4 FY25
Revenue$39,331
+ + + + +
Q4 FY25
Period endedJanuary 26, 2025
GAAP diluted earnings per share$0.89
+ """, + fiscal_period="2025-Q4", + filed_at="2026-02-25T00:00:00Z", + retrieved_at=RETRIEVED_AT, + ) + + assert len(result.rows) == 1 + assert result.rows[0].revenue_actual is None + assert result.rows[0].eps_actual == 0.89 + assert result.rows[0].period_end_date == "2025-01-26" + assert "period_end_missing" in {row.state for row in result.audit_rows} + + +def test_extract_explicit_q4_actual_rejects_annual_only_table(): + result = extract_explicit_q4_actual( + "SYN1", + Q4_EXHIBIT, + """ + + +
Fiscal 2025
Revenue$100 billion
GAAP diluted earnings per share$2.00
+ """, + fiscal_period="2025-Q4", + filed_at="2026-02-25T00:00:00Z", + retrieved_at=RETRIEVED_AT, + ) + + assert result.rows == () + assert "quarter_header_missing" in {row.state for row in result.audit_rows} + + +def test_extract_explicit_q4_actual_rejects_guidance_and_non_gaap_metrics(): + result = extract_explicit_q4_actual( + "SYN1", + Q4_EXHIBIT, + """ +

Q4 FY25 outlook

+ +
Q4 FY25
Revenue expectedapproximately $40 billion
Non-GAAP diluted earnings per share$0.90
+ """, + fiscal_period="2025-Q4", + filed_at="2026-02-25T00:00:00Z", + retrieved_at=RETRIEVED_AT, + ) + + assert result.rows == () + assert {row.state for row in result.audit_rows} >= {"guidance_or_outlook_rejected", "gaap_eps_missing"} + + +def test_extract_explicit_q4_actual_rejects_derived_and_ambiguous_q4_headers(): + result = extract_explicit_q4_actual( + "SYN1", + Q4_EXHIBIT, + """ + + +
Q4 / Fiscal 2025
Revenue (annual less nine months)$40 billion
GAAP diluted earnings per share$0.89
+ """, + fiscal_period="2025-Q4", + filed_at="2026-02-25T00:00:00Z", + retrieved_at=RETRIEVED_AT, + ) + + assert result.rows == () + assert {row.state for row in result.audit_rows} >= {"ambiguous_period_header", "derived_q4_rejected"} + + +def test_extract_explicit_q4_actual_rejects_nearby_annual_less_nine_month_derivation_note(): + result = extract_explicit_q4_actual( + "SYN1", + Q4_EXHIBIT, + """ +

Fourth Quarter Fiscal 2025 Summary

+ + +
Q4 FY25
Revenue$40 billion
GAAP diluted earnings per share$0.89
+

Q4 amounts were calculated from annual less nine-month results.

+ """, + fiscal_period="2025-Q4", + filed_at="2026-02-25T00:00:00Z", + retrieved_at=RETRIEVED_AT, + ) + + assert result.rows == () + assert "derived_q4_rejected" in {row.state for row in result.audit_rows} + + +@pytest.mark.parametrize( + "derivation_note", + ( + "Q4 amounts were calculated from annual less nine months results.", + "Q4 amounts were calculated from full year minus 9 months results.", + "Q4 amounts were derived by subtracting nine-month results from full-year results.", + "Q4 amounts were computed from annual results less the first three quarters.", + ), +) +def test_extract_explicit_q4_actual_rejects_plural_and_numeric_annual_minus_nine_month_derivation( + derivation_note, +): + result = extract_explicit_q4_actual( + "SYN1", + Q4_EXHIBIT, + f""" +

Fourth Quarter Fiscal 2025 Summary

+ + +
Q4 FY25
Revenue$40 billion
GAAP diluted earnings per share$0.89
+

{derivation_note}

+ """, + fiscal_period="2025-Q4", + filed_at="2026-02-25T00:00:00Z", + retrieved_at=RETRIEVED_AT, + ) + + assert result.rows == () + assert "derived_q4_rejected" in {row.state for row in result.audit_rows} + + +def test_extract_explicit_q4_actual_rejects_post_cutoff_and_withholds_unproven_split_basis(): + post_cutoff = extract_explicit_q4_actual( + "SYN1", + Q4_EXHIBIT, + Q4_RELEASE_HTML, + fiscal_period="2025-Q4", + filed_at="2026-07-01T00:00:00Z", + retrieved_at=RETRIEVED_AT, + cutoff=CUTOFF, + ) + missing_proof = extract_explicit_q4_actual( + "SYN1", + Q4_EXHIBIT, + Q4_RELEASE_HTML.replace( + "All per-share amounts are retrospectively adjusted for the ten-for-one split effective June 7, 2024.", + "", + ), + fiscal_period="2025-Q4", + filed_at="2026-02-25T00:00:00Z", + retrieved_at=RETRIEVED_AT, + ) + + assert post_cutoff.rows == () + assert "post_cutoff_rejected" in {row.state for row in post_cutoff.audit_rows} + malformed_proof = extract_explicit_q4_actual( + "SYN1", + Q4_EXHIBIT, + Q4_RELEASE_HTML.replace("June 7, 2024", "Smarch 99, 2024"), + fiscal_period="2025-Q4", + filed_at="2026-02-25T00:00:00Z", + retrieved_at=RETRIEVED_AT, + ) + + assert missing_proof.rows[0].split_adjustment_basis == PRIMARY_SPLIT_BASIS_UNVERIFIED + assert malformed_proof.rows[0].split_adjustment_basis == PRIMARY_SPLIT_BASIS_UNVERIFIED + assert eps_split_basis_verified(missing_proof.rows[0].split_adjustment_basis) is False + + +def _fact( + *, + val, + start, + end, + filed="2026-06-25", + fy=2026, + fp="Q3", + frame=None, + accn="0000000000-26-000001", +): + return { + "val": val, + "start": start, + "end": end, + "filed": filed, + "form": "10-Q", + "accn": accn, + "fy": fy, + "fp": fp, + "frame": frame, + } + + +def companyfacts_fixture(*, revenue, eps): + return { + "cik": 123456, + "facts": { + "us-gaap": { + "RevenueFromContractWithCustomerExcludingAssessedTax": {"units": {"USD": revenue}}, + "EarningsPerShareDiluted": {"units": {"USD/shares": eps}}, + } + }, + } + + +def actual( + fiscal_period, + *, + revenue, + eps, + source_ref, + reported_at, + source="sec_companyfacts", + period_end_date="2025-06-30", +): + return QuarterlyActual( + ticker="SYN1", + fiscal_period=fiscal_period, + period_end_date=period_end_date, + reported_at=reported_at, + revenue_actual=revenue, + eps_actual=eps, + source=source, + source_ref=source_ref, + retrieved_at=RETRIEVED_AT, + ) + + +def extraction_result(): + return ExtractionResult( + rows=( + actual( + "2025-Q2", + revenue=100, + eps=1.0, + source_ref="sec://original", + reported_at="2025-08-01T00:00:00Z", + ), + ), + audit_rows=( + ExtractionAuditRow( + ticker="SYN1", + state="cumulative_fact_rejected", + metric="revenue", + fiscal_period="2025-Q2", + source_ref="sec://original", + detail="duration is cumulative", + concept="Revenues", + start="2025-01-01", + end="2025-06-30", + frame="CY2025", + accession="0000000000-25-000001", + ), + ), + ) + + +def test_later_changed_presentation_is_append_only_revision(): + original = actual( + "2025-Q2", + revenue=100, + eps=1.0, + source_ref="sec://original", + reported_at="2025-08-01T00:00:00Z", + ) + revised = actual( + "2025-Q2", + revenue=100, + eps=0.1, + source_ref="sec://split-adjusted", + reported_at="2025-11-01T00:00:00Z", + ) + + linked = link_quarter_revisions([original, revised]) + + assert len(linked) == 2 + assert linked[1].supersedes_source_ref == original.source_ref + assert linked[0].eps_actual == 1.0 + + +def test_later_unchanged_presentation_is_deduplicated(): + original = actual( + "2025-Q2", + revenue=100, + eps=1.0, + source_ref="sec://original", + reported_at="2025-08-01T00:00:00Z", + ) + later_same = actual( + "2025-Q2", + revenue=100, + eps=1.0, + source_ref="sec://later-presentation", + reported_at="2025-11-01T00:00:00Z", + ) + + linked = link_quarter_revisions([later_same, original]) + + assert linked == (original,) + + +def test_revision_linking_preserves_a_b_a_chain_and_latest_return_to_a(): + original = actual( + "2025-Q2", + revenue=100, + eps=1.0, + source_ref="sec://a-original", + reported_at="2025-08-01T00:00:00Z", + ) + revised = actual( + "2025-Q2", + revenue=110, + eps=1.1, + source_ref="sec://b-revised", + reported_at="2025-11-01T00:00:00Z", + ) + restored = actual( + "2025-Q2", + revenue=100, + eps=1.0, + source_ref="sec://a-restored", + reported_at="2026-02-01T00:00:00Z", + ) + + linked = link_quarter_revisions([restored, original, revised]) + + assert [row.source_ref for row in linked] == [ + "sec://a-original", + "sec://b-revised", + "sec://a-restored", + ] + assert linked[1].supersedes_source_ref == "sec://a-original" + assert linked[2].supersedes_source_ref == "sec://b-revised" + + +def test_revision_linking_does_not_cross_period_end_collisions(): + original = actual( + "2025-Q2", + revenue=100, + eps=1.0, + source_ref="sec://first-period-end", + reported_at="2025-08-01T00:00:00Z", + period_end_date="2025-06-29", + ) + colliding_identity = actual( + "2025-Q2", + revenue=110, + eps=1.1, + source_ref="sec://second-period-end", + reported_at="2025-11-01T00:00:00Z", + period_end_date="2025-06-30", + ) + + linked = link_quarter_revisions([original, colliding_identity]) + + assert len(linked) == 2 + assert linked[1].supersedes_source_ref is None + + +def test_unrelated_conflicting_source_is_not_marked_as_revision(): + sec_original = actual( + "2025-Q2", + revenue=100, + eps=1.0, + source_ref="sec://original", + reported_at="2025-08-01T00:00:00Z", + ) + unrelated = actual( + "2025-Q2", + revenue=100, + eps=0.1, + source_ref="other://conflict", + reported_at="2025-11-01T00:00:00Z", + source="other_source", + ) + + linked = link_quarter_revisions([sec_original, unrelated]) + + assert len(linked) == 2 + assert linked[1].supersedes_source_ref is None + + +def test_stage_writes_only_explicit_output_directory(tmp_path): + result = write_sec_actuals_stage(tmp_path / "stage", {"SYN1": extraction_result()}) + + stage_dir = tmp_path / "stage" + assert Path(result.quarterly_actuals_path).parent == stage_dir + assert (stage_dir / "quarterly_actuals.csv").exists() + assert (stage_dir / "consensus_snapshots.csv").read_text(encoding="utf-8").count("\n") == 1 + assert (stage_dir / "signals.csv").read_text(encoding="utf-8").count("\n") == 1 + assert result.automatic_apply is False + assert not (tmp_path / "data").exists() + + audit = json.loads((stage_dir / "sec_actuals_audit.json").read_text(encoding="utf-8")) + assert audit["audit_rows"][0]["concept"] == "Revenues" + assert audit["audit_rows"][0]["start"] == "2025-01-01" + assert audit["audit_rows"][0]["end"] == "2025-06-30" + assert audit["audit_rows"][0]["frame"] == "CY2025" + assert audit["audit_rows"][0]["accession"] == "0000000000-25-000001" + + with (stage_dir / "sec_actuals_rejected.csv").open(newline="", encoding="utf-8") as handle: + rejected = list(csv.DictReader(handle)) + assert rejected[0]["reason_code"] == "cumulative_fact_rejected" + assert rejected[0]["state"] == "cumulative_fact_rejected" + + +def test_sec_actuals_cli_json_uses_injected_cached_fixture_stage(tmp_path, capsys): + def cached_fixture_stage(tickers, **kwargs): + assert tickers == ["SYN1"] + assert kwargs["output_dir"] == tmp_path / "stage" + assert kwargs["cutoff"] == CUTOFF + assert kwargs["allow_network"] is False + assert kwargs["refresh"] is False + return write_sec_actuals_stage(tmp_path / "stage", {"SYN1": extraction_result()}) + + sec_actuals.main( + [ + "--tickers", + "SYN1", + "--output-dir", + str(tmp_path / "stage"), + "--cutoff", + CUTOFF, + "--no-network", + "--json", + ], + stage_runner=cached_fixture_stage, + ) + + payload = json.loads(capsys.readouterr().out) + assert payload["automatic_apply"] is False + assert payload["tickers"]["SYN1"]["accepted_rows"] + assert payload["tickers"]["SYN1"]["rejected_rows"] + assert payload["tickers"]["SYN1"]["metrics"]["revenue"]["missing_q4"] is True + assert payload["tickers"]["SYN1"]["metrics"]["eps"]["missing_q4"] is True + assert payload["tickers"]["SYN1"]["source_refs"] + + +def test_sec_actuals_cli_fails_closed_when_stage_exceeds_max_runtime(tmp_path, capsys): + def slow_stage(_tickers, **_kwargs): + time.sleep(0.05) + return write_sec_actuals_stage(tmp_path / "stage", {"SYN1": extraction_result()}) + + with pytest.raises(SystemExit) as exc: + sec_actuals.main( + [ + "--tickers", + "SYN1", + "--output-dir", + str(tmp_path / "stage"), + "--cutoff", + CUTOFF, + "--no-network", + "--max-runtime-seconds", + "0.01", + ], + stage_runner=slow_stage, + ) + + assert exc.value.code == 2 + captured = capsys.readouterr() + assert "environment_limited" in captured.err + assert "exceeded max runtime" in captured.err + + +@pytest.mark.parametrize( + "output_dir", + ( + Path(sec_actuals.__file__).resolve().parents[1] / "data" / "earnings_nowcast", + Path(sec_actuals.__file__).resolve().parents[1] / "data" / "imports" / "earnings_nowcast", + ), +) +def test_sec_actuals_cli_rejects_canonical_output_directories(output_dir, capsys): + stage_called = False + + def forbidden_stage(*_args, **_kwargs): + nonlocal stage_called + stage_called = True + raise AssertionError("canonical output must be rejected before staging") + + with pytest.raises(SystemExit) as exc: + sec_actuals.main( + [ + "--tickers", + "SYN1", + "--output-dir", + str(output_dir), + "--cutoff", + CUTOFF, + "--no-network", + ], + stage_runner=forbidden_stage, + ) + + assert exc.value.code == 2 + assert stage_called is False + assert "generated temporary/review directory" in capsys.readouterr().err + + +def test_stage_rejects_existing_non_generated_evidence_directory(tmp_path): + existing_evidence = tmp_path / "existing-evidence" + existing_evidence.mkdir() + sentinel = existing_evidence / "quarterly_actuals.csv" + sentinel.write_text("trusted,evidence\n", encoding="utf-8") + + with pytest.raises(ValueError, match="existing non-generated evidence directory"): + write_sec_actuals_stage(existing_evidence, {"SYN1": extraction_result()}) + + assert sentinel.read_text(encoding="utf-8") == "trusted,evidence\n" + + +def test_stage_keeps_revenue_only_metric_partial_out_of_rejected_rows(tmp_path): + extraction = extract_q1_q3_lineage( + "SYN1", + companyfacts_fixture(revenue=[_fact(val=12, start="2026-02-27", end="2026-05-28")], eps=[]), + cutoff=CUTOFF, + retrieved_at=RETRIEVED_AT, + ) + + result = write_sec_actuals_stage(tmp_path / "stage", {"SYN1": extraction}) + + with Path(result.quarterly_actuals_path).open(newline="", encoding="utf-8") as handle: + staged_rows = list(csv.DictReader(handle)) + with Path(result.rejected_path).open(newline="", encoding="utf-8") as handle: + rejected_rows = list(csv.DictReader(handle)) + + assert len(staged_rows) == 1 + assert staged_rows[0]["revenue_actual"] == "12.0" + assert staged_rows[0]["eps_actual"] == "" + assert result.rejected_row_count == 0 + assert rejected_rows == [] + + +def test_stage_summary_reports_q4_and_continuity_by_metric(tmp_path): + result = write_sec_actuals_stage( + tmp_path / "stage", + { + "SYN1": ExtractionResult( + rows=( + actual( + "2025-Q3", + revenue=100, + eps=None, + source_ref="sec://revenue-q3", + reported_at="2025-11-01T00:00:00Z", + period_end_date="2025-09-30", + ), + actual( + "2025-Q4", + revenue=110, + eps=None, + source_ref="sec://revenue-q4", + reported_at="2026-02-01T00:00:00Z", + period_end_date="2025-12-31", + ), + actual( + "2025-Q3", + revenue=None, + eps=1.0, + source_ref="sec://eps-q3", + reported_at="2025-11-01T00:00:00Z", + period_end_date="2025-09-30", + ), + actual( + "2026-Q1", + revenue=None, + eps=1.1, + source_ref="sec://eps-q1", + reported_at="2026-05-01T00:00:00Z", + period_end_date="2026-03-31", + ), + ), + audit_rows=(), + ) + }, + ) + + summary = sec_actuals.build_sec_actuals_stage_summary(result)["tickers"]["SYN1"] + + assert summary["metrics"]["revenue"]["missing_q4"] is False + assert summary["metrics"]["revenue"]["continuity_gaps"] == [] + assert len(summary["metrics"]["revenue"]["accepted_rows"]) == 2 + assert summary["metrics"]["eps"]["missing_q4"] is True + assert summary["metrics"]["eps"]["continuity_gaps"] == [ + { + "after_fiscal_period": "2025-Q3", + "before_fiscal_period": "2026-Q1", + "missing_fiscal_periods": ["2025-Q4"], + } + ] + assert len(summary["metrics"]["eps"]["accepted_rows"]) == 2 + + inverse_result = write_sec_actuals_stage( + tmp_path / "inverse-stage", + { + "SYN1": ExtractionResult( + rows=( + actual( + "2025-Q3", + revenue=100, + eps=None, + source_ref="sec://inverse-revenue-q3", + reported_at="2025-11-01T00:00:00Z", + period_end_date="2025-09-30", + ), + actual( + "2026-Q1", + revenue=110, + eps=None, + source_ref="sec://inverse-revenue-q1", + reported_at="2026-05-01T00:00:00Z", + period_end_date="2026-03-31", + ), + actual( + "2025-Q3", + revenue=None, + eps=1.0, + source_ref="sec://inverse-eps-q3", + reported_at="2025-11-01T00:00:00Z", + period_end_date="2025-09-30", + ), + actual( + "2025-Q4", + revenue=None, + eps=1.1, + source_ref="sec://inverse-eps-q4", + reported_at="2026-02-01T00:00:00Z", + period_end_date="2025-12-31", + ), + ), + audit_rows=(), + ) + }, + ) + + inverse_summary = sec_actuals.build_sec_actuals_stage_summary(inverse_result)["tickers"]["SYN1"] + + assert inverse_summary["metrics"]["revenue"]["missing_q4"] is True + assert inverse_summary["metrics"]["revenue"]["continuity_gaps"] == [ + { + "after_fiscal_period": "2025-Q3", + "before_fiscal_period": "2026-Q1", + "missing_fiscal_periods": ["2025-Q4"], + } + ] + assert inverse_summary["metrics"]["eps"]["missing_q4"] is False + assert inverse_summary["metrics"]["eps"]["continuity_gaps"] == [] + + +def test_stage_writes_fiscal_period_conflicts_to_rejected_rows(tmp_path): + result = write_sec_actuals_stage( + tmp_path / "stage", + { + "SYN1": extract_q1_q3_lineage( + "SYN1", + companyfacts_fixture( + revenue=[ + _fact(val=12, start="2026-02-27", end="2026-05-28", fp="Q2"), + _fact(val=12, start="2026-02-27", end="2026-05-28", fp="Q3"), + ], + eps=[ + _fact(val=1.2, start="2026-02-27", end="2026-05-28", fp="Q2"), + _fact(val=1.2, start="2026-02-27", end="2026-05-28", fp="Q3"), + ], + ), + cutoff=CUTOFF, + retrieved_at=RETRIEVED_AT, + ) + }, + ) + + with Path(result.rejected_path).open(newline="", encoding="utf-8") as handle: + rejected_rows = list(csv.DictReader(handle)) + + assert result.rejected_row_count == 4 + assert {row["state"] for row in rejected_rows} == {"fiscal_period_conflict"} + + +def test_stage_rejects_direct_rows_with_one_identity_and_multiple_period_ends(tmp_path): + result = write_sec_actuals_stage( + tmp_path / "stage", + { + "SYN1": ExtractionResult( + rows=( + actual( + "2025-Q2", + revenue=100, + eps=1.0, + source_ref="sec://first-period-end", + reported_at="2025-08-01T00:00:00Z", + period_end_date="2025-06-29", + ), + actual( + "2025-Q2", + revenue=110, + eps=1.1, + source_ref="sec://second-period-end", + reported_at="2025-11-01T00:00:00Z", + period_end_date="2025-06-30", + ), + ), + audit_rows=(), + ) + }, + ) + + rows = list(csv.DictReader(Path(result.quarterly_actuals_path).open(encoding="utf-8"))) + rejected_rows = list(csv.DictReader(Path(result.rejected_path).open(encoding="utf-8"))) + assert rows == [] + assert result.rejected_row_count == 2 + assert {row["state"] for row in rejected_rows} == {"fiscal_period_conflict"} + + +def test_stage_rejects_direct_rows_with_one_period_end_and_multiple_identities(tmp_path): + result = write_sec_actuals_stage( + tmp_path / "stage", + { + "SYN1": ExtractionResult( + rows=( + actual( + "2025-Q3", + revenue=100, + eps=1.0, + source_ref="sec://q3-collision", + reported_at="2025-11-01T00:00:00Z", + period_end_date="2025-09-30", + ), + actual( + "2025-Q4", + revenue=110, + eps=1.1, + source_ref="sec://q4-collision", + reported_at="2026-02-01T00:00:00Z", + period_end_date="2025-09-30", + ), + ), + audit_rows=(), + ) + }, + ) + + rows = list(csv.DictReader(Path(result.quarterly_actuals_path).open(encoding="utf-8"))) + rejected_rows = list(csv.DictReader(Path(result.rejected_path).open(encoding="utf-8"))) + assert rows == [] + assert result.rejected_row_count == 2 + assert {row["state"] for row in rejected_rows} == {"fiscal_period_conflict"} + + +def test_stage_orchestrator_writes_unresolved_and_fetch_failures_to_rejected_rows(tmp_path): + result = stage_sec_quarterly_actuals( + ["missing", "broken"], + output_dir=tmp_path / "stage", + cutoff=CUTOFF, + user_agent="Test test@example.com", + retrieved_at=RETRIEVED_AT, + ticker_map={"BROKEN": {"ticker": "BROKEN", "cik": "0000123456"}}, + companyfacts_loader=lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("fixture failure")), + ) + + with Path(result.rejected_path).open(newline="", encoding="utf-8") as handle: + rejected_rows = list(csv.DictReader(handle)) + + assert result.rejected_row_count == 2 + assert {(row["ticker"], row["state"]) for row in rejected_rows} == { + ("BROKEN", "companyfacts_fetch_failed"), + ("MISSING", "ticker_unresolved"), + } + + +def test_stage_uses_injected_ticker_map_and_companyfacts_fetcher(tmp_path): + result = stage_sec_quarterly_actuals( + ["syn1", "missing"], + output_dir=tmp_path / "stage", + cutoff=CUTOFF, + user_agent="Test test@example.com", + retrieved_at=RETRIEVED_AT, + ticker_map={"SYN1": {"ticker": "SYN1", "cik": "0000123456"}}, + companyfacts_fetcher=lambda *_: companyfacts_fixture( + revenue=[_fact(val=12, start="2026-02-27", end="2026-05-28")], + eps=[_fact(val=1.2, start="2026-02-27", end="2026-05-28")], + ), + submissions_loader=lambda *_args, **_kwargs: {"filings": {"recent": {}}}, + ) + + assert result.requested_tickers == ("MISSING", "SYN1") + assert result.accepted_tickers == ("SYN1",) + assert result.withheld_tickers == ("MISSING",) + assert result.accepted_row_count == 1 + assert result.automatic_apply is False + + +def test_stage_records_unavailable_q4_source_when_submissions_have_no_eligible_candidate(tmp_path): + submissions = { + "cik": "123456", + "filings": { + "recent": { + "form": ["10-K", "10-Q"], + "filingDate": ["2026-02-25", "2026-05-25"], + "reportDate": ["2026-01-25", "2026-04-25"], + "accessionNumber": ["0000123456-26-000001", "0000123456-26-000002"], + } + }, + } + + result = stage_sec_quarterly_actuals( + ["syn1"], + output_dir=tmp_path / "stage", + cutoff=CUTOFF, + user_agent="Test test@example.com", + retrieved_at=RETRIEVED_AT, + ticker_map={"SYN1": {"ticker": "SYN1", "cik": "0000123456"}}, + companyfacts_loader=lambda *_args, **_kwargs: companyfacts_fixture(revenue=[], eps=[]), + submissions_loader=lambda *_args, **_kwargs: submissions, + filing_index_loader=lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("no eligible Q4 candidate must not fetch a filing index") + ), + ) + + rejected_rows = list(csv.DictReader(Path(result.rejected_path).open(encoding="utf-8"))) + assert result.accepted_row_count == 0 + assert {(row["state"], row["detail"]) for row in rejected_rows} == { + ( + "q4_source_unavailable", + "no eligible 8-K or 8-K/A Q4 candidate filing was found", + ), + } + + +def test_stage_combines_explicit_q4_exhibit_using_injected_document_loaders(tmp_path): + submissions = { + "cik": "123456", + "filings": { + "recent": { + "form": ["8-K"], + "filingDate": ["2026-02-25"], + "reportDate": ["2026-01-25"], + "accessionNumber": ["0000123456-26-000001"], + } + }, + } + index_html = """ + +
earnings-release.htmEarnings releaseEX-99.1
+ """ + release_html = ( + Q4_RELEASE_HTML.replace("Fiscal 2025", "Fiscal 2026") + .replace("FY25", "FY26") + .replace("January 26, 2025", "January 25, 2026") + ) + + result = stage_sec_quarterly_actuals( + ["syn1"], + output_dir=tmp_path / "stage", + cutoff=CUTOFF, + user_agent="Test test@example.com", + retrieved_at=RETRIEVED_AT, + ticker_map={"SYN1": {"ticker": "SYN1", "cik": "0000123456"}}, + companyfacts_loader=lambda *_args, **_kwargs: companyfacts_fixture(revenue=[], eps=[]), + submissions_loader=lambda *_args, **_kwargs: submissions, + filing_index_loader=lambda *_args, **_kwargs: index_html, + exhibit_document_loader=lambda *_args, **_kwargs: release_html, + ) + + rows = list(csv.DictReader(Path(result.quarterly_actuals_path).open(encoding="utf-8"))) + assert [(row["fiscal_period"], row["source"]) for row in rows] == [("2026-Q4", "sec_filed_exhibit")] + assert rows[0]["period_end_date"] == "2026-01-25" + assert rows[0]["source_ref"].endswith("/earnings-release.htm") + + +def test_stage_does_not_use_submission_report_date_as_q4_period_end(tmp_path): + submissions = { + "cik": "123456", + "filings": { + "recent": { + "form": ["8-K"], + "filingDate": ["2026-02-25"], + "reportDate": ["2026-01-25"], + "accessionNumber": ["0000123456-26-000001"], + } + }, + } + index_html = """ + +
earnings-release.htmEarnings releaseEX-99.1
+ """ + release_without_period_end = """ +

Fourth Quarter Fiscal 2026 Summary

+ + +
Q4 FY26
Revenue$40 billion
GAAP diluted earnings per share$0.89
+ """ + + result = stage_sec_quarterly_actuals( + ["syn1"], + output_dir=tmp_path / "stage", + cutoff=CUTOFF, + user_agent="Test test@example.com", + retrieved_at=RETRIEVED_AT, + ticker_map={"SYN1": {"ticker": "SYN1", "cik": "0000123456"}}, + companyfacts_loader=lambda *_args, **_kwargs: companyfacts_fixture(revenue=[], eps=[]), + submissions_loader=lambda *_args, **_kwargs: submissions, + filing_index_loader=lambda *_args, **_kwargs: index_html, + exhibit_document_loader=lambda *_args, **_kwargs: release_without_period_end, + ) + + rows = list(csv.DictReader(Path(result.quarterly_actuals_path).open(encoding="utf-8"))) + rejected_rows = list(csv.DictReader(Path(result.rejected_path).open(encoding="utf-8"))) + assert rows == [] + assert {row["state"] for row in rejected_rows} == {"period_end_missing"} + + +def test_stage_withholds_independent_q4_metrics_from_separate_exhibits(tmp_path): + submissions = { + "cik": "123456", + "filings": { + "recent": { + "form": ["8-K"], + "filingDate": ["2026-02-25"], + "reportDate": ["2026-01-25"], + "accessionNumber": ["0000123456-26-000001"], + } + }, + } + index_html = """ + + + +
revenue.htmEX-99.1
eps.htmEX-99.2
+ """ + documents = { + "revenue.htm": """ +

Fourth Quarter Fiscal 2026 Summary

+ + +
Q4 FY26
Period endedJanuary 25, 2026
Revenue$40 billion
+ """, + "eps.htm": """ +

Fourth Quarter Fiscal 2026 Summary

+ + +
Q4 FY26
Period endedJanuary 25, 2026
GAAP diluted earnings per share$0.89
+ """, + } + + result = stage_sec_quarterly_actuals( + ["syn1"], + output_dir=tmp_path / "stage", + cutoff=CUTOFF, + user_agent="Test test@example.com", + retrieved_at=RETRIEVED_AT, + ticker_map={"SYN1": {"ticker": "SYN1", "cik": "0000123456"}}, + companyfacts_loader=lambda *_args, **_kwargs: companyfacts_fixture(revenue=[], eps=[]), + submissions_loader=lambda *_args, **_kwargs: submissions, + filing_index_loader=lambda *_args, **_kwargs: index_html, + exhibit_document_loader=lambda _cik, _accession, document_name, *_args, **_kwargs: documents[document_name], + ) + + rows = list(csv.DictReader(Path(result.quarterly_actuals_path).open(encoding="utf-8"))) + rejected_rows = list(csv.DictReader(Path(result.rejected_path).open(encoding="utf-8"))) + + assert rows == [] + assert {(row["state"], row["fiscal_period"]) for row in rejected_rows} >= { + ("ambiguous_concept", "2026-Q4"), + } + + +def test_stage_rejects_same_day_date_only_filing_before_end_of_day_cutoff(tmp_path): + submissions = { + "cik": "123456", + "filings": { + "recent": { + "form": ["8-K"], + "filingDate": ["2026-02-25"], + "reportDate": ["2026-01-25"], + "accessionNumber": ["0000123456-26-000001"], + } + }, + } + + result = stage_sec_quarterly_actuals( + ["syn1"], + output_dir=tmp_path / "stage", + cutoff="2026-02-25T12:00:00Z", + user_agent="Test test@example.com", + retrieved_at=RETRIEVED_AT, + ticker_map={"SYN1": {"ticker": "SYN1", "cik": "0000123456"}}, + companyfacts_loader=lambda *_args, **_kwargs: companyfacts_fixture(revenue=[], eps=[]), + submissions_loader=lambda *_args, **_kwargs: submissions, + filing_index_loader=lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("same-day filing must fail closed before exhibit lookup")), + ) + + rows = list(csv.DictReader(Path(result.quarterly_actuals_path).open(encoding="utf-8"))) + rejected_rows = list(csv.DictReader(Path(result.rejected_path).open(encoding="utf-8"))) + assert rows == [] + assert {(row["state"], row["accession"]) for row in rejected_rows} == {("post_cutoff_rejected", "0000123456-26-000001")} + + +def test_normalization_rejects_non_json_numeric_scalars_and_non_integral_fiscal_years(): + payload = companyfacts_fixture( + revenue=[ + _fact(val=12, start="2026-02-27", end="2026-05-28", frame="CY2026Q2"), + _fact(val=True, start="2026-02-27", end="2026-05-28"), + _fact(val="12", start="2026-02-27", end="2026-05-28"), + _fact(val=12, start="2026-02-27", end="2026-05-28", fy=2026.9), + _fact(val=12, start="2026-02-27", end="2026-05-28", fy=True), + _fact(val=12, start="2026-02-27", end="2026-05-28", fy="2026"), + ], + eps=[], + ) + + facts = normalize_sec_duration_facts(payload) + + assert [(fact.value, fact.fiscal_year) for fact in facts] == [(12.0, 2026)] + + +def test_normalization_rejects_oversized_integer_scalars(): + oversized_integer = 10**400 + payload = companyfacts_fixture( + revenue=[ + _fact(val=12, start="2026-02-27", end="2026-05-28", frame="CY2026Q2"), + _fact(val=oversized_integer, start="2026-02-27", end="2026-05-28"), + _fact(val=12, start="2026-02-27", end="2026-05-28", fy=oversized_integer), + ], + eps=[], + ) + + facts = normalize_sec_duration_facts(payload) + + assert [(fact.value, fact.fiscal_year) for fact in facts] == [(12.0, 2026)] + + +def test_q3_lineage_keeps_aligned_quarter_and_rejects_ytd_and_comparative_period(): + payload = companyfacts_fixture( + revenue=[ + _fact(val=30, start="2025-08-29", end="2026-05-28"), + _fact(val=12, start="2026-02-27", end="2026-05-28", frame="CY2026Q2"), + _fact(val=8, start="2025-02-28", end="2025-05-29", frame="CY2025Q2"), + ], + eps=[ + _fact(val=3.0, start="2025-08-29", end="2026-05-28"), + _fact(val=1.2, start="2026-02-27", end="2026-05-28", frame="CY2026Q2"), + _fact(val=0.8, start="2025-02-28", end="2025-05-29", frame="CY2025Q2"), + ], + ) + + facts = normalize_sec_duration_facts(payload) + result = extract_q1_q3_lineage("SYN1", payload, cutoff=CUTOFF, retrieved_at=RETRIEVED_AT) + + assert len(facts) == 6 + assert sorted(fact.duration_days for fact in facts) == [90, 90, 90, 90, 272, 272] + assert [(row.fiscal_period, row.revenue_actual, row.eps_actual) for row in result.rows] == [ + ("2026-Q3", 12.0, 1.2) + ] + assert {row.state for row in result.audit_rows} >= { + "accepted_explicit_quarter", + "cumulative_fact_rejected", + "comparative_period_relabelled", + } + + +def test_later_comparative_uses_original_fiscal_identity_and_unknown_comparative_fails_closed(): + original_q2_revenue = _fact( + val=8, + start="2025-02-28", + end="2025-05-29", + filed="2025-06-25", + fy=2025, + fp="Q2", + frame="CY2025Q2", + accn="0000000000-25-000001", + ) + original_q2_eps = _fact( + val=0.8, + start="2025-02-28", + end="2025-05-29", + filed="2025-06-25", + fy=2025, + fp="Q2", + frame="CY2025Q2", + accn="0000000000-25-000001", + ) + comparative_revenue = _fact(val=9, start="2025-02-28", end="2025-05-29") + comparative_eps = _fact(val=0.9, start="2025-02-28", end="2025-05-29") + current_revenue = _fact(val=12, start="2026-02-27", end="2026-05-28", frame="CY2026Q2") + current_eps = _fact(val=1.2, start="2026-02-27", end="2026-05-28", frame="CY2026Q2") + + result = extract_q1_q3_lineage( + "SYN1", + companyfacts_fixture( + revenue=[original_q2_revenue, comparative_revenue, current_revenue], + eps=[original_q2_eps, comparative_eps, current_eps], + ), + cutoff=CUTOFF, + retrieved_at=RETRIEVED_AT, + ) + + q2_rows = [row for row in result.rows if row.fiscal_period == "2025-Q2"] + assert [(row.revenue_actual, row.eps_actual) for row in q2_rows] == [(8.0, 0.8), (9.0, 0.9)] + assert q2_rows[1].reported_at == "2026-06-25T23:59:59+00:00" + assert "accepted_revision" in {row.state for row in result.audit_rows} + + no_original = extract_q1_q3_lineage( + "SYN1", + companyfacts_fixture(revenue=[comparative_revenue, current_revenue], eps=[comparative_eps, current_eps]), + cutoff=CUTOFF, + retrieved_at=RETRIEVED_AT, + ) + + assert [row.fiscal_period for row in no_original.rows] == ["2026-Q3"] + assert "comparative_period_relabelled" in {row.state for row in no_original.audit_rows} + + +def test_companyfacts_date_only_filing_is_available_at_end_of_day(): + payload = companyfacts_fixture( + revenue=[ + _fact( + val=12, + start="2026-01-20", + end="2026-04-19", + filed="2026-04-20", + fy=2026, + fp="Q1", + ) + ], + eps=[ + _fact( + val=1.2, + start="2026-01-20", + end="2026-04-19", + filed="2026-04-20", + fy=2026, + fp="Q1", + ) + ], + ) + + early = extract_q1_q3_lineage( + "SYN1", + payload, + cutoff="2026-04-20T00:00:01Z", + retrieved_at=RETRIEVED_AT, + ) + end_of_day = extract_q1_q3_lineage( + "SYN1", + payload, + cutoff="2026-04-20T23:59:59Z", + retrieved_at=RETRIEVED_AT, + ) + + assert early.rows == () + assert "post_cutoff_rejected" in {row.state for row in early.audit_rows} + assert len(end_of_day.rows) == 1 + assert end_of_day.rows[0].reported_at == "2026-04-20T23:59:59+00:00" + + +def test_one_fiscal_identity_mapping_to_multiple_period_ends_fails_closed(): + payload = companyfacts_fixture( + revenue=[ + _fact( + val=12, + start="2026-02-27", + end="2026-05-28", + fy=2026, + fp="Q2", + accn="0000000000-26-000001", + ), + _fact( + val=13, + start="2026-02-28", + end="2026-05-29", + fy=2026, + fp="Q2", + accn="0000000000-26-000002", + ), + ], + eps=[ + _fact( + val=1.2, + start="2026-02-27", + end="2026-05-28", + fy=2026, + fp="Q2", + accn="0000000000-26-000001", + ), + _fact( + val=1.3, + start="2026-02-28", + end="2026-05-29", + fy=2026, + fp="Q2", + accn="0000000000-26-000002", + ), + ], + ) + + result = extract_q1_q3_lineage("SYN1", payload, cutoff=CUTOFF, retrieved_at=RETRIEVED_AT) + + assert result.rows == () + conflicts = [row for row in result.audit_rows if row.state == "fiscal_period_conflict"] + assert len(conflicts) == 4 + assert {row.end for row in conflicts} == {"2026-05-28", "2026-05-29"} + + +def test_companyfacts_eps_split_basis_is_unverified_for_split_restatement(): + original_eps = _fact( + val=0.80, + start="2025-02-28", + end="2025-05-29", + filed="2025-06-25", + fy=2025, + fp="Q2", + accn="0000000000-25-000001", + ) + comparative_eps = _fact( + val=0.08, + start="2025-02-28", + end="2025-05-29", + filed="2026-06-25", + fy=2026, + fp="Q3", + accn="0000000000-26-000001", + ) + result = extract_q1_q3_lineage( + "SYN1", + companyfacts_fixture( + revenue=[ + _fact( + val=8, + start="2025-02-28", + end="2025-05-29", + filed="2025-06-25", + fy=2025, + fp="Q2", + accn="0000000000-25-000001", + ), + _fact(val=12, start="2026-02-27", end="2026-05-28"), + ], + eps=[ + original_eps, + comparative_eps, + _fact(val=1.2, start="2026-02-27", end="2026-05-28"), + ], + ), + cutoff=CUTOFF, + retrieved_at=RETRIEVED_AT, + ) + + eps_rows = [row for row in result.rows if row.eps_actual is not None] + assert {row.split_adjustment_basis for row in eps_rows} == { + "companyfacts_split_basis_unverified" + } + assert "split_basis_unverified" in {row.state for row in result.audit_rows} + + +def test_explicit_filed_q4_keeps_revenue_ready_while_companyfacts_eps_is_withheld(): + companyfacts = extract_q1_q3_lineage( + "SYN1", + companyfacts_fixture( + revenue=[_fact(val=36_000_000_000, start="2026-02-27", end="2026-05-28")], + eps=[_fact(val=0.8, start="2026-02-27", end="2026-05-28")], + ), + cutoff=CUTOFF, + retrieved_at=RETRIEVED_AT, + ) + filed_q4 = extract_explicit_q4_actual( + "SYN1", + Q4_EXHIBIT, + Q4_RELEASE_HTML, + fiscal_period="2025-Q4", + filed_at="2026-02-25T00:00:00Z", + retrieved_at=RETRIEVED_AT, + ) + + companyfacts_seed = companyfacts.rows[0] + q4_seed = filed_q4.rows[0] + history = [ + replace( + q4_seed, + fiscal_period="2024-Q4", + period_end_date="2024-12-31", + reported_at="2025-02-20T00:00:00Z", + retrieved_at="2025-02-21T00:00:00Z", + revenue_actual=30_000_000_000, + eps_actual=0.70, + source_ref=f"{q4_seed.source_ref}#2024-Q4", + ), + replace( + companyfacts_seed, + fiscal_period="2025-Q1", + period_end_date="2025-03-31", + reported_at="2025-05-20T00:00:00Z", + retrieved_at="2025-05-21T00:00:00Z", + revenue_actual=32_000_000_000, + eps_actual=0.72, + source_ref=f"{companyfacts_seed.source_ref}#2025-Q1", + ), + replace( + companyfacts_seed, + fiscal_period="2025-Q2", + period_end_date="2025-06-30", + reported_at="2025-08-20T00:00:00Z", + retrieved_at="2025-08-21T00:00:00Z", + revenue_actual=34_000_000_000, + eps_actual=0.76, + source_ref=f"{companyfacts_seed.source_ref}#2025-Q2", + ), + replace( + companyfacts_seed, + fiscal_period="2025-Q3", + period_end_date="2025-09-30", + reported_at="2025-11-20T00:00:00Z", + retrieved_at="2025-11-21T00:00:00Z", + revenue_actual=36_000_000_000, + eps_actual=0.80, + source_ref=f"{companyfacts_seed.source_ref}#2025-Q3", + ), + replace( + q4_seed, + fiscal_period="2025-Q4", + period_end_date="2025-12-31", + reported_at="2026-02-20T00:00:00Z", + retrieved_at="2026-02-21T00:00:00Z", + revenue_actual=39_331_000_000, + source_ref=f"{q4_seed.source_ref}#2025-Q4", + ), + ] + consensus = ConsensusSnapshot( + ticker="SYN1", + fiscal_period="2026-Q1", + snapshot_at="2026-02-25T00:00:00Z", + retrieved_at="2026-02-25T01:00:00Z", + revenue_consensus=41_000_000_000, + eps_consensus=0.90, + source="synthetic_test_fixture", + source_ref="fixture:consensus:2026-Q1", + revenue_basis=history[0].revenue_basis, + eps_basis=history[-1].eps_basis, + eps_share_basis=history[-1].eps_share_basis, + eps_operations_basis=history[-1].eps_operations_basis, + split_adjustment_basis=history[-1].split_adjustment_basis, + ) + + readiness = assess_nowcast_readiness( + ticker="SYN1", + fiscal_period="2026-Q1", + as_of_timestamp="2026-03-01T00:00:00Z", + actuals=history, + consensus=[consensus], + ) + + assert readiness.revenue_ready is True + assert readiness.eps_ready is False + assert "incompatible_eps_definition" in readiness.missing_evidence + assert history[-1].split_adjustment_basis == "split_adjusted_2024_06_07" + assert { + row.split_adjustment_basis for row in history[1:4] + } == {"companyfacts_split_basis_unverified"} + + +def test_unproven_filed_q4_split_basis_keeps_revenue_ready_and_withholds_eps(): + extracted = extract_explicit_q4_actual( + "SYN1", + Q4_EXHIBIT, + Q4_RELEASE_HTML.replace( + "All per-share amounts are retrospectively adjusted for the ten-for-one split effective June 7, 2024.", + "", + ), + fiscal_period="2025-Q4", + filed_at="2026-02-25T00:00:00Z", + retrieved_at=RETRIEVED_AT, + ) + seed = extracted.rows[0] + periods = ( + ("2024-Q1", "2024-03-31", 20.0, 0.50), + ("2024-Q2", "2024-06-30", 22.0, 0.55), + ("2024-Q3", "2024-09-30", 24.0, 0.60), + ("2024-Q4", "2024-12-31", 26.0, 0.65), + ("2025-Q1", "2025-03-31", 28.0, 0.70), + ) + history = [ + replace( + seed, + fiscal_period=period, + period_end_date=period_end, + reported_at=f"{period_end}T20:00:00Z", + retrieved_at=f"{period_end}T21:00:00Z", + revenue_actual=revenue, + eps_actual=eps, + source_ref=f"{seed.source_ref}#{period}", + ) + for period, period_end, revenue, eps in periods + ] + consensus = ConsensusSnapshot( + ticker="SYN1", + fiscal_period="2025-Q2", + snapshot_at="2025-04-15T00:00:00Z", + retrieved_at="2025-04-15T01:00:00Z", + revenue_consensus=30.0, + eps_consensus=0.75, + source="synthetic_test_fixture", + source_ref="fixture:consensus:2025-Q2", + split_adjustment_basis=PRIMARY_SPLIT_BASIS_UNVERIFIED, + ) + + readiness = assess_nowcast_readiness( + ticker="SYN1", + fiscal_period="2025-Q2", + as_of_timestamp="2025-04-16T00:00:00Z", + actuals=history, + consensus=[consensus], + ) + + assert readiness.revenue_ready is True + assert readiness.eps_ready is False + assert "incompatible_eps_definition" in readiness.missing_evidence + assert {row.split_adjustment_basis for row in history} == { + PRIMARY_SPLIT_BASIS_UNVERIFIED + } + + +def test_missing_frame_keeps_uniquely_aligned_quarter(): + result = extract_q1_q3_lineage( + "SYN1", + companyfacts_fixture( + revenue=[_fact(val=12, start="2026-02-27", end="2026-05-28")], + eps=[_fact(val=1.2, start="2026-02-27", end="2026-05-28")], + ), + cutoff=CUTOFF, + retrieved_at=RETRIEVED_AT, + ) + + assert result.rows[0].revenue_actual == 12.0 + + +def test_conflicting_revenue_concepts_and_frames_withhold_only_revenue(): + revenue = _fact(val=12, start="2026-02-27", end="2026-05-28", frame="CY2026Q2") + eps = _fact(val=1.2, start="2026-02-27", end="2026-05-28", frame="CY2026Q2") + ambiguous_payload = companyfacts_fixture(revenue=[revenue], eps=[eps]) + ambiguous_payload["facts"]["us-gaap"]["Revenues"] = { + "units": {"USD": [_fact(val=13, start="2026-02-27", end="2026-05-28", frame="CY2026Q2")]} + } + conflicting_frame_payload = companyfacts_fixture( + revenue=[ + revenue, + _fact(val=12, start="2026-02-27", end="2026-05-28", frame="CY2026Q1"), + ], + eps=[eps], + ) + + ambiguous_result = extract_q1_q3_lineage( + "SYN1", ambiguous_payload, cutoff=CUTOFF, retrieved_at=RETRIEVED_AT + ) + conflicting_frame_result = extract_q1_q3_lineage( + "SYN1", conflicting_frame_payload, cutoff=CUTOFF, retrieved_at=RETRIEVED_AT + ) + + assert ambiguous_result.rows[0].revenue_actual is None + assert ambiguous_result.rows[0].eps_actual == 1.2 + assert conflicting_frame_result.rows[0].revenue_actual is None + assert "ambiguous_concept" in {row.state for row in ambiguous_result.audit_rows} + + +def test_revenue_only_quarter_is_preserved_as_metric_partial(): + result = extract_q1_q3_lineage( + "SYN1", + companyfacts_fixture(revenue=[_fact(val=12, start="2026-02-27", end="2026-05-28")], eps=[]), + cutoff=CUTOFF, + retrieved_at=RETRIEVED_AT, + ) + + assert result.rows[0].revenue_actual == 12.0 + assert result.rows[0].eps_actual is None + assert "metric_partial" in {row.state for row in result.audit_rows} + + +def test_post_cutoff_quarter_is_withheld_with_audit_state(): + result = extract_q1_q3_lineage( + "SYN1", + companyfacts_fixture( + revenue=[_fact(val=12, start="2026-02-27", end="2026-05-28", filed="2026-07-01")], + eps=[_fact(val=1.2, start="2026-02-27", end="2026-05-28", filed="2026-07-01")], + ), + cutoff=CUTOFF, + retrieved_at=RETRIEVED_AT, + ) + + assert result.rows == () + assert "post_cutoff_rejected" in {row.state for row in result.audit_rows} diff --git a/tests/test_earnings_nowcast_ui.py b/tests/test_earnings_nowcast_ui.py index b69faed2..c8b1d449 100644 --- a/tests/test_earnings_nowcast_ui.py +++ b/tests/test_earnings_nowcast_ui.py @@ -63,17 +63,19 @@ def test_public_answers_follow_reviewer_question_order(): assert list(answers) == [ "eligibility", - "baseline", - "ranges", + "actuals", "consensus", + "revenue", + "eps", "evidence_context", "withheld", "next_action", ] assert answers["eligibility"]["status"] == "synthetic_test_only" - assert answers["baseline"]["status"] == "synthetic_test_only" - assert "No real-company baseline" in answers["baseline"]["answer"] - assert "Revenue" in answers["ranges"]["answer"] + assert answers["actuals"]["status"] == "synthetic_test_only" + assert "test-only" in answers["actuals"]["answer"] + assert "Revenue range" in answers["revenue"]["answer"] + assert "EPS range" in answers["eps"]["answer"] assert "test-only" in answers["evidence_context"]["answer"] assert "probability" in answers["withheld"]["answer"].lower() @@ -82,10 +84,11 @@ def test_blocked_public_answers_expose_no_numbers_or_synthetic_evidence(): answers = nowcast_public_answers(None, ticker="NVDA") rendered = json.dumps(answers) - assert answers["baseline"]["status"] == "blocked" - assert answers["ranges"]["status"] == "withheld" + assert answers["actuals"]["status"] == "blocked" + assert answers["revenue"]["status"] == "withheld" + assert answers["eps"]["status"] == "withheld" assert "midpoint" not in rendered - assert not any(character.isdigit() for character in answers["ranges"]["answer"]) + assert not any(character.isdigit() for character in answers["revenue"]["answer"]) assert "SYN" not in rendered assert answers["next_action"]["answer"] == "Open Data Health" assert answers["eligibility"]["status"] == "eligibility_unverified" @@ -105,10 +108,10 @@ def test_internal_states_have_plain_english_labels(): def test_summary_card_body_presents_public_answers_in_review_order(): body = nowcast_summary_cards(_packet(), ticker="SYN1")[0]["body"] - labels = ["Eligibility:", "Baseline:", "Range:", "Consensus:", "Context:", "Withheld:", "Next:"] + labels = ["Eligibility:", "Actuals:", "Consensus:", "Revenue:", "EPS:", "Context:", "Withheld:", "Next:"] positions = [body.index(label) for label in labels] assert positions == sorted(positions) - assert body.count("\n") == 6 + assert body.count("\n") == 7 def test_real_packet_explains_metric_definitions_and_forecast_horizon(): @@ -129,7 +132,8 @@ def test_real_packet_explains_metric_definitions_and_forecast_horizon(): answers = nowcast_public_answers(packet, ticker="REAL") - assert "USD millions" in answers["ranges"]["answer"] - assert "GAAP diluted EPS" in answers["ranges"]["answer"] - assert "89-day forecast horizon" in answers["ranges"]["answer"] - assert "expected report date 2026-04-30" in answers["ranges"]["answer"] + combined = answers["revenue"]["answer"] + answers["eps"]["answer"] + assert "USD millions" in combined + assert "GAAP diluted EPS" in combined + assert "89-day forecast horizon" in combined + assert "expected report date 2026-04-30" in combined diff --git a/tests/test_focused_cohort_coverage.py b/tests/test_focused_cohort_coverage.py new file mode 100644 index 00000000..bc9f411d --- /dev/null +++ b/tests/test_focused_cohort_coverage.py @@ -0,0 +1,681 @@ +import pandas as pd +import pytest + +from src.commercial_source_rights import build_source_rights_registry +from src.focused_cohort_coverage import ( + COHORT_COVERAGE_LANES, + build_focused_cohort_coverage, + derive_cohort_evidence, + focused_cohort_coverage_frame, +) +from src.focused_research_cohort import build_focused_cohort +from src.quarterly_business_trend import build_quarterly_trend_packet +from src.earnings_nowcast_contract import QuarterlyActual + + +def _actual( + period: str, + revenue: float | None, + eps: float | None, + *, + source: str = "reviewed_fixture", + split_adjustment_basis: str = "as_reported", +) -> QuarterlyActual: + year, quarter = period.split("-Q") + return QuarterlyActual( + ticker="AAA", + fiscal_period=period, + period_end_date=f"{year}-{int(quarter) * 3:02d}-28", + reported_at=f"{int(year) + (1 if quarter == '4' else 0)}-02-01T00:00:00Z", + revenue_actual=revenue, + eps_actual=eps, + source=source, + source_ref=f"{source}:{period}", + retrieved_at="2026-07-17T00:00:00Z", + revenue_currency="USD", + revenue_unit_scale=1.0, + revenue_basis="gaap", + eps_currency="USD", + eps_basis="gaap_diluted", + eps_share_basis="diluted", + eps_operations_basis="continuing", + split_adjustment_basis=split_adjustment_basis, + ) + + +def _cohort(): + readiness = pd.DataFrame( + [ + { + "ticker": "AAA", + "price_ready": True, + "fundamentals_ready": True, + "dcf_ready": True, + "peer_ready": False, + } + ] + ) + universe = pd.DataFrame( + [ + { + "ticker": "AAA", + "name": "Alpha Co", + "asset_type": "company", + "is_active_listing": True, + } + ] + ) + return build_focused_cohort(readiness, universe, target_size=1, minimum_size=1), readiness + + +def _rights_registry(*supported_fields: str): + return build_source_rights_registry( + [ + { + "source_id": "licensed_source", + "display_name": "Licensed fixture source", + "permitted_use": "reviewed_research", + "commercial_use": "approved", + "redistribution": "derived_data_only", + "storage_limits": "reviewed rows only", + "attribution": "source reference required", + "rate_limits": "fixture only", + "authentication": "fixture only", + "expected_freshness": "fixture timestamp", + "supported_fields": list(supported_fields), + "fallback_priority": 1, + } + ] + ) + + +def test_coverage_matrix_reports_each_required_lane_without_padding_missing_evidence(): + cohort, readiness = _cohort() + actuals = ( + _actual("2024-Q1", 100, 1.0), + _actual("2024-Q4", 130, 1.3), + _actual("2025-Q1", 150, 1.5), + ) + packet = build_quarterly_trend_packet("AAA", actuals) + + coverage = build_focused_cohort_coverage( + cohort, + readiness, + quarterly_packets={"AAA": packet}, + evidence_by_ticker={ + "AAA": { + "margin_state": "partial", + "free_cash_flow_state": "blocked", + "cash_debt_state": "usable_now", + "shares_state": "usable_now", + "trusted_peers_state": "candidate_context_only", + "filing_dates_state": "usable_now", + "earnings_dates_state": "blocked", + "point_in_time_consensus_state": "blocked", + } + }, + ) + + assert coverage.status == "partial" + assert coverage.company_count == 1 + assert tuple(item.lane for item in coverage.rows) == COHORT_COVERAGE_LANES + states = {item.lane: item.state for item in coverage.rows} + assert states == { + "adjusted_daily_price_history": "usable_now", + "quarterly_revenue": "usable_now", + "quarterly_eps": "usable_now", + "margins": "partial", + "free_cash_flow": "blocked", + "cash_and_debt": "usable_now", + "shares_outstanding": "usable_now", + "trusted_peers": "candidate_context_only", + "filing_dates": "usable_now", + "earnings_dates": "blocked", + "point_in_time_consensus": "blocked", + } + + +def test_missing_evidence_is_blocked_and_non_company_is_excluded(): + cohort, readiness = _cohort() + coverage = build_focused_cohort_coverage(cohort, readiness) + states = {item.lane: item.state for item in coverage.rows} + assert states["adjusted_daily_price_history"] == "usable_now" + assert states["quarterly_revenue"] == "blocked" + assert states["trusted_peers"] == "blocked" + + excluded = build_focused_cohort_coverage( + cohort, + readiness, + evidence_by_ticker={"AAA": {"asset_type": "etf"}}, + ) + assert {item.state for item in excluded.rows} == {"excluded"} + + +def test_candidate_context_cannot_be_promoted_to_usable_and_invalid_states_fail(): + cohort, readiness = _cohort() + coverage = build_focused_cohort_coverage( + cohort, + readiness, + evidence_by_ticker={"AAA": {"trusted_peers_state": "candidate_context_only"}}, + ) + peer = next(item for item in coverage.rows if item.lane == "trusted_peers") + assert peer.state == "candidate_context_only" + assert "not trusted" in peer.boundary.lower() + + with pytest.raises(ValueError, match="unsupported coverage state"): + build_focused_cohort_coverage( + cohort, + readiness, + evidence_by_ticker={"AAA": {"margin_state": "ready_enough"}}, + ) + + +def test_coverage_frame_is_stable_and_contains_no_recommendation_language(): + cohort, readiness = _cohort() + coverage = build_focused_cohort_coverage(cohort, readiness) + frame = focused_cohort_coverage_frame(coverage) + + assert frame.columns.tolist() == ["Ticker", "Company", "Lane", "State", "Evidence", "Boundary"] + assert len(frame) == len(COHORT_COVERAGE_LANES) + rendered = frame.to_string(index=False).lower() + for prohibited in ("buy", "sell", "winner", "expected return", "recommendation score"): + assert prohibited not in rendered + + +def test_coverage_rejects_cohorts_over_commercial_beta_cap(): + cohort, readiness = _cohort() + oversized = cohort.__class__( + status="ready", + requested_size=50, + minimum_size=1, + eligible_count=51, + members=cohort.members * 51, + message="invalid", + ) + with pytest.raises(ValueError, match="cannot exceed 50"): + build_focused_cohort_coverage(oversized, readiness) + + +def test_derive_cohort_evidence_requires_values_and_source_provenance(): + fundamentals = pd.DataFrame( + [ + { + "ticker": "AAA", + "source": "sec_companyfacts", + "free_cash_flow": 10.0, + "operating_margin": 0.2, + "cash": 50.0, + "debt": 20.0, + "shares_outstanding": 100.0, + "sec_filed_date": "2026-05-01", + }, + { + "ticker": "BBB", + "source": "", + "free_cash_flow": 99.0, + "cash": 10.0, + "debt": 2.0, + "shares_outstanding": 200.0, + }, + ] + ) + readiness = pd.DataFrame( + [ + {"ticker": "AAA", "peer_ready": True}, + {"ticker": "BBB", "peer_ready": False}, + ] + ) + universe = pd.DataFrame( + [ + {"ticker": "AAA", "asset_type": "company"}, + {"ticker": "BBB", "asset_type": "company"}, + ] + ) + consensus = pd.DataFrame( + [ + { + "ticker": "AAA", + "fiscal_period": "2026-Q2", + "source": "licensed_consensus", + "source_ref": "consensus:AAA:2026-Q2", + "snapshot_at": "2026-07-01T00:00:00Z", + "revenue_consensus": 100.0, + } + ] + ) + earnings = pd.DataFrame( + [ + { + "ticker": "AAA", + "source": "sec_companyfacts", + "source_ref": "earnings:AAA", + "next_earnings_date": "2026-08-15", + } + ] + ) + + evidence = derive_cohort_evidence( + ("AAA", "BBB"), + fundamentals=fundamentals, + readiness=readiness, + universe=universe, + consensus=consensus, + earnings=earnings, + as_of="2026-07-17T00:00:00Z", + ) + + assert evidence["AAA"]["free_cash_flow_state"] == "usable_now" + assert evidence["AAA"]["margin_state"] == "usable_now" + assert evidence["AAA"]["cash_debt_state"] == "usable_now" + assert evidence["AAA"]["shares_state"] == "usable_now" + assert evidence["AAA"]["trusted_peers_state"] == "usable_now" + assert evidence["AAA"]["filing_dates_state"] == "usable_now" + assert evidence["AAA"]["point_in_time_consensus_state"] == "usable_now" + assert evidence["AAA"]["earnings_dates_state"] == "usable_now" + assert evidence["BBB"]["free_cash_flow_state"] == "blocked" + assert evidence["BBB"]["shares_state"] == "blocked" + + +def test_consensus_after_cutoff_and_unverified_commercial_sources_fail_closed(): + consensus = pd.DataFrame( + [ + { + "ticker": "AAA", + "fiscal_period": "2026-Q2", + "source": "yfinance", + "source_ref": "provider:AAA", + "snapshot_at": "2026-07-18T00:00:00Z", + } + ] + ) + fundamentals = pd.DataFrame( + [{"ticker": "AAA", "source": "yfinance", "source_ref": "provider:AAA", "shares_outstanding": 100}] + ) + + cutoff = derive_cohort_evidence( + ("AAA",), + consensus=consensus, + as_of="2026-07-17T00:00:00Z", + ) + commercial = derive_cohort_evidence( + ("AAA",), + fundamentals=fundamentals, + consensus=consensus.assign(snapshot_at="2026-07-16T00:00:00Z"), + as_of="2026-07-17T00:00:00Z", + commercial_mode=True, + ) + + assert cutoff["AAA"]["point_in_time_consensus_state"] == "blocked" + assert commercial["AAA"]["shares_state"] == "blocked" + assert commercial["AAA"]["point_in_time_consensus_state"] == "blocked" + + +def test_checked_registry_does_not_reuse_sec_revenue_permission_for_unrelated_fields(): + fundamentals = pd.DataFrame( + [ + { + "ticker": "AAA", + "source": "sec_companyfacts", + "source_ref": "sec:AAA:10-Q", + "operating_margin": 0.2, + "free_cash_flow": 10.0, + "cash": 50.0, + "debt": 20.0, + "shares_outstanding": 100.0, + "sec_filed_date": "2026-05-01", + } + ] + ) + + evidence = derive_cohort_evidence( + ("AAA",), fundamentals=fundamentals, commercial_mode=True + )["AAA"] + + assert evidence["margin_state"] == "blocked" + assert evidence["free_cash_flow_state"] == "blocked" + assert evidence["cash_debt_state"] == "blocked" + assert evidence["shares_state"] == "usable_now" + assert evidence["filing_dates_state"] == "usable_now" + assert "operating_margin" in evidence["margin_evidence"] + assert "free_cash_flow" in evidence["free_cash_flow_evidence"] + + +def test_commercial_cash_and_debt_scope_remain_independent(): + fundamentals = pd.DataFrame( + [ + { + "ticker": "AAA", + "source": "licensed_source", + "source_ref": "licensed:AAA", + "cash": 50.0, + "debt": 20.0, + } + ] + ) + + cash_only = derive_cohort_evidence( + ("AAA",), + fundamentals=fundamentals, + commercial_mode=True, + rights_registry=_rights_registry("cash"), + )["AAA"] + both = derive_cohort_evidence( + ("AAA",), + fundamentals=fundamentals, + commercial_mode=True, + rights_registry=_rights_registry("cash", "debt"), + )["AAA"] + + assert cash_only["cash_debt_state"] == "partial" + assert "debt" in cash_only["cash_debt_evidence"] + assert both["cash_debt_state"] == "usable_now" + + +def test_commercial_earnings_consensus_and_peer_fields_require_own_scope(): + readiness = pd.DataFrame([{"ticker": "AAA", "peer_ready": True}]) + earnings = pd.DataFrame( + [ + { + "ticker": "AAA", + "source": "licensed_source", + "source_ref": "licensed:earnings:AAA", + "next_earnings_date": "2026-08-15", + } + ] + ) + consensus = pd.DataFrame( + [ + { + "ticker": "AAA", + "fiscal_period": "2026-Q2", + "source": "licensed_source", + "source_ref": "licensed:consensus:AAA", + "snapshot_at": "2026-07-16T00:00:00Z", + "revenue_consensus": 100.0, + "eps_consensus": 1.0, + } + ] + ) + peers = pd.DataFrame( + [ + { + "ticker": "AAA", + "peer_ticker": "BBB", + "source": "licensed_source", + "source_ref": "licensed:peer:AAA:BBB", + } + ] + ) + + evidence = derive_cohort_evidence( + ("AAA",), + readiness=readiness, + earnings=earnings, + consensus=consensus, + peers=peers, + as_of="2026-07-17T00:00:00Z", + commercial_mode=True, + rights_registry=_rights_registry("revenue_consensus"), + )["AAA"] + + assert evidence["earnings_dates_state"] == "blocked" + assert evidence["point_in_time_consensus_state"] == "blocked" + assert "eps_consensus" in evidence["point_in_time_consensus_evidence"] + assert evidence["trusted_peers_state"] == "blocked" + assert "trusted_peers" in evidence["trusted_peers_evidence"] + + +def test_commercial_consensus_requires_a_populated_metric_not_only_a_date(): + consensus = pd.DataFrame( + [ + { + "ticker": "AAA", + "fiscal_period": "2026-Q2", + "source": "licensed_source", + "source_ref": "licensed:consensus:AAA", + "snapshot_at": "2026-07-16T00:00:00Z", + } + ] + ) + + evidence = derive_cohort_evidence( + ("AAA",), + consensus=consensus, + as_of="2026-07-17T00:00:00Z", + commercial_mode=True, + rights_registry=_rights_registry("revenue_consensus", "eps_consensus"), + )["AAA"] + + assert evidence["point_in_time_consensus_state"] == "blocked" + assert "value" in evidence["point_in_time_consensus_evidence"].lower() + + +def _price_rows(*, complete_lineage: bool = True, mixed: bool = False) -> pd.DataFrame: + rows = [ + { + "date": "2026-07-15", + "ticker": "AAA", + "close": 100.0, + "adj_close": 100.0, + "source": "licensed_source" if complete_lineage else "", + "source_ref": "licensed:price:AAA:2026-07-15" if complete_lineage else "", + "retrieved_at": "2026-07-16T00:00:00Z" if complete_lineage else "", + } + ] + if mixed: + rows.append( + { + "date": "2026-07-16", + "ticker": "AAA", + "close": 101.0, + "adj_close": 101.0, + "source": "", + "source_ref": "", + "retrieved_at": "", + } + ) + return pd.DataFrame(rows) + + +def test_commercial_price_coverage_requires_rows_and_complete_lineage(): + cohort, readiness = _cohort() + missing_evidence = derive_cohort_evidence( + ("AAA",), commercial_mode=True, rights_registry=_rights_registry("prices") + ) + unlined_evidence = derive_cohort_evidence( + ("AAA",), + prices=_price_rows(complete_lineage=False), + commercial_mode=True, + rights_registry=_rights_registry("prices"), + ) + + missing = build_focused_cohort_coverage( + cohort, readiness, evidence_by_ticker=missing_evidence + ) + unlined = build_focused_cohort_coverage( + cohort, readiness, evidence_by_ticker=unlined_evidence + ) + + assert next(row for row in missing.rows if row.lane == "adjusted_daily_price_history").state == "blocked" + unlined_price = next(row for row in unlined.rows if row.lane == "adjusted_daily_price_history") + assert unlined_price.state == "blocked" + assert "provenance" in unlined_price.evidence.lower() + + +def test_commercial_price_coverage_requires_prices_scope_for_every_history_row(): + cohort, readiness = _cohort() + scoped_evidence = derive_cohort_evidence( + ("AAA",), + prices=_price_rows(), + commercial_mode=True, + rights_registry=_rights_registry("prices"), + ) + missing_scope_evidence = derive_cohort_evidence( + ("AAA",), + prices=_price_rows(), + commercial_mode=True, + rights_registry=_rights_registry("revenue"), + ) + mixed_evidence = derive_cohort_evidence( + ("AAA",), + prices=_price_rows(mixed=True), + commercial_mode=True, + rights_registry=_rights_registry("prices"), + ) + + scoped = build_focused_cohort_coverage(cohort, readiness, evidence_by_ticker=scoped_evidence) + missing_scope = build_focused_cohort_coverage( + cohort, readiness, evidence_by_ticker=missing_scope_evidence + ) + mixed = build_focused_cohort_coverage(cohort, readiness, evidence_by_ticker=mixed_evidence) + + assert next(row for row in scoped.rows if row.lane == "adjusted_daily_price_history").state == "usable_now" + missing_scope_price = next(row for row in missing_scope.rows if row.lane == "adjusted_daily_price_history") + assert missing_scope_price.state == "blocked" + assert "prices" in missing_scope_price.evidence + assert next(row for row in mixed.rows if row.lane == "adjusted_daily_price_history").state == "blocked" + + +def test_research_price_coverage_keeps_saved_readiness_without_commercial_lineage(): + cohort, readiness = _cohort() + evidence = derive_cohort_evidence( + ("AAA",), prices=_price_rows(complete_lineage=False), commercial_mode=False + ) + + coverage = build_focused_cohort_coverage( + cohort, readiness, evidence_by_ticker=evidence + ) + + assert next(row for row in coverage.rows if row.lane == "adjusted_daily_price_history").state == "usable_now" + + +def test_commercial_quarterly_revenue_and_eps_require_independent_field_scope(): + cohort, readiness = _cohort() + actuals = ( + _actual("2024-Q1", 100.0, 1.0, source="licensed_source"), + _actual("2024-Q4", 130.0, 1.3, source="licensed_source"), + _actual("2025-Q1", 150.0, 1.5, source="licensed_source"), + ) + evidence = derive_cohort_evidence( + ("AAA",), + quarterly_actuals=actuals, + commercial_mode=True, + rights_registry=_rights_registry("revenue"), + ) + + coverage = build_focused_cohort_coverage( + cohort, + readiness, + quarterly_packets={"AAA": build_quarterly_trend_packet("AAA", actuals)}, + evidence_by_ticker=evidence, + ) + revenue = next(row for row in coverage.rows if row.lane == "quarterly_revenue") + eps = next(row for row in coverage.rows if row.lane == "quarterly_eps") + + assert revenue.state == "usable_now" + assert eps.state == "blocked" + assert "eps" in eps.evidence + + +def test_commercial_quarterly_metric_fails_closed_when_any_populated_row_is_unapproved(): + cohort, readiness = _cohort() + actuals = ( + _actual("2024-Q1", 100.0, None, source="licensed_source"), + _actual("2025-Q1", 150.0, None, source="unknown_source"), + ) + evidence = derive_cohort_evidence( + ("AAA",), + quarterly_actuals=actuals, + commercial_mode=True, + rights_registry=_rights_registry("revenue"), + ) + + coverage = build_focused_cohort_coverage( + cohort, + readiness, + quarterly_packets={"AAA": build_quarterly_trend_packet("AAA", actuals)}, + evidence_by_ticker=evidence, + ) + revenue = next(row for row in coverage.rows if row.lane == "quarterly_revenue") + + assert revenue.state == "blocked" + assert "commercial rights" in revenue.evidence.lower() + + +def test_research_quarterly_coverage_keeps_packet_state_without_commercial_review(): + cohort, readiness = _cohort() + actuals = ( + _actual("2024-Q1", 100.0, 1.0), + _actual("2024-Q4", 130.0, 1.3), + _actual("2025-Q1", 150.0, 1.5), + ) + + coverage = build_focused_cohort_coverage( + cohort, + readiness, + quarterly_packets={"AAA": build_quarterly_trend_packet("AAA", actuals)}, + evidence_by_ticker=derive_cohort_evidence(("AAA",), quarterly_actuals=actuals), + ) + + states = {row.lane: row.state for row in coverage.rows} + assert states["quarterly_revenue"] == "usable_now" + assert states["quarterly_eps"] == "usable_now" + + +def test_commercial_quarterly_eps_scope_cannot_override_unverified_split_basis(): + cohort, readiness = _cohort() + actuals = ( + _actual( + "2024-Q1", + 100.0, + 1.0, + source="licensed_source", + split_adjustment_basis="companyfacts_split_basis_unverified", + ), + _actual( + "2024-Q4", + 130.0, + 1.3, + source="licensed_source", + split_adjustment_basis="companyfacts_split_basis_unverified", + ), + _actual( + "2025-Q1", + 150.0, + 1.5, + source="licensed_source", + split_adjustment_basis="companyfacts_split_basis_unverified", + ), + ) + evidence = derive_cohort_evidence( + ("AAA",), + quarterly_actuals=actuals, + commercial_mode=True, + rights_registry=_rights_registry("revenue", "eps"), + ) + + coverage = build_focused_cohort_coverage( + cohort, + readiness, + quarterly_packets={"AAA": build_quarterly_trend_packet("AAA", actuals)}, + evidence_by_ticker=evidence, + ) + states = {row.lane: row.state for row in coverage.rows} + eps = next(row for row in coverage.rows if row.lane == "quarterly_eps") + + assert states["quarterly_revenue"] == "usable_now" + assert states["quarterly_eps"] == "blocked" + assert "split basis" in eps.boundary.lower() + + +def test_candidate_peer_rows_remain_candidate_context_only(): + candidates = pd.DataFrame( + [ + {"ticker": "AAA", "peer_ticker": "BBB", "candidate_state": "candidate_context_only"}, + {"ticker": "AAA", "peer_ticker": "CCC", "candidate_state": "candidate_context_only"}, + ] + ) + evidence = derive_cohort_evidence(("AAA",), peer_candidates=candidates) + + assert evidence["AAA"]["trusted_peers_state"] == "candidate_context_only" + assert "not trusted" in evidence["AAA"]["trusted_peers_evidence"].lower() diff --git a/tests/test_focused_research_cohort.py b/tests/test_focused_research_cohort.py new file mode 100644 index 00000000..018661a4 --- /dev/null +++ b/tests/test_focused_research_cohort.py @@ -0,0 +1,94 @@ +import pandas as pd + +from src.focused_research_cohort import build_focused_cohort, focused_cohort_frame + + +def _readiness(*rows): + return pd.DataFrame(rows) + + +def _universe(*rows): + return pd.DataFrame(rows) + + +def test_focused_cohort_is_deterministic_and_prioritizes_reviewability_without_ranking_language(): + readiness = _readiness( + {"ticker": "CCC", "name": "C Co", "price_ready": True, "fundamentals_ready": False, "dcf_ready": False, "peer_ready": False, "in_active_universe": False}, + {"ticker": "AAA", "name": "A Co", "price_ready": True, "fundamentals_ready": True, "dcf_ready": True, "peer_ready": True, "in_active_universe": True}, + {"ticker": "BBB", "name": "B Co", "price_ready": True, "fundamentals_ready": True, "dcf_ready": True, "peer_ready": False, "in_active_universe": False}, + ) + universe = _universe( + {"ticker": "BBB", "asset_type": "company", "is_active_listing": True, "sector": "Industrials", "industry": "Tools"}, + {"ticker": "CCC", "asset_type": "company", "is_active_listing": True, "sector": "Health Care", "industry": "Devices"}, + {"ticker": "AAA", "asset_type": "company", "is_active_listing": True, "sector": "Technology", "industry": "Software"}, + ) + + first = build_focused_cohort(readiness, universe, target_size=3, minimum_size=2, profile_freshness="current") + second = build_focused_cohort(readiness.sample(frac=1, random_state=3), universe.sample(frac=1, random_state=4), target_size=3, minimum_size=2, profile_freshness="current") + + assert [member.ticker for member in first.members] == ["AAA", "BBB", "CCC"] + assert first == second + assert first.status == "ready" + rendered = focused_cohort_frame(first).to_string(index=False).lower() + for prohibited in ("buy", "sell", "winner", "recommendation score", "rank"): + assert prohibited not in rendered + + +def test_focused_cohort_excludes_non_companies_inactive_rows_and_non_price_ready_rows(): + readiness = _readiness( + {"ticker": "KEEP", "name": "Keep Co", "price_ready": True}, + {"ticker": "ETF", "name": "Fund", "price_ready": True}, + {"ticker": "OLD", "name": "Old Co", "price_ready": True}, + {"ticker": "MISS", "name": "Missing Price", "price_ready": False}, + ) + universe = _universe( + {"ticker": "KEEP", "asset_type": "company", "is_active_listing": True}, + {"ticker": "ETF", "asset_type": "etf", "is_active_listing": True}, + {"ticker": "OLD", "asset_type": "company", "is_active_listing": False}, + {"ticker": "MISS", "asset_type": "company", "is_active_listing": True}, + ) + + cohort = build_focused_cohort(readiness, universe, target_size=25, minimum_size=25) + + assert [member.ticker for member in cohort.members] == ["KEEP"] + assert cohort.status == "awaiting_reviewed_source" + assert cohort.eligible_count == 1 + assert cohort.requested_size == 25 + + +def test_focused_cohort_deduplicates_tickers_and_preserves_source_backed_context(): + readiness = _readiness( + {"ticker": "DUP", "name": "Duplicate Co", "price_ready": True, "fundamentals_ready": False, "dcf_ready": False, "blocked_features": "fundamentals, dcf", "updated_at": "2026-07-01"}, + {"ticker": "DUP", "name": "Duplicate Co", "price_ready": True, "fundamentals_ready": True, "dcf_ready": True, "blocked_features": "peers", "updated_at": "2026-07-02"}, + ) + universe = _universe( + {"ticker": "DUP", "asset_type": "company", "is_active_listing": True, "sector": "Technology", "industry": "Hardware"}, + ) + + cohort = build_focused_cohort( + readiness, + universe, + target_size=25, + minimum_size=25, + profile_freshness="stale", + last_review_dates={"DUP": "2026-06-30"}, + ) + + assert len(cohort.members) == 1 + member = cohort.members[0] + assert member.ticker == "DUP" + assert member.sector == "Technology" + assert member.industry == "Hardware" + assert member.usable_lanes == ("price", "fundamentals", "dcf") + assert member.blocked_lanes == ("peers",) + assert member.freshness_state == "stale" + assert member.last_review_date == "2026-06-30" + + +def test_focused_cohort_missing_inputs_fail_closed(): + cohort = build_focused_cohort(pd.DataFrame(), pd.DataFrame(), target_size=25, minimum_size=25) + + assert cohort.status == "unavailable" + assert cohort.members == () + assert cohort.eligible_count == 0 + assert cohort.message == "Focused cohort inputs are unavailable." diff --git a/tests/test_forward_view.py b/tests/test_forward_view.py new file mode 100644 index 00000000..5b93c366 --- /dev/null +++ b/tests/test_forward_view.py @@ -0,0 +1,233 @@ +import json +from types import MappingProxyType, SimpleNamespace + +import pytest + +from src.forward_view import build_forward_view, forward_view_cards, forward_view_rows +from src.quarterly_business_trend import build_quarterly_trend_packet +from src.earnings_nowcast_contract import QuarterlyActual + + +def _actual(period: str, revenue: float, eps: float) -> QuarterlyActual: + return QuarterlyActual( + ticker="AAA", + fiscal_period=period, + period_end_date=f"{period[:4]}-{int(period[-1]) * 3:02d}-28", + reported_at=f"{period[:4]}-12-31T00:00:00Z", + revenue_actual=revenue, + eps_actual=eps, + source="reviewed_fixture", + source_ref=f"fixture:{period}", + retrieved_at="2026-07-17T00:00:00Z", + revenue_currency="USD", + revenue_unit_scale=1.0, + revenue_basis="gaap", + eps_currency="USD", + eps_basis="gaap_diluted", + eps_share_basis="diluted", + eps_operations_basis="continuing", + split_adjustment_basis="as_reported", + ) + + +def _ready_trend(): + return build_quarterly_trend_packet( + "AAA", + ( + _actual("2024-Q1", 100, 1.0), + _actual("2024-Q4", 130, 1.3), + _actual("2025-Q1", 150, 1.5), + ), + ) + + +def _report(): + return { + "ticker": "AAA", + "generated_at": "2026-07-17T00:00:00Z", + "valuation_readiness": {"dcf_ready": True, "peer_ready": True}, + "valuation_snapshot": { + "status": "calculated", + "source_metadata": [ + { + "provider": "sec_companyfacts", + "freshness": "current", + "retrieved_at": "2026-07-01T00:00:00Z", + "official": True, + "notes": ["SEC filing source"], + } + ], + "scenarios": [ + {"name": "bear", "dcf_result": {"status": "calculated", "fair_value_per_share": 70.0}, "assumptions": {"wacc": 0.11}}, + {"name": "base", "dcf_result": {"status": "calculated", "fair_value_per_share": 90.0}, "assumptions": {"wacc": 0.09}}, + {"name": "bull", "dcf_result": {"status": "calculated", "fair_value_per_share": 115.0}, "assumptions": {"wacc": 0.08}}, + ], + }, + } + + +def _entry(summary: str): + return SimpleNamespace(summary=summary, source="reviewed_journal", source_ref=f"journal:{summary}") + + +def test_forward_view_composes_verified_trend_scenarios_peers_and_reviewed_thesis(): + journal = SimpleNamespace( + catalysts=(_entry("Product cycle"),), + risks=(_entry("Customer concentration"),), + invalidation_conditions=(_entry("Revenue contracts year over year"),), + ) + peer_map = SimpleNamespace(status="ready", trusted_count=2, candidate_count=1, reviewable_count=1, boundary="Context only") + + packet = build_forward_view( + _report(), + _ready_trend(), + journal_state=journal, + peer_map=peer_map, + nowcast_packet=None, + freshness_state="current", + ) + + assert packet.status == "partial" + assert packet.historical_trend.state == "usable_now" + assert packet.valuation_scenarios.state == "usable_now" + assert [row["name"] for row in packet.valuation_scenarios.details] == ["bear", "base", "bull"] + assert packet.peer_context.state == "usable_now" + assert packet.thesis_context.state == "usable_now" + assert packet.earnings_outlook.state == "blocked" + assert "earnings_outlook" in packet.withheld_fields + assert packet.next_research_task == "Add exact-period point-in-time consensus before reviewing an Earnings Outlook range." + + +def test_forward_view_fails_closed_when_provenance_or_inputs_are_missing(): + report = _report() + report["valuation_snapshot"]["source_metadata"] = [] + + packet = build_forward_view(report, build_quarterly_trend_packet("AAA", []), freshness_state="stale") + + assert packet.historical_trend.state == "blocked" + assert packet.valuation_scenarios.state == "blocked" + assert packet.peer_context.state == "blocked" + assert packet.thesis_context.state == "blocked" + assert packet.source_cutoff == "2026-07-17T00:00:00Z" + assert "stale" in packet.boundary.lower() + rendered = str(forward_view_cards(packet)).lower() + assert "fair value" not in rendered + assert "probability" not in rendered + + +def test_candidate_peer_context_never_becomes_trusted_or_changes_scenarios(): + peer_map = SimpleNamespace(status="candidate_context_only", trusted_count=0, candidate_count=3, reviewable_count=0, boundary="Candidates only") + packet = build_forward_view(_report(), _ready_trend(), peer_map=peer_map) + + assert packet.peer_context.state == "candidate_context_only" + assert "not trusted" in packet.peer_context.boundary.lower() + assert len(packet.valuation_scenarios.details) == 3 + + +def test_nowcast_probability_stays_withheld_until_calibrated(): + uncalibrated = { + "readiness": {"state": "baseline_ready"}, + "forecast": {"revenue_low": 100, "revenue_high": 110, "eps_low": 1.0, "eps_high": 1.2}, + "calibration": {"probability_available": False}, + } + packet = build_forward_view( + _report(), + _ready_trend(), + nowcast_packet=uncalibrated, + freshness_state="current", + ) + + assert packet.earnings_outlook.state == "usable_now" + assert "probability withheld" in packet.earnings_outlook.boundary.lower() + assert all("probability" not in str(detail).lower() for detail in packet.earnings_outlook.details) + + +def test_nowcast_calibration_uses_production_probability_available_field(): + calibrated = { + "readiness": {"state": "calibrated"}, + "forecast": {"revenue_low": 100, "revenue_high": 110, "eps_low": 1.0, "eps_high": 1.2}, + "calibration": {"probability_available": True}, + } + + packet = build_forward_view( + _report(), + _ready_trend(), + nowcast_packet=calibrated, + freshness_state="current", + ) + + assert "calibration evidence passed" in packet.earnings_outlook.boundary.lower() + + +@pytest.mark.parametrize("freshness", ["stale", "unknown", "mixed", "missing"]) +def test_non_current_freshness_downgrades_every_otherwise_usable_section(freshness): + journal = SimpleNamespace( + catalysts=(_entry("Product cycle"),), + risks=(_entry("Customer concentration"),), + invalidation_conditions=(), + ) + peer_map = SimpleNamespace(trusted_count=1, candidate_count=0, reviewable_count=1, boundary="Context only") + nowcast = { + "readiness": {"state": "baseline_ready"}, + "forecast": {"revenue_low": 100, "revenue_high": 110}, + "calibration": {"probability_available": False}, + } + + packet = build_forward_view( + _report(), + _ready_trend(), + journal_state=journal, + peer_map=peer_map, + nowcast_packet=nowcast, + freshness_state=freshness, + ) + + sections = ( + packet.historical_trend, + packet.valuation_scenarios, + packet.peer_context, + packet.thesis_context, + packet.earnings_outlook, + ) + assert all(section.state == "partial" for section in sections) + assert all("freshness" in section.boundary.lower() for section in sections) + + +def test_forward_view_advanced_details_include_immutable_provenance(): + packet = build_forward_view(_report(), _ready_trend(), freshness_state="current") + + detail = packet.valuation_scenarios.details[0] + assert isinstance(detail, MappingProxyType) + assert detail["provenance"][0]["provider"] == "sec_companyfacts" + with pytest.raises(TypeError): + detail["name"] = "changed" + + +def test_forward_view_rows_keep_technical_details_separate_and_research_only(): + packet = build_forward_view(_report(), _ready_trend()) + rows = forward_view_rows(packet) + cards = forward_view_cards(packet) + + assert [row["Section"] for row in rows] == [ + "Historical Trend", + "Valuation Scenarios", + "Trusted Peer Context", + "Reviewer Thesis Context", + "Earnings Outlook", + ] + assert cards[-1]["kicker"] == "FORWARD-VIEW LANE UNBLOCK" + assert all(card["kicker"] != "NEXT RESEARCH TASK" for card in cards) + rendered = str(cards).lower() + for prohibited in ("buy now", "sell now", "position size", "will rise", "recommendation score"): + assert prohibited not in rendered + + +def test_forward_view_rows_serialize_immutable_evidence_for_display_tables(): + packet = build_forward_view(_report(), _ready_trend(), freshness_state="current") + + rows = forward_view_rows(packet) + valuation_row = next(row for row in rows if row["Section"] == "Valuation Scenarios") + + assert isinstance(valuation_row["Evidence"], str) + evidence = json.loads(valuation_row["Evidence"]) + assert evidence[0]["provenance"][0]["provider"] == "sec_companyfacts" diff --git a/tests/test_github_actions_workflow.py b/tests/test_github_actions_workflow.py new file mode 100644 index 00000000..d2e86385 --- /dev/null +++ b/tests/test_github_actions_workflow.py @@ -0,0 +1,48 @@ +from pathlib import Path + + +WORKFLOW_PATH = Path(".github/workflows/commercial-research-beta.yml") + + +def test_commercial_research_beta_workflow_is_minimal_pr_only_gate(): + assert WORKFLOW_PATH.exists(), "commercial research beta PR workflow is missing" + + workflow = WORKFLOW_PATH.read_text(encoding="utf-8") + + assert "on:\n pull_request:\n branches: [main]" in workflow + assert "permissions:\n contents: read" in workflow + assert "runs-on: ubuntu-latest" in workflow + assert "python-version: \"3.12\"" in workflow + assert "python3 -m pip install -e . pytest" in workflow + assert "PYTHONDONTWRITEBYTECODE: \"1\"" in workflow + assert "uses: actions/checkout@v6" in workflow + assert "uses: actions/setup-python@v6" in workflow + assert "actions/checkout@v4" not in workflow + assert "actions/setup-python@v5" not in workflow + assert "fetch-depth: 0" in workflow + assert "ref: ${{ github.event.pull_request.head.sha }}" in workflow + assert "PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}" in workflow + assert "PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}" in workflow + + required_commands = ( + "python3 -m pytest tests -q", + "make dashboard-smoke", + "make research-dashboard-render-smoke", + "make public-wording-check", + 'make pr-range-hygiene-check BASE_SHA="$PR_BASE_SHA" HEAD_SHA="$PR_HEAD_SHA"', + 'git diff --check "$PR_BASE_SHA...$PR_HEAD_SHA"', + ) + positions = [workflow.index(command) for command in required_commands] + assert positions == sorted(positions) + + prohibited_fragments = ( + "schedule:", + "workflow_dispatch:", + "push:", + "secrets.", + "upload-artifact", + "deploy", + "make readiness", + "provider", + ) + assert not [fragment for fragment in prohibited_fragments if fragment in workflow] diff --git a/tests/test_historical_valuation_regime.py b/tests/test_historical_valuation_regime.py new file mode 100644 index 00000000..9a9cf2e3 --- /dev/null +++ b/tests/test_historical_valuation_regime.py @@ -0,0 +1,178 @@ +from dataclasses import replace + +import pytest + +from src.historical_valuation_regime import ValuationObservation, build_valuation_regime +from src.commercial_source_rights import SourceRights + + +def _registry(*supported_fields: str): + return { + "reviewed_local_evidence": SourceRights( + "reviewed_local_evidence", "Reviewed", "evidence", "approved", + "derived_only", "reviewed", "required", "n/a", "none", "reviewed", + supported_fields, 1, + ) + } + + +def _observation(index: int, **overrides) -> ValuationObservation: + month = index + 1 + values = { + "ticker": "NVDA", + "metric": "price_to_fcf_per_share", + "numerator": 20.0 + index, + "denominator": 1.0, + "numerator_as_of": f"2025-{month:02d}-28T21:00:00Z", + "denominator_period_end": "2024-12-31", + "denominator_available_at": "2025-01-20T21:00:00Z", + "definition_id": "trailing_fcf_per_share_v1", + "source": "reviewed_local_evidence", + "source_ref": f"evidence://valuation/{index}", + "retrieved_at": f"2025-{month:02d}-28T21:01:00Z", + } + values.update(overrides) + return ValuationObservation(**values) + + +def test_regime_uses_only_aligned_point_in_time_observations(): + packet = build_valuation_regime( + tuple(_observation(index) for index in range(8)), + ticker="NVDA", + metric="price_to_fcf_per_share", + as_of="2025-09-01T00:00:00Z", + ) + + assert packet.state == "ready" + assert packet.observation_count == 8 + assert packet.latest_multiple == 27.0 + assert packet.percentile_rank == 100.0 + assert packet.segment_count == 1 + assert "descriptive" in packet.boundary + + +def test_regime_rejects_current_denominator_backfilled_over_older_prices(): + invalid = _observation( + 0, + numerator_as_of="2024-12-31T21:00:00Z", + denominator_available_at="2025-01-20T21:00:00Z", + ) + packet = build_valuation_regime( + (invalid,), + ticker="NVDA", + metric="price_to_fcf_per_share", + as_of="2025-09-01T00:00:00Z", + ) + + assert packet.state == "insufficient_history" + assert packet.observation_count == 0 + assert packet.rejected_count == 1 + assert "not public at the price timestamp" in packet.rejected_reasons[0] + + +def test_definition_change_splits_history_and_does_not_mix_regimes(): + rows = tuple(_observation(index) for index in range(4)) + tuple( + replace( + _observation(index + 4), + definition_id="forward_fcf_per_share_v2", + denominator_period_end="2025-03-31", + denominator_available_at="2025-04-20T21:00:00Z", + ) + for index in range(4) + ) + packet = build_valuation_regime( + rows, + ticker="NVDA", + metric="price_to_fcf_per_share", + as_of="2025-09-01T00:00:00Z", + ) + + assert packet.segment_count == 2 + assert packet.state == "insufficient_history" + assert packet.observation_count == 4 + assert packet.definition_id == "forward_fcf_per_share_v2" + + +def test_commercial_valuation_requires_exact_source_lane_scope(): + rows = tuple(_observation(index) for index in range(8)) + + blocked = build_valuation_regime( + rows, + ticker="NVDA", + metric="price_to_fcf_per_share", + as_of="2025-09-01T00:00:00Z", + commercial_mode=True, + rights_registry=_registry("revenue"), + ) + ready = build_valuation_regime( + rows, + ticker="NVDA", + metric="price_to_fcf_per_share", + as_of="2025-09-01T00:00:00Z", + commercial_mode=True, + rights_registry=_registry("valuation_history"), + ) + + assert blocked.state == "commercial_evidence_blocked" + assert blocked.observation_count == 0 + assert blocked.commercial_blocker_count == 8 + assert "valuation_history" in blocked.commercial_blockers[0] + assert ready.state == "ready" + assert ready.observation_count == 8 + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("numerator", float("nan")), + ("numerator", float("inf")), + ("numerator", float("-inf")), + ("denominator", float("nan")), + ("denominator", float("inf")), + ("denominator", float("-inf")), + ], +) +def test_regime_rejects_non_finite_multiple_inputs(field, value): + packet = build_valuation_regime( + (replace(_observation(0), **{field: value}),), + ticker="NVDA", + metric="price_to_fcf_per_share", + as_of="2025-09-01T00:00:00Z", + minimum_observations=1, + ) + + assert packet.state == "insufficient_history" + assert packet.observation_count == 0 + assert packet.rejected_count == 1 + assert packet.rejected_reasons == ("numerator and denominator must be finite",) + + +@pytest.mark.parametrize("period_end", ["", "2025-02-30", "2025/02/28"]) +def test_regime_rejects_invalid_denominator_period_end(period_end): + packet = build_valuation_regime( + (_observation(0, denominator_period_end=period_end),), + ticker="NVDA", + metric="price_to_fcf_per_share", + as_of="2025-09-01T00:00:00Z", + minimum_observations=1, + ) + + assert packet.state == "insufficient_history" + assert packet.observation_count == 0 + assert packet.rejected_count == 1 + assert packet.rejected_reasons == ("denominator_period_end must use YYYY-MM-DD",) + + +def test_regime_rejects_evidence_retrieved_after_review_cutoff(): + packet = build_valuation_regime( + (_observation(0, retrieved_at="2025-09-02T00:00:00Z"),), + ticker="NVDA", + metric="price_to_fcf_per_share", + as_of="2025-09-01T00:00:00Z", + minimum_observations=1, + ) + + assert packet.state == "insufficient_history" + assert packet.observation_count == 0 + assert packet.rejected_count == 1 + assert packet.rejected_reasons == ("observation contains post-cutoff evidence",) diff --git a/tests/test_hosted_access_control.py b/tests/test_hosted_access_control.py new file mode 100644 index 00000000..3e97359d --- /dev/null +++ b/tests/test_hosted_access_control.py @@ -0,0 +1,452 @@ +from dataclasses import FrozenInstanceError + +import pytest + +from src.hosted_access_control import ( + AuditObligation, + PrincipalContext, + WorkspaceAccessDecision, + WorkspaceAccessRequest, + WorkspaceAction, + WorkspaceMembership, + WorkspaceResource, + WorkspaceRole, + evaluate_workspace_access, +) + + +def _valid_inputs( + *, + principal_id: object = "principal-1", + membership_principal_id: object = "principal-1", + membership_workspace_id: object = "workspace-1", + request_workspace_id: object = "workspace-1", + request_id: object = "request-1", + authenticated: object = True, + active: object = True, + role: object = WorkspaceRole.EDITOR, + action: object = WorkspaceAction.READ, + resource: object = WorkspaceResource.RESEARCH_RECORD, +) -> tuple[PrincipalContext, WorkspaceMembership, WorkspaceAccessRequest]: + return ( + PrincipalContext( + principal_id=principal_id, + authenticated=authenticated, + ), + WorkspaceMembership( + principal_id=membership_principal_id, + workspace_id=membership_workspace_id, + role=role, + active=active, + ), + WorkspaceAccessRequest( + request_id=request_id, + workspace_id=request_workspace_id, + action=action, + resource=resource, + ), + ) + + +@pytest.mark.parametrize( + ("principal", "membership", "access_request"), + [ + (object(),) + _valid_inputs()[1:], + (_valid_inputs()[0], object(), _valid_inputs()[2]), + _valid_inputs()[:2] + (object(),), + ], +) +def test_malformed_boundary_objects_fail_closed( + principal, membership, access_request +): + decision = evaluate_workspace_access(principal, membership, access_request) + + assert decision.allowed is False + assert decision.reason_code == "invalid_request" + + +@pytest.mark.parametrize( + ("principal", "membership", "access_request"), + [ + (object.__new__(PrincipalContext),) + _valid_inputs()[1:], + ( + _valid_inputs()[0], + object.__new__(WorkspaceMembership), + _valid_inputs()[2], + ), + _valid_inputs()[:2] + (object.__new__(WorkspaceAccessRequest),), + ], +) +def test_incomplete_exact_type_objects_fail_closed( + principal, membership, access_request +): + decision = evaluate_workspace_access(principal, membership, access_request) + + assert decision.allowed is False + assert decision.reason_code == "invalid_request" + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("principal_id", None), + ("principal_id", ""), + ("principal_id", " leading"), + ("principal_id", "trailing "), + ("principal_id", "line\nbreak"), + ("principal_id", "c1\u0085control"), + ("principal_id", "line\u2028separator"), + ("principal_id", "paragraph\u2029separator"), + ("principal_id", "\ud800"), + ("principal_id", "x" * 129), + ("membership_principal_id", 7), + ("membership_workspace_id", "\x00"), + ("request_workspace_id", "\tworkspace"), + ("request_id", False), + ("authenticated", 1), + ("active", "true"), + ("role", "editor"), + ("action", "read"), + ("resource", "research_record"), + ], +) +def test_malformed_fields_fail_closed(field, value): + decision = evaluate_workspace_access(*_valid_inputs(**{field: value})) + + assert decision == WorkspaceAccessDecision( + allowed=False, + reason_code="invalid_request", + audit=AuditObligation( + event_type="workspace_access_decision", + outcome="denied", + reason_code="invalid_request", + required_fields=( + "request_id", + "principal_id", + "workspace_id", + "resource", + "action", + "outcome", + "reason_code", + "occurred_at", + ), + ), + ) + + +def test_hostile_string_subclass_fails_closed_without_calling_strip(): + class HostileIdentifier(str): + def strip(self): + raise AssertionError("hostile strip must not be invoked") + + decision = evaluate_workspace_access( + *_valid_inputs(principal_id=HostileIdentifier("principal-1")) + ) + + assert decision.allowed is False + assert decision.reason_code == "invalid_request" + + +def test_frozen_inputs_and_decisions_cannot_be_mutated(): + principal, membership, request = _valid_inputs() + decision = evaluate_workspace_access(principal, membership, request) + + with pytest.raises(FrozenInstanceError): + principal.principal_id = "other" + with pytest.raises(FrozenInstanceError): + membership.role = WorkspaceRole.OWNER + with pytest.raises(FrozenInstanceError): + request.workspace_id = "other" + with pytest.raises(FrozenInstanceError): + decision.allowed = True + + +_ALLOWED_CASES = ( + (WorkspaceRole.VIEWER, WorkspaceResource.RESEARCH_RECORD, WorkspaceAction.READ), + (WorkspaceRole.EDITOR, WorkspaceResource.RESEARCH_RECORD, WorkspaceAction.READ), + (WorkspaceRole.EDITOR, WorkspaceResource.RESEARCH_RECORD, WorkspaceAction.APPEND), + (WorkspaceRole.OWNER, WorkspaceResource.RESEARCH_RECORD, WorkspaceAction.READ), + (WorkspaceRole.OWNER, WorkspaceResource.RESEARCH_RECORD, WorkspaceAction.APPEND), + (WorkspaceRole.OWNER, WorkspaceResource.RESEARCH_RECORD, WorkspaceAction.EXPORT), + (WorkspaceRole.VIEWER, WorkspaceResource.SAVED_WORKSPACE_STATE, WorkspaceAction.READ), + (WorkspaceRole.EDITOR, WorkspaceResource.SAVED_WORKSPACE_STATE, WorkspaceAction.READ), + (WorkspaceRole.EDITOR, WorkspaceResource.SAVED_WORKSPACE_STATE, WorkspaceAction.APPEND), + (WorkspaceRole.EDITOR, WorkspaceResource.SAVED_WORKSPACE_STATE, WorkspaceAction.UPDATE), + (WorkspaceRole.OWNER, WorkspaceResource.SAVED_WORKSPACE_STATE, WorkspaceAction.READ), + (WorkspaceRole.OWNER, WorkspaceResource.SAVED_WORKSPACE_STATE, WorkspaceAction.APPEND), + (WorkspaceRole.OWNER, WorkspaceResource.SAVED_WORKSPACE_STATE, WorkspaceAction.UPDATE), + (WorkspaceRole.OWNER, WorkspaceResource.SAVED_WORKSPACE_STATE, WorkspaceAction.DELETE), + (WorkspaceRole.OWNER, WorkspaceResource.SAVED_WORKSPACE_STATE, WorkspaceAction.EXPORT), + (WorkspaceRole.OWNER, WorkspaceResource.WORKSPACE_MEMBERSHIP, WorkspaceAction.READ), + (WorkspaceRole.OWNER, WorkspaceResource.WORKSPACE_MEMBERSHIP, WorkspaceAction.MANAGE), + (WorkspaceRole.OWNER, WorkspaceResource.WORKSPACE_AUDIT, WorkspaceAction.READ), + (WorkspaceRole.OWNER, WorkspaceResource.WORKSPACE_AUDIT, WorkspaceAction.EXPORT), +) + + +@pytest.mark.parametrize(("role", "resource", "action"), _ALLOWED_CASES) +def test_exact_policy_matrix_allows_only_declared_entries(role, resource, action): + decision = evaluate_workspace_access( + *_valid_inputs(role=role, resource=resource, action=action) + ) + + assert decision.allowed is True + assert decision.reason_code == "allowed" + assert decision.audit.outcome == "allowed" + assert decision.audit.reason_code == "allowed" + + +_ALLOWED_SET = set(_ALLOWED_CASES) +_DENIED_CASES = tuple( + (role, resource, action) + for role in WorkspaceRole + for resource in WorkspaceResource + for action in WorkspaceAction + if (role, resource, action) not in _ALLOWED_SET +) + + +@pytest.mark.parametrize(("role", "resource", "action"), _DENIED_CASES) +def test_every_absent_policy_entry_is_denied(role, resource, action): + decision = evaluate_workspace_access( + *_valid_inputs(role=role, resource=resource, action=action) + ) + + expected = ( + "append_only_mutation_denied" + if resource is WorkspaceResource.RESEARCH_RECORD + and action in {WorkspaceAction.UPDATE, WorkspaceAction.DELETE} + else "role_action_denied" + ) + assert decision.allowed is False + assert decision.reason_code == expected + assert decision.audit.outcome == "denied" + assert decision.audit.reason_code == expected + + +@pytest.mark.parametrize("role", tuple(WorkspaceRole)) +@pytest.mark.parametrize( + "action", + (WorkspaceAction.UPDATE, WorkspaceAction.DELETE), +) +def test_no_role_can_mutate_append_only_research_records(role, action): + decision = evaluate_workspace_access( + *_valid_inputs( + role=role, + resource=WorkspaceResource.RESEARCH_RECORD, + action=action, + ) + ) + + assert decision.allowed is False + assert decision.reason_code == "append_only_mutation_denied" + + +@pytest.mark.parametrize( + ("changes", "reason_code"), + [ + ({"authenticated": False}, "authentication_required"), + ({"membership_principal_id": "principal-2"}, "principal_mismatch"), + ({"active": False}, "membership_inactive"), + ({"request_workspace_id": "workspace-2"}, "workspace_mismatch"), + ], +) +def test_isolation_denials_follow_declared_order(changes, reason_code): + decision = evaluate_workspace_access(*_valid_inputs(**changes)) + + assert decision.allowed is False + assert decision.reason_code == reason_code + + +def test_earlier_denial_cannot_be_overridden_by_later_allow_rule(): + decision = evaluate_workspace_access( + *_valid_inputs( + authenticated=False, + membership_principal_id="principal-2", + active=False, + request_workspace_id="workspace-2", + role=WorkspaceRole.OWNER, + resource=WorkspaceResource.SAVED_WORKSPACE_STATE, + action=WorkspaceAction.DELETE, + ) + ) + + assert decision.reason_code == "authentication_required" + + +def test_principal_mismatch_precedes_inactive_membership_and_workspace_mismatch(): + decision = evaluate_workspace_access( + *_valid_inputs( + membership_principal_id="principal-2", + active=False, + request_workspace_id="workspace-2", + ) + ) + + assert decision.reason_code == "principal_mismatch" + + +def test_membership_inactive_precedes_workspace_mismatch(): + decision = evaluate_workspace_access( + *_valid_inputs( + active=False, + request_workspace_id="workspace-2", + ) + ) + + assert decision.reason_code == "membership_inactive" + + +def test_workspace_mismatch_precedes_append_only_policy_denial(): + decision = evaluate_workspace_access( + *_valid_inputs( + request_workspace_id="workspace-2", + role=WorkspaceRole.OWNER, + resource=WorkspaceResource.RESEARCH_RECORD, + action=WorkspaceAction.DELETE, + ) + ) + + assert decision.reason_code == "workspace_mismatch" + + +def test_valid_unicode_scalar_identifiers_are_accepted_deterministically(): + inputs = _valid_inputs( + principal_id="研究员-😀", + membership_principal_id="研究员-😀", + membership_workspace_id="工作区-α", + request_workspace_id="工作区-α", + request_id="请求-1", + ) + + first = evaluate_workspace_access(*inputs) + second = evaluate_workspace_access(*inputs) + + assert first == second + assert first.allowed is True + + +@pytest.mark.parametrize( + "changes", + [ + {}, + {"authenticated": False}, + {"membership_principal_id": "principal-2"}, + {"active": False}, + {"request_workspace_id": "workspace-2"}, + {"action": WorkspaceAction.MANAGE}, + { + "resource": WorkspaceResource.RESEARCH_RECORD, + "action": WorkspaceAction.DELETE, + "role": WorkspaceRole.OWNER, + }, + ], +) +def test_every_decision_has_the_exact_privacy_safe_audit_obligation(changes): + decision = evaluate_workspace_access(*_valid_inputs(**changes)) + + assert decision.audit.event_type == "workspace_access_decision" + assert decision.audit.outcome == ("allowed" if decision.allowed else "denied") + assert decision.audit.reason_code == decision.reason_code + assert decision.audit.required_fields == ( + "request_id", + "principal_id", + "workspace_id", + "resource", + "action", + "outcome", + "reason_code", + "occurred_at", + ) + assert "principal-1" not in decision.reason_code + assert "workspace-1" not in decision.reason_code + + +def test_public_contract_contains_no_secret_or_research_content_fields(): + public_fields = { + *PrincipalContext.__dataclass_fields__, + *WorkspaceMembership.__dataclass_fields__, + *WorkspaceAccessRequest.__dataclass_fields__, + *AuditObligation.__dataclass_fields__, + *WorkspaceAccessDecision.__dataclass_fields__, + } + forbidden = { + "credential", + "token", + "cookie", + "api_key", + "session_secret", + "thesis", + "evidence", + "catalyst", + "research_outcome", + "forecast", + "probability", + "recommendation", + "position", + "transaction", + } + + assert public_fields.isdisjoint(forbidden) + + +def test_evaluation_does_not_mutate_inputs_or_write_files(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + principal, membership, request = _valid_inputs() + before = ( + (principal.principal_id, principal.authenticated), + ( + membership.principal_id, + membership.workspace_id, + membership.role, + membership.active, + ), + ( + request.request_id, + request.workspace_id, + request.action, + request.resource, + ), + ) + + evaluate_workspace_access(principal, membership, request) + + after = ( + (principal.principal_id, principal.authenticated), + ( + membership.principal_id, + membership.workspace_id, + membership.role, + membership.active, + ), + ( + request.request_id, + request.workspace_id, + request.action, + request.resource, + ), + ) + assert after == before + assert list(tmp_path.iterdir()) == [] + + +def test_module_uses_only_the_approved_standard_library_imports(): + import ast + import inspect + + import src.hosted_access_control as module + + tree = ast.parse(inspect.getsource(module)) + imported_roots = { + alias.name.split(".", 1)[0] + for node in ast.walk(tree) + if isinstance(node, ast.Import) + for alias in node.names + } + imported_roots.update( + node.module.split(".", 1)[0] + for node in ast.walk(tree) + if isinstance(node, ast.ImportFrom) and node.module != "__future__" + ) + + assert imported_roots == {"dataclasses", "enum", "unicodedata"} diff --git a/tests/test_launchers.py b/tests/test_launchers.py index 886d1612..82b8785b 100644 --- a/tests/test_launchers.py +++ b/tests/test_launchers.py @@ -44,10 +44,14 @@ def test_streamlit_toolbar_uses_viewer_mode_for_public_dashboard(): def test_dashboard_launchers_force_viewer_mode_for_public_demo(): makefile = Path("Makefile").read_text(encoding="utf-8") launcher = Path("scripts/dashboard.sh").read_text(encoding="utf-8") + smoke = Path("scripts/smoke_dashboard.sh").read_text(encoding="utf-8") assert "streamlit run src/dashboard.py --client.toolbarMode viewer --server.headless true" in makefile assert "--client.toolbarMode viewer" in launcher assert "--server.headless true" in launcher + assert 'export PYTHONPATH="${REPO_ROOT}${PYTHONPATH:+:${PYTHONPATH}}"' in launcher + assert 'export PYTHONPATH="${REPO_ROOT}${PYTHONPATH:+:${PYTHONPATH}}"' in smoke + assert 'PYTHONPATH="$(CURDIR):$${PYTHONPATH:-}" streamlit run' in makefile def test_public_check_requires_a_fresh_dashboard_render_smoke(): @@ -59,6 +63,14 @@ def test_public_check_requires_a_fresh_dashboard_render_smoke(): assert "$(MAKE) --silent demo-dashboard-smoke" in public_check +def test_makefile_exposes_research_dashboard_render_smoke(): + makefile = Path("Makefile").read_text(encoding="utf-8") + + assert "research-dashboard-render-smoke" in _makefile_targets() + target = makefile.split("research-dashboard-render-smoke:", 1)[1].split("\n\n", 1)[0] + assert "python3 -m src.dashboard_render_smoke --routes research" in target + + def test_dashboard_smoke_uses_an_isolated_fresh_server(): smoke = Path("scripts/smoke_dashboard.sh").read_text(encoding="utf-8") @@ -66,6 +78,8 @@ def test_dashboard_smoke_uses_an_isolated_fresh_server(): assert 'if [[ "${PORT}" == "0" ]]' in smoke assert "Dashboard already healthy" not in smoke assert "--server.fileWatcherType none" in smoke + assert "Dashboard import check passed" in smoke + assert "Path(dashboard.__file__).resolve()" in smoke def test_price_mutation_targets_use_the_ignored_local_profile(): @@ -76,6 +90,17 @@ def test_price_mutation_targets_use_the_ignored_local_profile(): assert "STOCK_RESEARCH_DATA_PROFILE=local" in block +def test_price_review_targets_forward_one_explicit_temporal_cutoff(): + makefile = Path("Makefile").read_text(encoding="utf-8") + + for target in ("price-validate", "price-preview", "price-apply"): + block = makefile.split(f"{target}:", 1)[1].split("\n\n", 1)[0] + assert '--review-cutoff "$(AS_OF)"' in block + normalize = makefile.split("price-normalize:", 1)[1].split("\n\n", 1)[0] + assert "AS_OF is required when RETRIEVED_AT is supplied" in normalize + assert '--review-cutoff "$(AS_OF)"' in normalize + + def test_demo_data_check_only_runs_the_manifest_verifier(): makefile = Path("Makefile").read_text(encoding="utf-8") @@ -139,6 +164,7 @@ def test_makefile_contains_convenience_targets(): "decision-proof-queue", "metric-readiness-board", "diff-hygiene-summary", + "pr-range-hygiene-check", "diff-hygiene-files", "data-release-decision", "public-release-package", @@ -320,7 +346,7 @@ def test_makefile_help_documents_key_workflows(): "make public-ux-review-note", "Record one local UX review note row without staging or refreshing data", "make pilot-review-feedback", - "Print the controlled 5-10 reviewer feedback capture guide", + "Print the controlled 10-20 reviewer feedback capture guide", "make project-status-check", "Print project status without writing dashboard snapshot files", "make readiness-ops-center", @@ -504,10 +530,13 @@ def test_linkedin_share_check_prints_read_only_final_checklist(): output = result.stdout assert "LinkedIn Share Check" in output assert "Read-only: this target prints the final LinkedIn visual checklist only." in output - assert "Stock Research Command Center | Readiness-First Stock Research Workflow" in output + assert "Stock Research Command Center | Evidence-First Company Research" in output + assert "Research Desk -> Discover -> Company Workbench -> Monitor" in output + assert "stable GitHub repository link only after this reviewed feature reaches the default branch" in output + assert "Draft engineering preview" in output assert "docs/assets/linkedin-public-dashboard.png" in output assert "GitHub's generated OpenGraph card" in output - assert "hosted Streamlit app" in output + assert "hosting" in output assert "screenshots prove current data freshness" in output assert "provider-key activation" in output assert "make public-check" in output @@ -901,8 +930,7 @@ def test_readme_public_landing_page_is_short_visual_and_command_focused(): public_demo = Path("docs/PUBLIC_DEMO_WALKTHROUGH.md").read_text(encoding="utf-8") assert len(readme.splitlines()) < 180 - assert "![Dashboard preview](docs/assets/public-demo-home-real.jpg)" in readme - assert "docs/assets/linkedin-public-dashboard.png" in readme + assert "![Company Workbench answer preview](docs/assets/linkedin-public-dashboard.png)" in readme assert "make status-check TOP_N=5` remains the source for current local counts" in readme for preview_phrase in ( "plain-language stock analysis modes", @@ -934,10 +962,10 @@ def test_readme_public_landing_page_is_short_visual_and_command_focused(): assert stale_preview_phrase not in preview assert "## Quick Start" in readme assert "flowchart LR" in readme - assert 'Home["Home: workflow start"] --> Selector["Stock Selector: readiness-backed queue"]' in readme - assert 'Selector --> Report["Single-Stock Report: one ticker"]' in readme - assert 'Report --> Health["Data Health: missing input"]' in readme - assert 'Health --> Proof["Proof History: source-proof trail"]' in readme + assert 'Desk["Research Desk: changed evidence"] --> Discover["Discover: readiness-backed company"]' in readme + assert 'Discover --> Workbench["Company Workbench: trend, valuation, forward view"]' in readme + assert 'Workbench --> Monitor["Monitor: unresolved research changes"]' in readme + assert 'Workbench -. advanced evidence .-> Health["Data Health and Proof History"]' in readme assert "Open the product before proof packets or report commands" in readme quick_start = readme[readme.index("## Quick Start") : readme.index("## Try This Visitor Workflow")] assert quick_start.index("make demo-dashboard") < quick_start.index("make status-check TOP_N=5") @@ -1053,7 +1081,6 @@ def test_readme_public_landing_page_is_short_visual_and_command_focused(): "what to read first", "Copyable Proof Commands", "readiness-state output, not an action list", - "Roadmap Snapshot", "Review them before committing", "Before sharing or committing, run `make public-check`, then `make public-release-package`", "compact branch status, package status, staging, generated-exclusion, final-check, commit, and push checklist", @@ -1413,22 +1440,20 @@ def test_roadmap_keeps_active_plan_separate_from_completed_product_history(): for phrase in ( "## Now", "## Next", + "## Externally blocked", "## Later", + "## Completed with evidence", "### P0: Performance Release Candidate", "### P1: Controlled Hosted Preview Verification", "### P1: Controlled Pilot Review", - "### P2: FMP One-Ticker Source Smoke", - "### P3: 25-50 Company Trusted-Peer Pilot", - "keep GitHub as the public link until the hosted route is verified.", + "`hosted_account_and_controls_required`", + "`independent_reviewers_required`", ): assert phrase in roadmap - assert roadmap.index("### P0: Performance Release Candidate") < roadmap.index( - "### P1: Controlled Hosted Preview Verification" - ) - assert roadmap.index("### P1: Controlled Hosted Preview Verification") < roadmap.index( - "### P1: Controlled Pilot Review" - ) + completed = roadmap.index("## Completed with evidence") + assert completed < roadmap.index("### P0: Performance Release Candidate") + assert roadmap.index("## Externally blocked") < completed def test_roadmap_routes_exhausted_proof_queues_to_provider_setup_before_candidate_loops(): @@ -1606,8 +1631,9 @@ def test_public_release_docs_point_to_operator_guide_without_stale_future_copy() assert "review-required lanes for fundamentals, peers, earnings, and analyst estimates" in checklist assert "not told to manually refresh the full universe every day" in checklist assert "lane-specific freshness and generated-data hygiene" in checklist - assert "Keep the guided product flow near the top" in checklist - assert "then `make dashboard` and the Home -> Stock Selector -> Single-Stock Report -> Data Health -> Proof History path" in checklist + assert "Keep the primary product flow near the top" in checklist + assert "then Research Desk -> Discover -> Company Workbench -> Monitor" in checklist + assert "Home -> Stock Selector -> Single-Stock Report -> Data Health -> Proof History as the secondary controlled Public demo" in checklist assert "Keep terminal proof commands secondary" in checklist assert "make stock-report-md TICKER=NVDA" in checklist assert "make trusted-data-pilot-candidates TOP_N=10" in checklist @@ -1808,39 +1834,23 @@ def test_linkedin_project_brief_uses_current_demo_path_and_analysis_quality(): brief = Path("docs/LINKEDIN_PROJECT_BRIEF.md").read_text(encoding="utf-8") for phrase in ( - "data readiness first, analysis second, research decision last", - "what can be reviewed now", - "what is blocked by missing data", - "what is excluded because the method does not apply", - "which trusted local input would unlock the next layer", - "Best first click: open the dashboard preview, then follow Home -> Stock Selector -> Single-Stock Report -> Data Health -> Proof History.", - "Each public page now opens with one question, one short answer, one primary next action, and one stop rule.", - "The public workflow is checked at desktop and mobile widths.", - "The current-page shortcut is visible so visitors know where they are.", + "Stock Research Command Center | Evidence-First Company Research", + "local Python and Streamlit portfolio beta for evidence-first company research", + "Research Desk -> Discover -> Company Workbench -> Monitor", + "Home -> Stock Selector -> Single-Stock Report -> Data Health -> Proof History", + "what evidence can be used now", + "keep unsupported conclusions withheld", + "Data Health and Proof History remain available", + "real Workbench answer-first screenshot", + "screenshots are product evidence only", + "stable GitHub repository link only after this reviewed feature reaches the default branch", + "Draft engineering preview", "Operator details stay collapsed until someone intentionally leaves the public path.", - "The first story is the public workflow, not operator automation.", - "Keep reviewed batch packets, provider setup, and validate / preview / apply mechanics as operator detail after the visitor understands the product.", - "Use the refreshed `docs/assets/linkedin-public-dashboard.png` thumbnail for the LinkedIn Featured card.", - "Best demos", - "`NVDA` for DCF-ready company review", - "`META` for valuation still gated by trusted fundamentals", - "`QQQ` for ETF/index monitor context", - "`MU` for standalone DCF with peer valuation still locked", - "`CRDO` for a fundamentals-gated proof workflow", - "A Streamlit command center dashboard", - "Market-wide readiness checks across a broad ticker universe", - "Single-stock Markdown reports", - "At A Glance", - "Reader Guide", - "Evaluation Snapshot", - "Proof Checklist", - "Best Review Path", - "DCF-ready, standalone DCF, price/setup-only, monitor-only, and data-needed-before-analysis report modes", - "Source readiness notes and copyable local proof commands", - "CSV-first import, validation, preview, rejected-row, and readiness workflows", - "ready data can be analyzed, blocked data is explained", - "missing rows are treated as the next proof step", - "At A Glance status, Evaluation Snapshot, Proof Checklist, Best Review Path, method cue, DCF assumptions", + "The first story is company research, not operator automation.", + "reviewed batch packets, provider setup, validate / preview / apply mechanics", + "Use the reviewed `docs/assets/linkedin-public-dashboard.png` Workbench thumbnail", + "no broker integration, no order routing, no auto-trading, and no direct buy/sell instructions", + "local portfolio beta, not a hosted product or market-validated service", "Run `make project-status-check` first and use `make provider-setup-checklist` when source-proof queues are exhausted.", "Do not run trusted-data pilot queues as a LinkedIn demo talking point unless project-status-check shows executable source-backed candidates.", "Keep lane-level operator views, coverage frontier details, reviewed batch packets, and validate / preview / apply guidance as follow-up context after the public workflow is clear.", @@ -1867,21 +1877,14 @@ def test_linkedin_project_brief_uses_current_demo_path_and_analysis_quality(): ): assert phrase in brief - assert "standalone DCF review where peer-relative valuation is still locked" in brief - assert "price/setup review where valuation remains gated" in brief + assert "readiness counts" not in brief.lower() assert "CSV-first staged import workflows" not in brief assert "staged import validation" not in brief linkedin_demo_talking_points = brief.split("## Demo Talking Points", 1)[1] assert "make trusted-data-pilot-candidates TOP_N=10" not in linkedin_demo_talking_points assert "make trusted-data-pilot-packet TICKER=CRDO" not in linkedin_demo_talking_points assert "make trusted-data-pilot TICKERS= TOP_N=10" not in linkedin_demo_talking_points - assert "refusing to present every ticker as complete" in brief - for guardrail_phrase in ( - "direct buy/sell instructions", - "jumping straight to rankings", - "no broker integration", - ): - assert guardrail_phrase in brief + assert "investment advice" in brief def test_dashboard_qa_records_latest_public_flow_browser_check(): @@ -2604,6 +2607,14 @@ def test_makefile_verify_and_daily_targets_reuse_shared_make_workflows(): assert "make trusted-data-pilot-candidates [TICKERS=NVDA,CRDO,META] [TOP_N=10] Rank read-only company candidates for the next trusted-data pilot" in makefile assert "make trusted-data-pilot-lane LANE=fundamentals_dcf [TICKERS=MU,CRDO,HOOD] [TOP_N=10] Print a read-only lane-group runbook and evidence summary" in makefile assert "price-normalize:\nifndef INPUT\n\t$(error INPUT is required, for example: make price-normalize INPUT=data/raw/prices/NVDA.csv TICKER=NVDA SOURCE=yahoo_manual)\nendif" in makefile + price_normalize_target = makefile.split("price-normalize:", 1)[1].split("daily:", 1)[0] + assert '$(if $(SOURCE_REF),--source-ref "$(SOURCE_REF)",)' in price_normalize_target + assert '$(if $(RETRIEVED_AT),--retrieved-at "$(RETRIEVED_AT)",)' in price_normalize_target + assert ( + "make price-normalize INPUT=data/raw/prices/NVDA.csv TICKER=NVDA SOURCE= " + "SOURCE_REF= RETRIEVED_AT= " + "AS_OF=" + ) in makefile assert "stock-report:\nifndef TICKER\n\t$(error TICKER is required, for example: make stock-report TICKER=NVDA)\nendif\n\tpython3 -m src.stock_report --ticker $(TICKER) --provider $(if $(PROVIDER),$(PROVIDER),local) $(if $(OUTPUT),--output $(OUTPUT),) $(if $(MD_OUTPUT),--markdown-output $(MD_OUTPUT),)" in makefile assert "stock-report-md:\nifndef TICKER\n\t$(error TICKER is required, for example: make stock-report-md TICKER=NVDA)\nendif\n\t@python3 -m src.stock_report --ticker $(TICKER) --provider $(if $(PROVIDER),$(PROVIDER),local) --quiet $(if $(MD_OUTPUT),--markdown-output $(MD_OUTPUT),)" in makefile assert "local-tickers:\n\tpython3 -m src.stock_report --list-local-tickers" in makefile @@ -2718,3 +2729,100 @@ def test_earnings_nowcast_onboarding_launchers_have_no_apply_path(): assert "src.earnings_nowcast_onboarding" in section assert "earnings-nowcast-apply" not in section assert "imports-apply" not in section + + +def test_earnings_nowcast_readiness_launcher_has_explicit_fixture_onboarding_path(): + makefile = Path("Makefile").read_text(encoding="utf-8") + section = makefile[makefile.index("earnings-nowcast-readiness:") :] + + assert "$(if $(FIXTURE),tests/fixtures/earnings_nowcast_onboarding,$(or $(INPUT_DIR),data/imports/earnings_nowcast))" in section + assert "tests/fixtures/earnings_nowcast)" not in section + assert "imports-apply" not in section + + +def test_earnings_nowcast_sec_actuals_stage_launcher_requires_scoped_output_and_cutoff(): + makefile = Path("Makefile").read_text(encoding="utf-8") + + assert "earnings-nowcast-sec-actuals-stage" in _makefile_targets() + target = makefile.split("earnings-nowcast-sec-actuals-stage:", 1)[1].split("\n\n", 1)[0] + assert "TICKERS is required" in target + assert "OUTPUT_DIR is required" in target + assert "AS_OF is required" in target + assert "--cutoff \"$(AS_OF)\"" in target + assert "--output-dir \"$(OUTPUT_DIR)\"" in target + assert "--max-runtime-seconds \"$(or $(SEC_STAGE_MAX_RUNTIME_SECONDS),300)\"" in target + assert "generated temporary/review directory" in target + assert "data/earnings_nowcast" not in target + assert "data/imports" not in target + assert "imports-apply" not in target + assert "--apply" not in target + + +def test_makefile_exposes_read_only_commercial_beta_check(): + makefile = Path("Makefile").read_text(encoding="utf-8") + + assert "commercial-beta-check" in _makefile_targets() + target = makefile.split("commercial-beta-check:", 1)[1].split("\n\n", 1)[0] + assert "$(MAKE) --silent commercial-source-rights" in target + assert "$(MAKE) --silent refresh-operations-status" in target + assert "$(MAKE) --silent private-beta-readiness" in target + for forbidden in ("price-refresh", "imports-apply", "git add", "git push"): + assert forbidden not in target + + +def test_makefile_exposes_read_only_commercial_beta_release_check(): + makefile = Path("Makefile").read_text(encoding="utf-8") + + assert "commercial-beta-release-check" in _makefile_targets() + target = makefile.split("commercial-beta-release-check:", 1)[1].split("\n\n", 1)[0] + for required in ( + "$(MAKE) --silent commercial-beta-check", + "$(MAKE) --silent research-dashboard-render-smoke", + "$(MAKE) --silent commercial-beta-performance-contract", + "$(MAKE) --silent browser-qa-evidence", + "$(MAKE) --silent public-check", + "$(MAKE) --silent pilot-readiness-check TOP_N=10", + "$(MAKE) --silent diff-hygiene-summary", + "git diff --check", + "Safe claims:", + "Unsafe claims:", + ): + assert required in target + for forbidden in ( + "price-refresh", + "imports-apply", + "git add", + "git commit", + "git push", + "deploy", + ): + assert forbidden not in target + + +def test_makefile_exposes_bytecode_free_consensus_source_review_target(): + makefile = Path("Makefile").read_text(encoding="utf-8") + + assert "earnings-consensus-source-review:" in makefile + assert "PYTHONDONTWRITEBYTECODE=1 python3 -m src.earnings_consensus_sources" in makefile + assert '--review-csv "$(INPUT)"' in makefile + assert '--provider "$(PROVIDER)"' in makefile + assert '--as-of "$(AS_OF)"' in makefile + + +def test_consensus_record_requires_the_exact_reviewed_preview_receipt(): + makefile = Path("Makefile").read_text(encoding="utf-8") + match = re.search( + r"^earnings-consensus-collection-record:\n(?P(?:\t.*\n)+)", + makefile, + flags=re.MULTILINE, + ) + assert match is not None + target = match.group("body") + + assert 'test -n "$(AS_OF)"' in target + assert 'test -n "$(PREVIEW_RECEIPT)"' in target + assert 'test "$(CONFIRM_REVIEWED)" = "1"' in target + assert '--as-of "$(AS_OF)"' in target + assert '--preview-receipt "$(PREVIEW_RECEIPT)"' in target + assert target.count("--confirm-reviewed") == 1 + assert "$(if $(JSON),--json,)" in makefile diff --git a/tests/test_local_market_data_provider.py b/tests/test_local_market_data_provider.py index c233f7d1..564a45e2 100644 --- a/tests/test_local_market_data_provider.py +++ b/tests/test_local_market_data_provider.py @@ -144,9 +144,9 @@ def test_local_provider_reuses_financial_and_peer_lookups(tmp_path: Path, monkey encoding="utf-8", ) (tmp_path / "data" / "peers.csv").write_text( - "ticker,peer_ticker,peer_group\n" - "ALFA,BETA,test_group\n" - "ALFA,GAMMA,test_group\n", + "ticker,peer_ticker,peer_group,peer_role,relationship_rationale,comparability_basis,valuation_anchor_eligible,source,as_of_date\n" + "ALFA,BETA,test_group,core_peer,Reviewed operating overlap,business model; growth and margin,yes,fixture,2026-01-02\n" + "ALFA,GAMMA,test_group,secondary_peer,Reviewed operating overlap,business model; customer mix,yes,fixture,2026-01-02\n", encoding="utf-8", ) provider = LocalCSVMarketDataProvider(base_dir=tmp_path) @@ -442,6 +442,40 @@ def test_local_provider_peer_summary_exposes_relationship_and_result_evidence(tm json.dumps(summary) +def test_local_provider_keeps_legacy_relationship_visible_but_out_of_valuation_inputs(tmp_path: Path): + (tmp_path / "data").mkdir() + (tmp_path / "data" / "prices.csv").write_text( + "date,ticker,adj_close,volume\n" + "2026-05-01,ALFA,150,1000\n" + "2026-05-01,BETA,90,1000\n" + "2026-05-01,GAMMA,80,1000\n", + encoding="utf-8", + ) + (tmp_path / "data" / "fundamentals.csv").write_text( + "ticker,revenue,eps,free_cash_flow,shares_outstanding,market_cap,source\n" + "ALFA,1000,5,100,10,1500,fixture\n" + "BETA,800,4,90,12,1080,fixture\n" + "GAMMA,700,3,80,11,880,fixture\n", + encoding="utf-8", + ) + (tmp_path / "data" / "peers.csv").write_text( + "ticker,peer_ticker,peer_group,peer_role,relationship_rationale,comparability_basis,valuation_anchor_eligible,source,as_of_date\n" + "ALFA,BETA,software,,,,,fixture,2026-06-30\n" + "ALFA,GAMMA,software,core_peer,Reviewed operating overlap,business model; customer mix,yes,fixture,2026-06-30\n", + encoding="utf-8", + ) + provider = LocalCSVMarketDataProvider(base_dir=tmp_path) + + summary = provider.get_peer_summary("ALFA") + inputs = provider.get_peer_valuation_inputs("ALFA") + + relationships = {row["peer_ticker"]: row for row in summary["trusted_relationships"]} + assert relationships["BETA"]["valuation_anchor_state"] == "withheld" + assert "peer_role_missing" in relationships["BETA"]["evidence_quality_blockers"] + assert relationships["GAMMA"]["valuation_anchor_state"] == "eligible" + assert [row["ticker"] for row in inputs] == ["GAMMA"] + + def test_local_provider_ignores_self_peers_and_duplicate_rows(tmp_path: Path): (tmp_path / "data").mkdir() (tmp_path / "data" / "prices.csv").write_text( diff --git a/tests/test_makefile_test_targets.py b/tests/test_makefile_test_targets.py new file mode 100644 index 00000000..c328863e --- /dev/null +++ b/tests/test_makefile_test_targets.py @@ -0,0 +1,228 @@ +import json +import os +import subprocess +from pathlib import Path + +import pytest + + +PROJECT_ROOT = Path(__file__).resolve().parent.parent + + +def _make( + *arguments: str, + dry_run: bool = False, + env: dict[str, str] | None = None, +) -> subprocess.CompletedProcess[str]: + command = ["make", "--no-print-directory"] + if dry_run: + command.append("--just-print") + command.extend(arguments) + return subprocess.run( + command, + cwd=PROJECT_ROOT, + check=False, + capture_output=True, + text=True, + env=env, + ) + + +def _phony_targets(makefile: str) -> set[str]: + targets: set[str] = set() + for line in makefile.splitlines(): + if line.startswith(".PHONY:"): + targets.update(line.partition(":")[2].split()) + return targets + + +def _argv_capture_environment(tmp_path: Path) -> dict[str, str]: + shim = tmp_path / "python3" + shim.write_text( + "#!/bin/sh\nprintf '%s\\n' \"$@\"\n", + encoding="utf-8", + ) + shim.chmod(0o755) + return {**os.environ, "PATH": f"{tmp_path}:{os.environ['PATH']}"} + + +def test_prospective_field_proof_targets_are_phony_and_default_ledger_is_explicit(): + makefile = (PROJECT_ROOT / "Makefile").read_text(encoding="utf-8") + phony_targets = _phony_targets(makefile) + + for target in ( + "prospective-field-proof-status", + "prospective-field-proof-audit", + "prospective-field-proof-preview", + "prospective-field-proof-record", + ): + assert target in phony_targets + + +def test_default_status_target_runs_live_without_creating_or_changing_scoped_files(): + ledger = PROJECT_ROOT / "data" / "prospective_field_proofs.csv" + scoped_files = ( + PROJECT_ROOT / "data" / "earnings_readiness.csv", + PROJECT_ROOT / "data" / "universe_master.csv", + PROJECT_ROOT / "data" / "reviewed_data_proof.csv", + PROJECT_ROOT / "outputs" / "feature_readiness_summary.csv", + ) + assert not ledger.exists() + before = {path: path.read_bytes() for path in scoped_files if path.is_file()} + + result = _make("prospective-field-proof-status", "JSON=1") + payload = json.loads(result.stdout) + after = {path: path.read_bytes() for path in scoped_files if path.is_file()} + + assert result.returncode == 0 + assert payload["ledger"] == "data/prospective_field_proofs.csv" + assert payload["state"] == "absent" + assert payload["write_performed"] is False + assert not ledger.exists() + assert after == before + + +def test_default_audit_target_runs_live_without_creating_or_changing_scoped_files(): + ledger = PROJECT_ROOT / "data" / "prospective_field_proofs.csv" + scoped_files = ( + PROJECT_ROOT / "data" / "earnings_readiness.csv", + PROJECT_ROOT / "data" / "universe_master.csv", + PROJECT_ROOT / "data" / "reviewed_data_proof.csv", + PROJECT_ROOT / "outputs" / "feature_readiness_summary.csv", + ) + assert not ledger.exists() + before = {path: path.read_bytes() for path in scoped_files if path.is_file()} + + result = _make("prospective-field-proof-audit", "JSON=1") + payload = json.loads(result.stdout) + after = {path: path.read_bytes() for path in scoped_files if path.is_file()} + + assert result.returncode == 0 + assert payload["ledger"] == "data/prospective_field_proofs.csv" + assert payload["mode"] == "audit_read_only" + assert payload["state"] == "absent" + assert payload["write_performed"] is False + assert payload["preview_receipt_persisted"] is False + assert not ledger.exists() + assert after == before + + +def test_prospective_field_proof_make_targets_forward_adversarial_values_literally( + tmp_path: Path, +): + marker = tmp_path / "make-injection-marker" + command_substitution = f"$(touch {marker})" + backtick_substitution = f"`touch {marker}`" + input_path = ( + "review inputs/quote'\"-" + f"{backtick_substitution}-{command_substitution}-back\\slash.csv" + ) + ledger_path = ( + "review ledgers/quote'\"-" + f"{backtick_substitution}-{command_substitution}-back\\slash.csv" + ) + cutoff = ( + "2026-07-20T00:00:00Z quote'\"-" + f"{backtick_substitution}-{command_substitution}-back\\slash" + ) + receipt = ( + "receipt quote'\"-" + f"{backtick_substitution}-{command_substitution}-back\\slash" + ) + capture_env = _argv_capture_environment(tmp_path) + + preview = _make( + "prospective-field-proof-preview", + f"INPUT={input_path}", + f"LEDGER={ledger_path}", + f"AS_OF={cutoff}", + "JSON=1", + env=capture_env, + ) + record = _make( + "prospective-field-proof-record", + f"INPUT={input_path}", + f"LEDGER={ledger_path}", + f"AS_OF={cutoff}", + f"PREVIEW_RECEIPT={receipt}", + "CONFIRM_REVIEWED=1", + "JSON=1", + env=capture_env, + ) + marker_created = marker.exists() + marker.unlink(missing_ok=True) + + assert preview.returncode == record.returncode == 0 + assert preview.stdout.splitlines() == [ + "-m", + "src.prospective_field_proof", + "preview", + "--input", + input_path, + "--ledger", + ledger_path, + "--as-of", + cutoff, + "--json", + ] + assert record.stdout.splitlines() == [ + "-m", + "src.prospective_field_proof", + "record", + "--input", + input_path, + "--ledger", + ledger_path, + "--as-of", + cutoff, + "--preview-receipt", + receipt, + "--confirm-reviewed", + "--json", + ] + assert marker_created is False + + +@pytest.mark.parametrize( + ("variable", "message"), + [ + ("INPUT", "INPUT is required for the exact reviewed field proof batch"), + ("AS_OF", "AS_OF is required and must match the reviewed preview cutoff"), + ( + "PREVIEW_RECEIPT", + "PREVIEW_RECEIPT is required from the exact reviewed preview", + ), + ], +) +def test_record_make_target_rejects_blank_values_before_python( + variable: str, message: str +): + values = { + "INPUT": "review/proposed.csv", + "AS_OF": "2026-07-20T00:00:00Z", + "PREVIEW_RECEIPT": "c" * 64, + } + values[variable] = " " + + result = _make( + "prospective-field-proof-record", + *(f"{name}={value}" for name, value in values.items()), + "CONFIRM_REVIEWED=1", + ) + + assert result.returncode != 0 + assert message in result.stderr + assert "src.prospective_field_proof" not in result.stdout + result.stderr + + +def test_record_make_target_requires_explicit_review_confirmation_before_python(): + result = _make( + "prospective-field-proof-record", + "INPUT=review/proposed.csv", + "AS_OF=2026-07-20T00:00:00Z", + f"PREVIEW_RECEIPT={'d' * 64}", + ) + + assert result.returncode != 0 + assert "CONFIRM_REVIEWED=1 is required after reviewing the exact preview" in result.stderr + assert "src.prospective_field_proof" not in result.stdout + result.stderr diff --git a/tests/test_peer_evidence_quality.py b/tests/test_peer_evidence_quality.py new file mode 100644 index 00000000..6450d788 --- /dev/null +++ b/tests/test_peer_evidence_quality.py @@ -0,0 +1,104 @@ +from src.peer_evidence_quality import assess_peer_evidence, is_valuation_anchor_eligible + + +def _eligible_row(**overrides: object) -> dict[str, object]: + row: dict[str, object] = { + "ticker": "ALFA", + "peer_ticker": "BETA", + "source": "https://example.test/peer-proof", + "as_of_date": "2026-06-30", + "peer_role": "core_peer", + "relationship_rationale": "Both companies sell subscription workflow software to large enterprises.", + "comparability_basis": "business model; customer mix; growth and margin profile", + "valuation_anchor_eligible": "yes", + } + row.update(overrides) + return row + + +def test_core_peer_with_complete_review_evidence_is_valuation_anchor_eligible(): + quality = assess_peer_evidence(_eligible_row()) + + assert quality.relationship_state == "source_backed" + assert quality.role_state == "reviewed_role" + assert quality.comparability_state == "reviewed_comparable" + assert quality.valuation_anchor_state == "eligible" + assert quality.blockers == () + assert is_valuation_anchor_eligible(_eligible_row()) is True + + +def test_legacy_relationship_remains_visible_but_fails_closed_for_valuation_anchor(): + quality = assess_peer_evidence( + { + "ticker": "ALFA", + "peer_ticker": "BETA", + "peer_group": "enterprise software", + "sector": "Technology", + "industry": "Software", + "source": "https://example.test/peer-proof", + "as_of_date": "2026-06-30", + } + ) + + assert quality.relationship_state == "source_backed" + assert quality.role_state == "unreviewed_role" + assert quality.comparability_state == "unreviewed" + assert quality.valuation_anchor_state == "withheld" + assert quality.blockers == ( + "peer_role_missing", + "relationship_rationale_missing", + "comparability_basis_missing", + "valuation_anchor_decision_missing", + ) + + +def test_secondary_peer_can_anchor_when_explicitly_reviewed(): + quality = assess_peer_evidence(_eligible_row(peer_role="secondary_peer")) + + assert quality.peer_role == "secondary_peer" + assert quality.valuation_anchor_state == "eligible" + + +def test_context_only_role_cannot_anchor_even_when_flag_says_yes(): + quality = assess_peer_evidence(_eligible_row(peer_role="aspirational_peer")) + + assert quality.comparability_state == "context_only" + assert quality.valuation_anchor_state == "withheld" + assert quality.blockers == ("peer_role_not_anchor_eligible",) + + +def test_explicit_no_keeps_reviewed_core_peer_as_context_only(): + quality = assess_peer_evidence(_eligible_row(valuation_anchor_eligible="no")) + + assert quality.comparability_state == "context_only" + assert quality.valuation_anchor_state == "withheld" + assert quality.blockers == ("valuation_anchor_explicitly_withheld",) + + +def test_invalid_role_and_missing_comparability_are_named_blockers(): + quality = assess_peer_evidence( + _eligible_row(peer_role="favorite_peer", comparability_basis="") + ) + + assert quality.role_state == "invalid_role" + assert quality.comparability_state == "unreviewed" + assert quality.valuation_anchor_state == "withheld" + assert quality.blockers == ("peer_role_invalid", "comparability_basis_missing") + + +def test_missing_relationship_provenance_blocks_anchor_independently(): + quality = assess_peer_evidence(_eligible_row(source="", as_of_date="")) + + assert quality.relationship_state == "provenance_missing" + assert quality.role_state == "reviewed_role" + assert quality.comparability_state == "reviewed_comparable" + assert quality.valuation_anchor_state == "withheld" + assert quality.blockers == ("relationship_source_missing", "relationship_as_of_missing") + + +def test_candidate_context_never_enters_trusted_or_anchor_state(): + quality = assess_peer_evidence(_eligible_row(candidate_state="candidate")) + + assert quality.relationship_state == "candidate_context_only" + assert quality.valuation_anchor_state == "withheld" + assert quality.blockers == ("candidate_context_not_trusted",) diff --git a/tests/test_peer_mapping_source_review.py b/tests/test_peer_mapping_source_review.py index 62f316d7..2d24f4b7 100644 --- a/tests/test_peer_mapping_source_review.py +++ b/tests/test_peer_mapping_source_review.py @@ -121,7 +121,7 @@ def test_peer_mapping_source_review_packet_builds_two_review_slots_per_candidate assert "## First Peer Readiness Answer" in rendered assert "First answer status: `needs_source_review_fields`" in rendered assert "Trusted peer proof state: `locked`" in rendered - assert "Import schema: `ticker, peer_ticker, peer_group, sector, industry, source, as_of_date`" in rendered + assert "Import schema: `ticker, peer_ticker, peer_group, sector, industry, peer_role" in rendered assert "relationship rationale" in rendered assert "memory, popularity, sector/theme similarity alone" in rendered assert "Candidate context:" in rendered @@ -216,7 +216,10 @@ def test_peer_mapping_source_review_completion_detects_placeholders(tmp_path: Pa assert "keep peer valuation locked" in completion.next_safe_action assert peer_mapping_import_row_scaffold(packet.rows[0]).startswith("blocked until reviewed fields are filled") assert preview.status == "needs_field_fills" - assert preview.csv_header == "ticker,peer_ticker,peer_group,sector,industry,source,as_of_date" + assert preview.csv_header == ( + "ticker,peer_ticker,peer_group,sector,industry,peer_role,relationship_rationale," + "comparability_basis,valuation_anchor_eligible,source,as_of_date" + ) assert preview.csv_row == "" assert "Do not edit or apply" in preview.apply_boundary @@ -229,9 +232,12 @@ def test_peer_mapping_source_review_completion_builds_ready_import_row_scaffold( peer_group="large-cap software", sector="Technology", industry="Software", + peer_role="core_peer", source="https://example.com/peer-proof", as_of_date="2026-06-14", relationship_rationale="Source names comparable enterprise software exposure.", + comparability_basis="business model; customer mix; growth and margin profile", + valuation_anchor_eligible="yes", reviewer="local reviewer", review_date="2026-06-14", source_proof_status="reviewed", @@ -244,9 +250,17 @@ def test_peer_mapping_source_review_completion_builds_ready_import_row_scaffold( assert peer_mapping_source_review_missing_fields(reviewed_row) == () assert completion.status == "ready_for_import_row_scaffold" - assert completion.import_row_scaffold == "AAA,MSFT,large-cap software,Technology,Software,https://example.com/peer-proof,2026-06-14" + assert completion.import_row_scaffold == ( + "AAA,MSFT,large-cap software,Technology,Software,core_peer," + "Source names comparable enterprise software exposure.," + "business model; customer mix; growth and margin profile,yes," + "https://example.com/peer-proof,2026-06-14" + ) assert "validate and preview" in completion.next_safe_action - assert peer_mapping_import_csv_header() == "ticker,peer_ticker,peer_group,sector,industry,source,as_of_date" + assert peer_mapping_import_csv_header() == ( + "ticker,peer_ticker,peer_group,sector,industry,peer_role,relationship_rationale," + "comparability_basis,valuation_anchor_eligible,source,as_of_date" + ) assert preview.status == "ready_for_validate_preview" assert preview.csv_row == completion.import_row_scaffold assert preview.validation_command == "make imports-validate IMPORT_TICKERS= && make imports-preview IMPORT_TICKERS=" @@ -257,6 +271,31 @@ def test_peer_mapping_source_review_completion_builds_ready_import_row_scaffold( assert decision.trusted_peer_proof_state == "ready_for_guard" +def test_peer_mapping_source_review_blocks_invalid_role_or_anchor_decision(tmp_path: Path): + packet = build_peer_mapping_source_review_packet(_sample_root(tmp_path), top_n=1) + invalid_row = replace( + packet.rows[0], + proposed_peer_ticker="MSFT", + peer_group="large-cap software", + peer_role="favorite_peer", + source="https://example.com/peer-proof", + as_of_date="2026-06-14", + relationship_rationale="Source names comparable enterprise software exposure.", + comparability_basis="business model; customer mix; growth and margin profile", + valuation_anchor_eligible="yes", + reviewer="local reviewer", + review_date="2026-06-14", + source_proof_status="reviewed", + import_row_ready="yes", + ) + + completion = peer_mapping_source_review_completion(invalid_row, packet.freshness) + + assert completion.status == "needs_field_fills" + assert completion.missing_fields == ("peer_role_invalid",) + assert completion.import_row_scaffold.startswith("blocked until reviewed fields are filled") + + def test_peer_mapping_writeback_guard_allows_ready_non_duplicate_row(tmp_path: Path): root = _sample_root(tmp_path) packet = build_peer_mapping_source_review_packet(root, top_n=1) @@ -266,9 +305,12 @@ def test_peer_mapping_writeback_guard_allows_ready_non_duplicate_row(tmp_path: P peer_group="large-cap software", sector="Technology", industry="Software", + peer_role="core_peer", source="https://example.com/peer-proof", as_of_date="2026-06-14", relationship_rationale="Source names comparable enterprise software exposure.", + comparability_basis="business model; customer mix; growth and margin profile", + valuation_anchor_eligible="yes", reviewer="local reviewer", review_date="2026-06-14", source_proof_status="reviewed", @@ -281,8 +323,16 @@ def test_peer_mapping_writeback_guard_allows_ready_non_duplicate_row(tmp_path: P assert guard.status == "ready_for_validate_preview" assert guard.blocking_reasons == () assert guard.duplicate_sources == () - assert guard.csv_header == "ticker,peer_ticker,peer_group,sector,industry,source,as_of_date" - assert guard.csv_row == "AAA,MSFT,large-cap software,Technology,Software,https://example.com/peer-proof,2026-06-14" + assert guard.csv_header == ( + "ticker,peer_ticker,peer_group,sector,industry,peer_role,relationship_rationale," + "comparability_basis,valuation_anchor_eligible,source,as_of_date" + ) + assert guard.csv_row == ( + "AAA,MSFT,large-cap software,Technology,Software,core_peer," + "Source names comparable enterprise software exposure.," + "business model; customer mix; growth and margin profile,yes," + "https://example.com/peer-proof,2026-06-14" + ) assert guard.proof_record_status == "ready_for_review_fields" assert "validation_result" in guard.proof_record_missing_fields assert "final_outcome" in guard.proof_record_missing_fields @@ -312,9 +362,12 @@ def test_peer_mapping_writeback_guard_blocks_duplicate_and_self_peer(tmp_path: P packet.rows[0], proposed_peer_ticker="MSFT", peer_group="large-cap software", + peer_role="core_peer", source="https://example.com/peer-proof", as_of_date="2026-06-14", relationship_rationale="Source names comparable enterprise software exposure.", + comparability_basis="business model; customer mix; growth and margin profile", + valuation_anchor_eligible="yes", reviewer="local reviewer", review_date="2026-06-14", source_proof_status="reviewed", @@ -354,12 +407,18 @@ def test_peer_mapping_writeback_guard_cli_is_copy_only(tmp_path: Path, capsys): "Technology", "--industry", "Software", + "--peer-role", + "core_peer", "--source", "https://example.com/peer-proof", "--as-of-date", "2026-06-14", "--relationship-rationale", "Source names comparable enterprise software exposure.", + "--comparability-basis", + "business model; customer mix; growth and margin profile", + "--valuation-anchor-eligible", + "yes", "--reviewer", "local reviewer", "--review-date", @@ -375,7 +434,12 @@ def test_peer_mapping_writeback_guard_cli_is_copy_only(tmp_path: Path, capsys): assert rc == 0 assert "Peer mapping write-back guard" in output assert "status: ready_for_validate_preview" in output - assert "csv_row: AAA,MSFT,large-cap software,Technology,Software,https://example.com/peer-proof,2026-06-14" in output + assert ( + "csv_row: AAA,MSFT,large-cap software,Technology,Software,core_peer," + "Source names comparable enterprise software exposure.," + "business model; customer mix; growth and margin profile,yes," + "https://example.com/peer-proof,2026-06-14" + ) in output assert "proof_record_command: DRY_RUN=1 make reviewed-batch-proof-record" in output assert "does not edit files" in output diff --git a/tests/test_peer_read_through_map.py b/tests/test_peer_read_through_map.py index 0fdcb231..4d000199 100644 --- a/tests/test_peer_read_through_map.py +++ b/tests/test_peer_read_through_map.py @@ -14,6 +14,10 @@ def _payload(*, target_period: str | None = "2026-Q2") -> dict[str, object]: "peer_ticker": "BETA", "peer_group": "cloud infrastructure", "industry": "Cloud platforms", + "peer_role": "core_peer", + "relationship_rationale": "Cloud platforms with comparable enterprise customer exposure.", + "comparability_basis": "business model; customer mix; growth and margin profile", + "valuation_anchor_eligible": "yes", "source": "https://example.test/peer-source", "as_of_date": "2026-06-30", "peer_result": { @@ -46,7 +50,10 @@ def test_trusted_relationship_with_result_and_explicit_periods_is_reviewable_con edge = result.edges[0] assert edge.peer_ticker == "BETA" assert edge.relationship_state == "trusted_peer_ready" - assert edge.business_overlap == "Cloud platforms" + assert edge.peer_role == "core_peer" + assert edge.comparability_state == "reviewed_comparable" + assert edge.valuation_anchor_state == "eligible" + assert edge.business_overlap == "Cloud platforms with comparable enterprise customer exposure." assert edge.fiscal_timing == "ALFA 2026-Q2 / BETA 2026-Q2" assert edge.result_evidence == "Revenue actual and EPS actual" assert edge.read_through_state == "reviewable_context" @@ -61,6 +68,7 @@ def test_candidate_relationship_never_becomes_trusted_or_reviewable(): assert candidate.read_through_state == "candidate_context_only" assert candidate.result_evidence == "Not reviewed" assert result.reviewable_count == 1 + assert result.valuation_anchor_count == 1 def test_trusted_relationship_without_result_fails_closed(): @@ -95,6 +103,36 @@ def test_missing_relationship_provenance_cannot_be_trusted(): assert edge.read_through_state == "awaiting_relationship_proof" +def test_legacy_trusted_relationship_keeps_result_context_but_withholds_valuation_anchor(): + payload = _payload() + trusted = payload["valuation_readiness"]["peer_summary"]["trusted_relationships"][0] + for key in ("peer_role", "relationship_rationale", "comparability_basis", "valuation_anchor_eligible"): + trusted.pop(key) + + result = build_peer_read_through_map(payload, profile_key="demo") + edge = result.edges[0] + + assert edge.relationship_state == "trusted_peer_ready" + assert edge.read_through_state == "reviewable_context" + assert edge.peer_role == "unreviewed" + assert edge.comparability_state == "unreviewed" + assert edge.valuation_anchor_state == "withheld" + assert "peer_role_missing" in edge.evidence_quality_blockers + assert result.valuation_anchor_count == 0 + assert result.comparability_withheld_count == 1 + + +def test_aspirational_peer_is_explicit_context_only_not_a_valuation_anchor(): + payload = _payload() + trusted = payload["valuation_readiness"]["peer_summary"]["trusted_relationships"][0] + trusted["peer_role"] = "aspirational_peer" + + edge = build_peer_read_through_map(payload, profile_key="demo").edges[0] + + assert edge.comparability_state == "context_only" + assert edge.valuation_anchor_state == "withheld" + + def test_non_company_assets_are_excluded(): payload = _payload() payload["asset_type"] = "etf" @@ -113,5 +151,8 @@ def test_identity_is_deterministic_and_rows_keep_trust_boundary(): assert first.map_identity == second.map_identity rows = peer_read_through_rows(first) assert rows[0]["Relationship"] == "Trusted peer" + assert rows[0]["Peer Role"] == "Core peer" + assert rows[0]["Comparability"] == "Reviewed comparable" + assert rows[0]["Valuation Anchor"] == "Eligible" assert rows[1]["Relationship"] == "Candidate context only" assert all("buy" not in str(row).lower() for row in rows) diff --git a/tests/test_pilot_readiness.py b/tests/test_pilot_readiness.py index 5689b0ab..38f547a0 100644 --- a/tests/test_pilot_readiness.py +++ b/tests/test_pilot_readiness.py @@ -474,7 +474,9 @@ def test_pilot_readiness_blocks_product_dirty_and_stale_readiness(tmp_path: Path assert by_area["Generated artifact hygiene"].status == "blocked" assert "product/code/docs/test" in by_area["Generated artifact hygiene"].detail assert by_area["Readiness freshness"].status == "blocked" - assert by_area["Readiness freshness"].command == "make readiness" + assert by_area["Readiness freshness"].command == "make readiness-preview TOP_N=20" + assert "preview" in by_area["Readiness freshness"].stop_rule.lower() + assert "final counts" in by_area["Readiness freshness"].stop_rule.lower() assert pilot_readiness_verdict(checks) == "blocked" diff --git a/tests/test_pilot_review_feedback_template.py b/tests/test_pilot_review_feedback_template.py index 0876b9e0..3f9ed131 100644 --- a/tests/test_pilot_review_feedback_template.py +++ b/tests/test_pilot_review_feedback_template.py @@ -1,5 +1,6 @@ from pathlib import Path import csv +import subprocess def test_pilot_review_feedback_template_keeps_clarity_feedback_separate_from_data_proof(): @@ -57,7 +58,8 @@ def test_pilot_review_feedback_make_target_is_read_only_and_discoverable(): assert "docs/PILOT_REVIEW_FEEDBACK_TEMPLATE.md" in target assert "docs/PILOT_REVIEW_FEEDBACK_LOG_TEMPLATE.csv" in target assert "cp docs/PILOT_REVIEW_FEEDBACK_LOG_TEMPLATE.csv /tmp/stock-command-center-pilot-feedback.csv" in target - assert "5-10 external reviewers" in target + assert "10-20 external reviewers" in target + assert "Research Desk -> Discover -> Company Workbench -> Monitor" in target assert "not data proof" in target.lower() assert "does not refresh data, import rows, stage files, commit, push, or deploy" in target assert "Commit a feedback log only after removing personal information" in target @@ -68,6 +70,25 @@ def test_pilot_review_feedback_make_target_is_read_only_and_discoverable(): assert "does not refresh data, import rows, stage files, commit, push, deploy, or publish feedback" in closeout_target +def test_pilot_review_feedback_command_prints_current_workflow_and_complete_scorecard(): + result = subprocess.run( + ["make", "pilot-review-feedback"], + check=True, + capture_output=True, + text=True, + ) + + assert "Research Desk -> Discover -> Company Workbench -> Monitor" in result.stdout + assert "Evidence trace" in result.stdout + assert "Authoring friction" in result.stdout + assert "Repeat-use intent" in result.stdout + assert "Most important missing workflow" in result.stdout + assert "voluntary participation" in result.stdout + assert "withdrawal" in result.stdout + assert "deletion date" in result.stdout + assert "Home -> Stock Selector -> Single-Stock Report" not in result.stdout + + def test_pilot_review_feedback_log_template_is_anonymous_and_comparable(): path = Path("docs/PILOT_REVIEW_FEEDBACK_LOG_TEMPLATE.csv") rows = list(csv.DictReader(path.read_text(encoding="utf-8").splitlines())) @@ -113,6 +134,30 @@ def test_pilot_review_feedback_log_template_is_anonymous_and_comparable(): assert "sell" not in body.lower() +def test_commercial_beta_feedback_log_captures_complete_validation_and_consent_contract(): + path = Path("docs/PILOT_REVIEW_FEEDBACK_LOG_TEMPLATE.csv") + rows = list(csv.DictReader(path.read_text(encoding="utf-8").splitlines())) + + assert rows + header = rows[0].keys() + for column in ( + "consent_confirmed", + "consent_withdrawn", + "retention_delete_after", + "evidence_trace_result", + "authoring_friction", + "repeat_use_intent", + "most_important_missing_workflow", + ): + assert column in header + + example = rows[0] + assert example["consent_confirmed"] == "yes / no" + assert example["consent_withdrawn"] == "yes / no" + assert example["evidence_trace_result"] == "completed / completed_with_help / not_completed" + assert example["repeat_use_intent"] == "yes / maybe / no" + + def test_pilot_feedback_closeout_checklist_keeps_feedback_out_of_data_gates(): body = Path("docs/PILOT_FEEDBACK_CLOSEOUT_CHECKLIST.md").read_text(encoding="utf-8") readme = Path("README.md").read_text(encoding="utf-8") @@ -141,7 +186,7 @@ def test_pilot_review_invitation_is_a_short_privacy_safe_entry_point(): readme = Path("README.md").read_text(encoding="utf-8") linkedin_brief = Path("docs/LINKEDIN_PROJECT_BRIEF.md").read_text(encoding="utf-8") - assert "Home -> Stock Selector -> Single-Stock Report -> Data Health -> Proof History" in invitation + assert "Research Desk -> Discover -> Company Workbench -> Monitor" in invitation assert "under three minutes" in invitation assert "Where did you start?" in invitation assert "What could you use now?" in invitation @@ -153,3 +198,47 @@ def test_pilot_review_invitation_is_a_short_privacy_safe_entry_point(): assert "not data proof" in invitation.lower() assert "docs/PILOT_REVIEW_INVITATION.md" in readme assert "docs/PILOT_REVIEW_INVITATION.md" in linkedin_brief + + +def test_commercial_beta_pilot_contract_is_task_based_and_truthful(): + roadmap = Path("ROADMAP.md").read_text(encoding="utf-8") + readme = Path("README.md").read_text(encoding="utf-8") + runbook = Path("docs/PILOT_RUNBOOK.md").read_text(encoding="utf-8") + decision = Path("docs/PRODUCT_DIRECTION_DECISION.md").read_text(encoding="utf-8") + + assert "## Now: Commercial Research Beta Foundation" in roadmap + assert "Research Desk -> Discover -> Company Workbench -> Monitor" in roadmap + assert "local Commercial Research Beta foundation" in readme + assert "not a hosted or commercially launched product" in readme + assert "10 to 20" in runbook + for metric in ( + "Task success", + "Time to first answer", + "Readiness comprehension", + "Misuse risk", + "Trust in evidence", + "Perceived performance", + "Repeat-use case", + ): + assert metric in runbook + assert "awaiting_external_review" in decision + assert "awaiting_reviewed_source" in decision + assert "external_account_required" in decision + + +def test_commercial_beta_pilot_uses_the_research_workflow_and_nine_tasks(): + template = Path("docs/PILOT_REVIEW_FEEDBACK_TEMPLATE.md").read_text(encoding="utf-8") + + assert "## Commercial Research Beta Tasks" in template + assert "Research Desk -> Discover -> Company Workbench -> Monitor" in template + for task in ( + "Identify the focused cohort and freshness state", + "Use Discover to select one reviewable company", + "Explain what can be used now", + "Identify one withheld input", + "Review Business Trend, Valuation, and Forward View boundaries", + "Use Monitor to determine whether verified evidence changed", + "State why the product is research-only", + ): + assert task in template + assert "Do not fabricate reviewer sessions, completion rates, quotes, or findings" in template diff --git a/tests/test_point_in_time_universe.py b/tests/test_point_in_time_universe.py new file mode 100644 index 00000000..70554c01 --- /dev/null +++ b/tests/test_point_in_time_universe.py @@ -0,0 +1,5161 @@ +import csv +import hashlib +import json +from types import SimpleNamespace + +import pytest + +from tests.point_in_time_universe_fixture import build_valid_package +from tests.test_point_in_time_universe_contracts import _rewrite_csv_and_manifest + + +EXPECTED_REASON_PREFIXES = { + "manifest_", + "schema_", + "lineage_", + "identity_", + "membership_", + "corporate_action_", + "delisting_", + "source_rights_", + "cutoff_", + "leakage_", + "partition_", +} + +EXPECTED_EXCLUSION_CODES = { + "schema_columns_invalid", + "schema_required_field_missing", + "schema_whitespace_invalid", + "schema_timestamp_invalid", + "schema_enum_invalid", + "schema_ratio_invalid", + "schema_ratio_pair_required", + "schema_delisting_listing_state_invalid", + "schema_identity_interval_reversed", + "schema_identity_issuer_matches_security", + "schema_membership_interval_reversed", + "schema_event_row_id_duplicate", + "schema_evaluation_row_id_duplicate", + "source_rights_source_unreadable", + "lineage_duplicate_id", + "lineage_missing_parent", + "lineage_cross_scope_parent", + "lineage_order_reversed", + "lineage_multiple_roots", + "lineage_fork", + "lineage_cycle", + "identity_interval_overlap", + "identity_missing", + "identity_security_id_reused_across_issuers", + "membership_interval_inactive", + "membership_universe_undeclared", + "membership_snapshot_omission_unexplained", + "corporate_action_policy_unsupported", + "corporate_action_successor_required", + "delisting_transition_invalid", + "cutoff_evaluation_after_manifest", + "cutoff_evaluation_unavailable", + "cutoff_post_evaluation_evidence", + "cutoff_required_scope_unavailable", + "cutoff_later_revision_invisible", + "cutoff_unrelated_scope_invisible", + "leakage_evaluation_after_manifest_cutoff", + "leakage_evaluation_available_late", + "leakage_post_cutoff_evidence", + "partition_assignment_invalid", + "partition_minimum_history_unmet", + "partition_boundary_unassigned", +} + +EXPECTED_EXCLUSION_PREFIXES = { + "schema_", + "lineage_", + "identity_", + "membership_", + "corporate_action_", + "delisting_", + "cutoff_", + "leakage_", + "partition_", + "source_rights_", +} + +EXCLUSION_MUTATION_CASES = ( + "schema_columns", + "schema_required", + "schema_whitespace", + "schema_timestamp", + "schema_enum", + "schema_ratio", + "schema_ratio_pair", + "schema_delisting_state", + "schema_identity_interval", + "schema_identity_issuer_matches_security", + "schema_membership_interval", + "schema_event_duplicate", + "schema_evaluation_duplicate", + "evaluation_undeclared", + "lineage_duplicate", + "lineage_missing_parent", + "lineage_cross_scope", + "lineage_order_reversed", + "lineage_multiple_roots", + "lineage_fork", + "lineage_cycle", + "identity_interval_overlap", + "identity_missing", + "identity_security_reuse", + "membership_interval_inactive", + "membership_snapshot_omission", + "corporate_action_unsupported", + "corporate_action_successor", + "delisting_transition", + "cutoff_after_manifest", + "cutoff_evaluation_unavailable", + "cutoff_required_scope", + "cutoff_later_revision", + "cutoff_unrelated_scope", + "partition_assignment", + "partition_minimum_history", + "partition_boundary", +) + + +def _sha256_members(*security_ids): + return hashlib.sha256("\n".join(sorted(security_ids)).encode("utf-8")).hexdigest() + + +def _decision(packet, area): + return packet.decisions[area] + + +def _digest_by_universe(packet): + return {digest.universe_id: digest for digest in packet.membership_digests} + + +def _rewrite_manifest(manifest, mutate): + raw = json.loads(manifest.read_text()) + mutate(raw) + manifest.write_text(json.dumps(raw), encoding="utf-8") + + +def _refresh_registry_digest(manifest, registry): + _rewrite_manifest( + manifest, + lambda raw: raw.update( + source_rights_registry_sha256=hashlib.sha256( + registry.read_bytes() + ).hexdigest() + ), + ) + + +def _replace_contract_rows(manifest, contract, rows): + raw = json.loads(manifest.read_text()) + entry = next(item for item in raw["files"] if item["contract"] == contract) + path = manifest.parent / entry["path"] + with path.open(encoding="utf-8", newline="") as handle: + fieldnames = next(csv.reader(handle)) + with path.open("w", encoding="utf-8", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=fieldnames) + writer.writeheader() + writer.writerows(rows) + entry["sha256"] = hashlib.sha256(path.read_bytes()).hexdigest() + entry["row_count"] = len(rows) + manifest.write_text(json.dumps(raw), encoding="utf-8") + + +def _read_contract_rows(manifest, contract): + raw = json.loads(manifest.read_text()) + entry = next(item for item in raw["files"] if item["contract"] == contract) + path = manifest.parent / entry["path"] + with path.open(encoding="utf-8", newline="") as handle: + return list(csv.DictReader(handle)) + + +def _refresh_contract_digest(manifest, contract): + raw = json.loads(manifest.read_text()) + entry = next( + item for item in raw["files"] if item["contract"] == contract + ) + path = manifest.parent / entry["path"] + entry["sha256"] = hashlib.sha256(path.read_bytes()).hexdigest() + entry["row_count"] = len(path.read_text(encoding="utf-8").splitlines()) - 1 + manifest.write_text(json.dumps(raw), encoding="utf-8") + + +def _mutate_identity_lineage_exclusion(manifest, case): + def mutate(rows): + base = rows[0] + if case == "lineage_duplicate": + rows.append( + { + **base, + "source_ref": "fixture://identity/duplicate", + } + ) + elif case == "lineage_missing_parent": + base["supersedes_identity_row_id"] = "missing" + elif case == "lineage_cross_scope": + rows.append( + { + **base, + "identity_row_id": "id-cross-scope", + "security_id": "sec-2", + "issuer_id": "issuer-2", + "source_ref": "fixture://identity/cross-scope", + "source_published_at": "2020-02-01T00:00:00Z", + "retrieved_at": "2020-02-02T00:00:00Z", + "supersedes_identity_row_id": "id-1", + } + ) + elif case == "lineage_order_reversed": + rows.append( + { + **base, + "identity_row_id": "id-reversed", + "source_ref": "fixture://identity/reversed", + "source_published_at": "2020-01-01T00:00:00Z", + "retrieved_at": "2020-01-01T00:00:00Z", + "supersedes_identity_row_id": "id-1", + } + ) + elif case == "lineage_multiple_roots": + rows.append( + { + **base, + "identity_row_id": "id-second-root", + "source_ref": "fixture://identity/second-root", + } + ) + elif case == "lineage_fork": + for suffix, month in (("a", "02"), ("b", "03")): + rows.append( + { + **base, + "identity_row_id": f"id-child-{suffix}", + "source_ref": f"fixture://identity/child-{suffix}", + "source_published_at": ( + f"2020-{month}-01T00:00:00Z" + ), + "retrieved_at": f"2020-{month}-02T00:00:00Z", + "supersedes_identity_row_id": "id-1", + } + ) + elif case == "lineage_cycle": + base["supersedes_identity_row_id"] = "id-cycle" + rows.append( + { + **base, + "identity_row_id": "id-cycle", + "source_ref": "fixture://identity/cycle", + "source_published_at": "2020-02-01T00:00:00Z", + "retrieved_at": "2020-02-02T00:00:00Z", + "supersedes_identity_row_id": "id-1", + } + ) + + _rewrite_csv_and_manifest(manifest, "security_identity", mutate) + + +def _mutate_exclusion_case(manifest, case): + if case == "schema_columns": + identity = manifest.parent / "identity.csv" + lines = identity.read_text(encoding="utf-8").splitlines() + lines[1] = ",".join(lines[1].split(",")[:7]) + identity.write_text("\n".join(lines) + "\n", encoding="utf-8") + _refresh_contract_digest(manifest, "security_identity") + elif case == "schema_required": + _rewrite_csv_and_manifest( + manifest, + "security_identity", + lambda rows: rows[0].update(ticker=""), + ) + elif case == "schema_whitespace": + _rewrite_csv_and_manifest( + manifest, + "security_identity", + lambda rows: rows[0].update(security_id=" sec-1 "), + ) + elif case == "schema_timestamp": + _rewrite_csv_and_manifest( + manifest, + "security_identity", + lambda rows: rows[0].update(valid_from="not-a-timestamp"), + ) + elif case == "schema_enum": + _rewrite_csv_and_manifest( + manifest, + "membership", + lambda rows: rows[0].update(membership_state="maybe"), + ) + elif case == "schema_ratio": + _rewrite_csv_and_manifest( + manifest, + "events", + lambda rows: rows[0].update( + ratio_numerator="nan", + ratio_denominator="1", + ), + ) + elif case == "schema_ratio_pair": + _rewrite_csv_and_manifest( + manifest, + "events", + lambda rows: rows[0].update( + event_type="split", + ratio_numerator="", + ratio_denominator="", + ), + ) + elif case == "schema_delisting_state": + _rewrite_csv_and_manifest( + manifest, + "events", + lambda rows: rows[0].update( + event_type="delisting", + listing_state_after="active", + ), + ) + elif case == "schema_identity_interval": + _rewrite_csv_and_manifest( + manifest, + "security_identity", + lambda rows: rows[0].update( + valid_from="2020-06-01T00:00:00Z", + valid_to="2020-05-01T00:00:00Z", + ), + ) + elif case == "schema_identity_issuer_matches_security": + _rewrite_csv_and_manifest( + manifest, + "security_identity", + lambda rows: rows[0].update( + issuer_id=rows[0]["security_id"], + ), + ) + elif case == "schema_membership_interval": + _rewrite_csv_and_manifest( + manifest, + "membership", + lambda rows: rows[0].update( + effective_from="2020-06-01T00:00:00Z", + effective_to="2020-05-01T00:00:00Z", + ), + ) + elif case == "membership_snapshot_omission": + _rewrite_csv_and_manifest( + manifest, + "membership", + lambda rows: rows.append( + { + **rows[0], + "membership_row_id": "member-bench-later-sec-2", + "security_id": "sec-2", + "effective_from": "2020-06-01T00:00:00Z", + "observation_at": "2020-06-01T00:00:00Z", + "source_ref": "fixture://membership/later-sec-2", + "source_published_at": "2020-06-01T00:00:00Z", + "retrieved_at": "2020-06-02T00:00:00Z", + } + ), + ) + elif case == "schema_event_duplicate": + _rewrite_csv_and_manifest( + manifest, + "events", + lambda rows: rows.append( + { + **rows[0], + "source_ref": "fixture://event/duplicate", + } + ), + ) + elif case == "schema_evaluation_duplicate": + _rewrite_csv_and_manifest( + manifest, + "evaluations", + lambda rows: rows[1].update( + evaluation_row_id=rows[0]["evaluation_row_id"], + ), + ) + elif case == "evaluation_undeclared": + def add_undeclared_evaluation(rows): + rows.append( + { + **rows[0], + "evaluation_row_id": "eval-undeclared", + "universe_id": "ghost", + "source_ref": "fixture://evaluation/undeclared", + } + ) + + _rewrite_csv_and_manifest( + manifest, + "evaluations", + add_undeclared_evaluation, + ) + elif case.startswith("lineage_"): + _mutate_identity_lineage_exclusion(manifest, case) + elif case == "identity_interval_overlap": + mutate_identity_membership_case(manifest, "overlapping_identity") + elif case == "identity_missing": + mutate_identity_membership_case(manifest, "missing_identity") + elif case == "identity_security_reuse": + def append_reused_security(rows): + prior = rows[0] + prior["valid_to"] = "2020-06-01T00:00:00Z" + rows.append( + { + **prior, + "identity_row_id": "id-reused-security", + "issuer_id": "issuer-reused", + "valid_from": "2020-06-01T00:00:00Z", + "valid_to": "", + "source_ref": "fixture://identity/reused-security", + "source_published_at": "2020-06-01T00:00:00Z", + "retrieved_at": "2020-06-02T00:00:00Z", + "supersedes_identity_row_id": ( + prior["identity_row_id"] + ), + } + ) + + _rewrite_csv_and_manifest( + manifest, + "security_identity", + append_reused_security, + ) + elif case == "membership_interval_inactive": + mutate_identity_membership_case( + manifest, + "membership_outside_interval", + ) + elif case == "corporate_action_unsupported": + _rewrite_manifest( + manifest, + lambda raw: raw["corporate_action_policy"].update( + listing="unsupported", + ), + ) + elif case == "corporate_action_successor": + _rewrite_csv_and_manifest( + manifest, + "events", + lambda rows: rows[0].update( + event_type="merger", + successor_security_id="", + listing_state_after="", + ), + ) + _rewrite_manifest( + manifest, + lambda raw: raw["corporate_action_policy"].update( + {"listing": "not_applicable", "merger": "required"} + ), + ) + elif case == "delisting_transition": + _rewrite_csv_and_manifest( + manifest, + "events", + lambda rows: rows[0].update( + event_type="suspension", + listing_state_after="active", + ), + ) + _rewrite_manifest( + manifest, + lambda raw: raw["corporate_action_policy"].update( + {"listing": "not_applicable", "suspension": "required"} + ), + ) + elif case == "cutoff_after_manifest": + def add_evaluation(rows): + rows.append( + { + **rows[-1], + "evaluation_row_id": "eval-after-manifest", + "evaluation_at": "2022-01-01T00:00:00Z", + "available_at": "2022-01-01T00:00:00Z", + "source_ref": "fixture://evaluation/after-manifest", + } + ) + + _rewrite_csv_and_manifest(manifest, "evaluations", add_evaluation) + elif case == "cutoff_evaluation_unavailable": + _rewrite_csv_and_manifest( + manifest, + "evaluations", + lambda rows: rows[0].update( + available_at="2022-01-01T00:00:00Z", + ), + ) + elif case == "cutoff_required_scope": + _rewrite_csv_and_manifest( + manifest, + "membership", + lambda rows: rows[0].update( + retrieved_at="2022-01-01T00:00:00Z", + ), + ) + elif case == "cutoff_later_revision": + def add_revision(rows): + rows.append( + { + **rows[0], + "membership_row_id": "member-later", + "source_ref": "fixture://membership/later", + "source_published_at": "2022-01-01T00:00:00Z", + "retrieved_at": "2022-01-02T00:00:00Z", + "supersedes_membership_row_id": rows[0][ + "membership_row_id" + ], + } + ) + + _rewrite_csv_and_manifest(manifest, "membership", add_revision) + elif case == "cutoff_unrelated_scope": + def add_unrelated(rows): + rows.append( + { + **rows[0], + "identity_row_id": "id-unrelated", + "security_id": "sec-unrelated", + "issuer_id": "issuer-unrelated", + "ticker": "ZZZ", + "source_ref": "fixture://identity/unrelated", + "source_published_at": "2022-01-01T00:00:00Z", + "retrieved_at": "2022-01-02T00:00:00Z", + "supersedes_identity_row_id": "", + } + ) + + _rewrite_csv_and_manifest( + manifest, + "security_identity", + add_unrelated, + ) + elif case == "partition_assignment": + _rewrite_csv_and_manifest( + manifest, + "evaluations", + lambda rows: [ + row.update(partition="test") + for row in rows + ], + ) + elif case == "partition_minimum_history": + _rewrite_csv_and_manifest( + manifest, + "evaluations", + lambda rows: [ + row.update(partition="walk_forward") + for row in rows + ], + ) + _rewrite_manifest( + manifest, + lambda raw: raw.update( + evaluation_policy={ + "kind": "walk_forward", + "minimum_history_count": 2, + } + ), + ) + elif case == "partition_boundary": + _rewrite_manifest( + manifest, + lambda raw: raw.update( + evaluation_policy={ + "kind": "train_validation_test", + "train_end_at": "2020-06-01T00:00:00Z", + "validation_start_at": "2020-08-01T00:00:00Z", + "validation_end_at": "2020-09-01T00:00:00Z", + "test_start_at": "2020-10-01T00:00:00Z", + } + ), + ) + _rewrite_csv_and_manifest( + manifest, + "evaluations", + lambda rows: [ + row.update( + evaluation_at="2020-07-01T00:00:00Z", + available_at="2020-07-01T00:00:00Z", + partition="train", + ) + for row in rows + ], + ) + + +def mutate_package_for_empty_case(manifest, mutation): + memberships = _read_contract_rows(manifest, "membership") + evaluations = _read_contract_rows(manifest, "evaluations") + raw = json.loads(manifest.read_text()) + if mutation == "no_evaluations": + _replace_contract_rows(manifest, "evaluations", []) + return + if mutation == "benchmark_only": + memberships = [ + row + for row in memberships + if row["universe_kind"] == "benchmark" + ] + evaluations = [ + row + for row in evaluations + if row["universe_id"] == "bench-1" + ] + raw["declared_universes"] = [ + item + for item in raw["declared_universes"] + if item["universe_kind"] == "benchmark" + ] + elif mutation == "research_only": + memberships = [ + row + for row in memberships + if row["universe_kind"] == "research_universe" + ] + evaluations = [ + row + for row in evaluations + if row["universe_id"] == "research-1" + ] + raw["declared_universes"] = [ + item + for item in raw["declared_universes"] + if item["universe_kind"] == "research_universe" + ] + elif mutation == "all_excluded": + for row in memberships: + row["membership_state"] = "excluded" + manifest.write_text(json.dumps(raw), encoding="utf-8") + _replace_contract_rows(manifest, "membership", memberships) + _replace_contract_rows(manifest, "evaluations", evaluations) + + +def _append_registry_source( + registry, + source_id, + supported_fields, + *, + commercial_use="approved", +): + fields = ", ".join(supported_fields) + registry.write_text( + registry.read_text() + + ( + f" - source_id: {source_id}\n" + f" display_name: {source_id}\n" + " permitted_use: test_only\n" + f" commercial_use: {commercial_use}\n" + " redistribution: test_only\n" + " storage_limits: pytest temporary directory only\n" + " attribution: synthetic fixture\n" + " rate_limits: not_applicable\n" + " authentication: none\n" + " expected_freshness: point in time\n" + f" supported_fields: [{fields}]\n" + " fallback_priority: 2\n" + ), + encoding="utf-8", + ) + + +def test_ticker_change_preserves_security_identity_without_current_ticker_fallback( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + def mutate(rows): + prior = dict(rows[0]) + rows[0]["valid_to"] = "2020-06-01T00:00:00Z" + rows.append( + { + **prior, + "identity_row_id": "id-2", + "ticker": "BBB", + "valid_from": "2020-06-01T00:00:00Z", + "valid_to": "", + "source_ref": "fixture://identity/id-2", + "source_published_at": "2020-06-01T00:00:00Z", + "retrieved_at": "2020-06-02T00:00:00Z", + "supersedes_identity_row_id": "id-1", + } + ) + + _rewrite_csv_and_manifest(manifest, "security_identity", mutate) + + packet = validate_point_in_time_universe(manifest, registry) + + assert packet.decisions["identity_coverage"].status == "passed" + assert packet.display_tickers == {"sec-1": "BBB"} + + +def test_display_ticker_does_not_change_stable_id_membership_digest(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + before = validate_point_in_time_universe(manifest, registry) + + _rewrite_csv_and_manifest( + manifest, + "security_identity", + lambda rows: rows[0].update(ticker="RENAMED"), + ) + after = validate_point_in_time_universe(manifest, registry) + + assert before.membership_digests == after.membership_digests + assert before.display_tickers == {"sec-1": "AAA"} + assert after.display_tickers == {"sec-1": "RENAMED"} + + +def test_same_ticker_for_two_security_ids_does_not_merge_membership(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + def mutate(rows): + rows.append( + { + **rows[0], + "identity_row_id": "id-2", + "security_id": "sec-2", + "issuer_id": "issuer-2", + "source_ref": "fixture://identity/id-2", + } + ) + + _rewrite_csv_and_manifest(manifest, "security_identity", mutate) + + packet = validate_point_in_time_universe(manifest, registry) + + assert all(digest.member_count == 1 for digest in packet.membership_digests) + assert packet.display_tickers == {"sec-1": "AAA"} + + +@pytest.mark.parametrize( + "case,reason", + [ + ("overlapping_identity", "identity_interval_overlap"), + ("missing_identity", "identity_missing"), + ("membership_outside_interval", "membership_interval_inactive"), + ("undeclared_universe", "membership_universe_undeclared"), + ("kind_mismatch", "membership_universe_kind_mismatch"), + ], +) +def test_identity_and_membership_fail_closed(tmp_path, case, reason): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + mutate_identity_membership_case(manifest, case) + + packet = validate_point_in_time_universe(manifest, registry) + + assert reason in { + code + for decision in packet.decisions.values() + for code in decision.reason_codes + } + assert packet.analysis_eligible is False + + +@pytest.mark.parametrize( + "case,reason", + [ + ("undeclared_universe", "membership_universe_undeclared"), + ("kind_mismatch", "membership_universe_kind_mismatch"), + ], +) +def test_invalid_membership_row_has_exact_complete_exclusion( + tmp_path, + case, + reason, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + baseline = validate_point_in_time_universe(manifest, registry) + baseline_research_digest = _digest_by_universe(baseline)["research-1"] + mutate_identity_membership_case(manifest, case) + + packet = validate_point_in_time_universe(manifest, registry) + membership_exclusions = tuple( + row + for row in packet.excluded + if row.contract == "membership" + ) + + assert _decision(packet, "membership_coverage").status == "blocked" + assert reason in _decision( + packet, + "membership_coverage", + ).reason_codes + assert len(membership_exclusions) == 1 + offender = membership_exclusions[0] + assert ( + offender.contract, + offender.source_row, + offender.row_id, + offender.reason_codes, + ) == ( + "membership", + 2, + "member-bench-1", + (reason,), + ) + assert packet.excluded_count == 1 + assert dict(packet.exclusion_reason_counts) == {reason: 1} + assert all( + row.row_id != "member-research-1" + for row in packet.excluded + ) + assert ( + _digest_by_universe(packet)["research-1"] + == baseline_research_digest + ) + assert packet.analysis_eligible_rows == () + + +@pytest.mark.parametrize( + "case", + ["future_kind_mismatch", "future_undeclared_universe"], +) +def test_future_invalid_membership_is_only_cutoff_invisible( + tmp_path, + case, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + baseline = validate_point_in_time_universe(manifest, registry) + + def add_future_invalid_row(rows): + rows.append( + { + **rows[0], + "membership_row_id": "member-future-invalid", + "universe_id": ( + "future-undeclared" + if case == "future_undeclared_universe" + else "bench-1" + ), + "universe_kind": ( + "benchmark" + if case == "future_undeclared_universe" + else "research_universe" + ), + "security_id": "sec-future-invalid", + "effective_from": "2022-01-01T00:00:00Z", + "observation_at": "2022-01-01T00:00:00Z", + "source_ref": f"fixture://membership/{case}", + "source_published_at": "2022-01-01T00:00:00Z", + "retrieved_at": "2022-01-02T00:00:00Z", + } + ) + + _rewrite_csv_and_manifest( + manifest, + "membership", + add_future_invalid_row, + ) + + packet = validate_point_in_time_universe(manifest, registry) + future_exclusion = next( + row + for row in packet.excluded + if row.row_id == "member-future-invalid" + ) + + assert _decision(packet, "membership_coverage").status == "passed" + assert _decision(packet, "membership_coverage").reason_codes == () + assert packet.analysis_eligible is True + assert packet.membership_digests == baseline.membership_digests + assert packet.display_tickers == baseline.display_tickers + assert packet.analysis_eligible_rows == baseline.analysis_eligible_rows + assert ( + future_exclusion.contract, + future_exclusion.source_row, + future_exclusion.reason_codes, + ) == ( + "membership", + 4, + ("cutoff_unrelated_scope_invisible",), + ) + assert packet.excluded_count == 1 + assert dict(packet.exclusion_reason_counts) == { + "cutoff_unrelated_scope_invisible": 1, + } + + +def test_future_membership_kind_mismatch_applies_when_later_evaluation_sees_it( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + def add_future_mismatch(rows): + rows.append( + { + **rows[0], + "membership_row_id": "member-future-visible", + "universe_kind": "research_universe", + "security_id": "sec-future-visible", + "effective_from": "2022-01-01T00:00:00Z", + "observation_at": "2022-01-01T00:00:00Z", + "source_ref": "fixture://membership/future-visible", + "source_published_at": "2022-01-01T00:00:00Z", + "retrieved_at": "2022-01-02T00:00:00Z", + } + ) + + def add_later_evaluation(rows): + rows.append( + { + **rows[0], + "evaluation_row_id": "eval-bench-later", + "evaluation_at": "2023-01-01T00:00:00Z", + "available_at": "2023-01-01T00:00:00Z", + "partition": "test", + "source_ref": "fixture://evaluation/bench-later", + } + ) + + _rewrite_csv_and_manifest( + manifest, + "membership", + add_future_mismatch, + ) + _rewrite_csv_and_manifest( + manifest, + "evaluations", + add_later_evaluation, + ) + _rewrite_manifest( + manifest, + lambda raw: raw.update( + observation_cutoff_at="2023-01-01T00:00:00Z", + manifest_created_at="2023-01-02T00:00:00Z", + ), + ) + + packet = validate_point_in_time_universe(manifest, registry) + offender = next( + row + for row in packet.excluded + if row.row_id == "member-future-visible" + ) + + assert _decision(packet, "membership_coverage").status == "blocked" + assert "membership_universe_kind_mismatch" in offender.reason_codes + assert ( + packet.exclusion_reason_counts[ + "membership_universe_kind_mismatch" + ] + == 1 + ) + + +def test_future_only_membership_cannot_promote_current_membership_coverage( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _replace_contract_rows( + manifest, + "membership", + [ + row + for row in _read_contract_rows(manifest, "membership") + if row["universe_id"] != "bench-1" + ], + ) + baseline = validate_point_in_time_universe(manifest, registry) + + def add_future_benchmark_membership(rows): + rows.append( + { + **rows[0], + "membership_row_id": "member-bench-future-only", + "universe_id": "bench-1", + "universe_kind": "benchmark", + "effective_from": "2022-01-01T00:00:00Z", + "observation_at": "2022-01-01T00:00:00Z", + "source_ref": "fixture://membership/bench-future-only", + "source_published_at": "2022-01-01T00:00:00Z", + "retrieved_at": "2022-01-02T00:00:00Z", + } + ) + + _rewrite_csv_and_manifest( + manifest, + "membership", + add_future_benchmark_membership, + ) + with_future = validate_point_in_time_universe(manifest, registry) + future_row = next( + row + for row in with_future.excluded + if row.row_id == "member-bench-future-only" + ) + + assert _decision( + baseline, + "membership_coverage", + ) == _decision(with_future, "membership_coverage") + assert _decision( + with_future, + "membership_coverage", + ).reason_codes == ("membership_no_eligible_members",) + assert with_future.membership_digests == baseline.membership_digests + assert with_future.display_tickers == baseline.display_tickers + assert ( + with_future.analysis_eligible_rows + == baseline.analysis_eligible_rows + ) + assert tuple( + row + for row in with_future.excluded + if row.row_id != "member-bench-future-only" + ) == baseline.excluded + assert ( + future_row.contract, + future_row.source_row, + future_row.reason_codes, + ) == ( + "membership", + 3, + ( + "cutoff_post_evaluation_evidence", + "cutoff_required_scope_unavailable", + "leakage_post_cutoff_evidence", + ), + ) + + +def test_future_only_identity_cannot_promote_current_identity_coverage( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _rewrite_csv_and_manifest( + manifest, + "membership", + lambda rows: rows[0].update(security_id="sec-future-only"), + ) + baseline = validate_point_in_time_universe(manifest, registry) + + def add_future_identity(rows): + rows.append( + { + **rows[0], + "identity_row_id": "id-future-only", + "security_id": "sec-future-only", + "issuer_id": "issuer-future-only", + "ticker": "FUTURE", + "valid_from": "2022-01-01T00:00:00Z", + "source_ref": "fixture://identity/future-only", + "source_published_at": "2022-01-01T00:00:00Z", + "retrieved_at": "2022-01-02T00:00:00Z", + } + ) + + _rewrite_csv_and_manifest( + manifest, + "security_identity", + add_future_identity, + ) + with_future = validate_point_in_time_universe(manifest, registry) + future_row = next( + row + for row in with_future.excluded + if row.row_id == "id-future-only" + ) + + assert _decision( + baseline, + "identity_coverage", + ) == _decision(with_future, "identity_coverage") + assert _decision( + with_future, + "identity_coverage", + ).reason_codes == ("identity_missing",) + assert with_future.membership_digests == baseline.membership_digests + assert with_future.display_tickers == baseline.display_tickers + assert ( + with_future.analysis_eligible_rows + == baseline.analysis_eligible_rows + ) + assert tuple( + row + for row in with_future.excluded + if row.row_id != "id-future-only" + ) == baseline.excluded + assert ( + future_row.contract, + future_row.source_row, + future_row.reason_codes, + ) == ( + "security_identity", + 3, + ( + "cutoff_post_evaluation_evidence", + "cutoff_required_scope_unavailable", + "cutoff_unrelated_scope_invisible", + "leakage_post_cutoff_evidence", + ), + ) + + +def test_future_only_required_event_cannot_promote_current_event_coverage( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _replace_contract_rows(manifest, "events", []) + baseline = validate_point_in_time_universe(manifest, registry) + + future_event = { + "event_row_id": "event-future-only", + "security_id": "sec-1", + "event_type": "listing", + "effective_at": "2022-01-01T00:00:00Z", + "successor_security_id": "", + "ratio_numerator": "", + "ratio_denominator": "", + "listing_state_after": "active", + "source_id": "fixture_source", + "source_ref": "fixture://event/future-only", + "source_published_at": "2022-01-01T00:00:00Z", + "retrieved_at": "2022-01-02T00:00:00Z", + "supersedes_event_row_id": "", + } + _replace_contract_rows(manifest, "events", [future_event]) + with_future = validate_point_in_time_universe(manifest, registry) + future_row = next( + row + for row in with_future.excluded + if row.row_id == "event-future-only" + ) + + assert _decision( + baseline, + "corporate_action_coverage", + ) == _decision(with_future, "corporate_action_coverage") + assert _decision( + with_future, + "corporate_action_coverage", + ).reason_codes == ("corporate_action_evidence_missing",) + assert with_future.membership_digests == baseline.membership_digests + assert ( + with_future.analysis_eligible_rows + == baseline.analysis_eligible_rows + ) + assert tuple( + row + for row in with_future.excluded + if row.row_id != "event-future-only" + ) == baseline.excluded + assert ( + future_row.contract, + future_row.source_row, + future_row.reason_codes, + ) == ( + "events", + 2, + ("cutoff_unrelated_scope_invisible",), + ) + + +def test_membership_row_exclusion_unions_kind_and_lineage_reasons(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + def make_kind_and_lineage_invalid(rows): + rows[0].update( + universe_kind="research_universe", + supersedes_membership_row_id="member-missing-parent", + ) + + _rewrite_csv_and_manifest( + manifest, + "membership", + make_kind_and_lineage_invalid, + ) + + packet = validate_point_in_time_universe(manifest, registry) + offender = next( + row + for row in packet.excluded + if ( + row.contract == "membership" + and row.row_id == "member-bench-1" + ) + ) + + assert ( + offender.source_row, + offender.reason_codes, + ) == ( + 2, + ( + "lineage_missing_parent", + "membership_universe_kind_mismatch", + ), + ) + assert dict(packet.exclusion_reason_counts) == { + "lineage_missing_parent": 1, + "membership_universe_kind_mismatch": 1, + } + assert packet.excluded_count == 1 + + +def mutate_identity_membership_case(manifest, case): + if case == "overlapping_identity": + + def mutate(rows): + rows.append( + { + **rows[0], + "identity_row_id": "id-overlap", + "issuer_id": "issuer-overlap", + "source_ref": "fixture://identity/id-overlap", + "source_published_at": "2020-02-01T00:00:00Z", + "retrieved_at": "2020-02-02T00:00:00Z", + "supersedes_identity_row_id": "", + } + ) + + _rewrite_csv_and_manifest(manifest, "security_identity", mutate) + elif case == "missing_identity": + _rewrite_csv_and_manifest( + manifest, + "membership", + lambda rows: rows[0].update(security_id="sec-missing"), + ) + elif case == "membership_outside_interval": + _rewrite_csv_and_manifest( + manifest, + "membership", + lambda rows: rows[0].update(effective_to="2020-06-01T00:00:00Z"), + ) + elif case == "undeclared_universe": + _rewrite_csv_and_manifest( + manifest, + "membership", + lambda rows: rows[0].update(universe_id="unknown"), + ) + elif case == "kind_mismatch": + _rewrite_csv_and_manifest( + manifest, + "membership", + lambda rows: rows[0].update(universe_kind="research_universe"), + ) + + +@pytest.mark.parametrize( + "case,expected_reason", + [ + ("membership_security_drift", "lineage_cross_scope_parent"), + ("membership_duplicate_across_scopes", "lineage_duplicate_id"), + ("membership_fork_across_scopes", "lineage_fork"), + ("identity_security_drift", "lineage_cross_scope_parent"), + ("identity_duplicate_across_scopes", "lineage_duplicate_id"), + ("identity_fork_across_scopes", "lineage_fork"), + ("corrupt_non_member_identity", "lineage_missing_parent"), + ], +) +def test_integrated_lineage_validation_precedes_scope_grouping( + tmp_path, + case, + expected_reason, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + mutate_integrated_lineage_case(manifest, case) + + packet = validate_point_in_time_universe(manifest, registry) + + area = ( + "membership_coverage" + if case.startswith("membership_") + else "identity_coverage" + ) + assert expected_reason in _decision(packet, area).reason_codes + assert _decision(packet, area).status == "blocked" + assert packet.analysis_eligible is False + + +def mutate_integrated_lineage_case(manifest, case): + if case.startswith("membership_"): + + def mutate(rows): + if case == "membership_security_drift": + rows.append( + { + **rows[0], + "membership_row_id": "member-drift", + "security_id": "sec-2", + "source_ref": "fixture://membership/member-drift", + "source_published_at": "2020-02-01T00:00:00Z", + "retrieved_at": "2020-02-02T00:00:00Z", + "supersedes_membership_row_id": rows[0][ + "membership_row_id" + ], + } + ) + elif case == "membership_duplicate_across_scopes": + rows[1]["membership_row_id"] = rows[0][ + "membership_row_id" + ] + elif case == "membership_fork_across_scopes": + rows.extend( + [ + { + **rows[0], + "membership_row_id": "member-child-1", + "source_ref": ( + "fixture://membership/member-child-1" + ), + "source_published_at": ( + "2020-02-01T00:00:00Z" + ), + "retrieved_at": "2020-02-02T00:00:00Z", + "supersedes_membership_row_id": rows[0][ + "membership_row_id" + ], + }, + { + **rows[0], + "membership_row_id": "member-child-2", + "security_id": "sec-2", + "source_ref": ( + "fixture://membership/member-child-2" + ), + "source_published_at": ( + "2020-03-01T00:00:00Z" + ), + "retrieved_at": "2020-03-02T00:00:00Z", + "supersedes_membership_row_id": rows[0][ + "membership_row_id" + ], + }, + ] + ) + + _rewrite_csv_and_manifest(manifest, "membership", mutate) + return + + def mutate(rows): + if case == "identity_security_drift": + rows.append( + { + **rows[0], + "identity_row_id": "id-security-drift", + "security_id": "sec-2", + "source_ref": "fixture://identity/id-security-drift", + "source_published_at": "2020-02-01T00:00:00Z", + "retrieved_at": "2020-02-02T00:00:00Z", + "supersedes_identity_row_id": "id-1", + } + ) + elif case == "identity_duplicate_across_scopes": + rows.append( + { + **rows[0], + "security_id": "sec-2", + "issuer_id": "issuer-2", + "source_ref": "fixture://identity/id-duplicate", + } + ) + elif case == "identity_fork_across_scopes": + rows.extend( + [ + { + **rows[0], + "identity_row_id": "id-child-1", + "source_ref": "fixture://identity/id-child-1", + "source_published_at": "2020-02-01T00:00:00Z", + "retrieved_at": "2020-02-02T00:00:00Z", + "supersedes_identity_row_id": "id-1", + }, + { + **rows[0], + "identity_row_id": "id-child-2", + "security_id": "sec-2", + "issuer_id": "issuer-2", + "source_ref": "fixture://identity/id-child-2", + "source_published_at": "2020-03-01T00:00:00Z", + "retrieved_at": "2020-03-02T00:00:00Z", + "supersedes_identity_row_id": "id-1", + }, + ] + ) + elif case == "corrupt_non_member_identity": + rows.append( + { + **rows[0], + "identity_row_id": "id-orphan", + "security_id": "sec-2", + "issuer_id": "issuer-2", + "source_ref": "fixture://identity/id-orphan", + "supersedes_identity_row_id": "missing", + } + ) + + _rewrite_csv_and_manifest(manifest, "security_identity", mutate) + + +@pytest.mark.parametrize( + "case,identity_reason", + [ + ("overlapping_identity", "identity_interval_overlap"), + ("missing_identity", "identity_missing"), + ], +) +def test_identity_failure_preserves_membership_decision_but_removes_digest_member( + tmp_path, + case, + identity_reason, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + mutate_identity_membership_case(manifest, case) + + packet = validate_point_in_time_universe(manifest, registry) + digests = _digest_by_universe(packet) + + assert _decision(packet, "identity_coverage").status == "blocked" + assert identity_reason in _decision( + packet, + "identity_coverage", + ).reason_codes + assert _decision(packet, "membership_coverage").status == "passed" + assert _decision(packet, "membership_coverage").reason_codes == () + assert digests["bench-1"].member_count == 0 + assert digests["bench-1"].sha256 == _sha256_members() + expected_research_members = () if case == "overlapping_identity" else ("sec-1",) + assert digests["research-1"].member_count == len( + expected_research_members + ) + assert digests["research-1"].sha256 == _sha256_members( + *expected_research_members + ) + + +@pytest.mark.parametrize( + "case", + [ + "lineage_missing_parent", + "lineage_fork", + "lineage_cycle", + ], +) +def test_identity_lineage_failure_removes_all_members_from_reproduction( + tmp_path, + case, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _mutate_identity_lineage_exclusion(manifest, case) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "identity_coverage").status == "blocked" + assert packet.membership_digests + assert all( + digest.member_count == 0 + and digest.sha256 == _sha256_members() + for digest in packet.membership_digests + ) + + +def test_visible_inactive_identity_leaf_cannot_fall_back_to_superseded_root( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + def add_visible_future_leaf(rows): + rows.append( + { + **rows[0], + "identity_row_id": "id-future-leaf", + "ticker": "FUTURE", + "valid_from": "2022-01-01T00:00:00Z", + "source_ref": "fixture://identity/id-future-leaf", + "source_published_at": "2020-02-01T00:00:00Z", + "retrieved_at": "2020-02-02T00:00:00Z", + "supersedes_identity_row_id": "id-1", + } + ) + + _rewrite_csv_and_manifest( + manifest, + "security_identity", + add_visible_future_leaf, + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "identity_coverage").status == "blocked" + assert _decision(packet, "identity_coverage").reason_codes == ( + "identity_missing", + ) + assert _decision(packet, "membership_coverage").status == "passed" + assert _decision(packet, "reproduction_ready").status == "passed" + assert all( + digest.member_count == 0 + and digest.sha256 == _sha256_members() + for digest in packet.membership_digests + ) + assert packet.display_tickers == {} + assert packet.analysis_eligible is False + assert packet.analysis_eligible_rows == () + assert ( + "security_identity", + 3, + "id-future-leaf", + ("identity_missing",), + ) in { + ( + row.contract, + row.source_row, + row.row_id, + row.reason_codes, + ) + for row in packet.excluded + } + + +def test_active_identity_leaf_is_used_without_superseded_root_reference( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + def add_active_leaf(rows): + rows[0]["valid_to"] = "2020-06-01T00:00:00Z" + rows.append( + { + **rows[0], + "identity_row_id": "id-active-leaf", + "valid_from": "2020-06-01T00:00:00Z", + "valid_to": "", + "source_ref": "fixture://identity/id-active-leaf", + "source_published_at": "2020-06-01T00:00:00Z", + "retrieved_at": "2020-06-02T00:00:00Z", + "supersedes_identity_row_id": "id-1", + } + ) + + _rewrite_csv_and_manifest( + manifest, + "security_identity", + add_active_leaf, + ) + + packet = validate_point_in_time_universe(manifest, registry) + eligible_identity_ids = { + row.row_id + for row in packet.analysis_eligible_rows + if row.contract == "security_identity" + } + + assert packet.analysis_eligible is True + assert packet.display_tickers == {"sec-1": "AAA"} + assert eligible_identity_ids == {"id-active-leaf"} + + +def test_lineage_duplicate_membership_excludes_each_physical_source_row( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + def duplicate_benchmark_member(rows): + rows.append( + { + **rows[0], + "source_ref": "fixture://membership/duplicate", + } + ) + + _rewrite_csv_and_manifest( + manifest, + "membership", + duplicate_benchmark_member, + ) + + packet = validate_point_in_time_universe(manifest, registry) + duplicate_rows = tuple( + row + for row in packet.excluded + if ( + row.contract == "membership" + and row.row_id == "member-bench-1" + ) + ) + + assert { + row.source_row for row in duplicate_rows + } == {2, 4} + assert all( + row.reason_codes + == ("lineage_duplicate_id", "lineage_multiple_roots") + for row in duplicate_rows + ) + assert packet.exclusion_reason_counts["lineage_duplicate_id"] == 2 + assert packet.exclusion_reason_counts["lineage_multiple_roots"] == 2 + assert { + (row.source_row, row.row_id) + for row in packet.normalized_rows + if row.contract == "membership" + } >= {(2, "member-bench-1"), (4, "member-bench-1")} + + +@pytest.mark.parametrize( + "case,expected_reason,expected_source_rows", + [ + ("lineage_duplicate", "lineage_duplicate_id", {2, 3}), + ("lineage_missing_parent", "lineage_missing_parent", {2}), + ("lineage_fork", "lineage_fork", {2, 3, 4}), + ("lineage_cycle", "lineage_cycle", {2, 3}), + ], +) +def test_invalid_identity_lineage_excludes_exact_physical_source_rows( + tmp_path, + case, + expected_reason, + expected_source_rows, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _mutate_identity_lineage_exclusion(manifest, case) + + packet = validate_point_in_time_universe(manifest, registry) + identity_exclusions = tuple( + row + for row in packet.excluded + if ( + row.contract == "security_identity" + and expected_reason in row.reason_codes + ) + ) + + assert { + row.source_row for row in identity_exclusions + } == expected_source_rows + assert packet.exclusion_reason_counts[expected_reason] >= len( + expected_source_rows + ) + if case == "lineage_duplicate": + assert { + (row.source_row, row.row_id) + for row in packet.normalized_rows + if row.contract == "security_identity" + } >= {(2, "id-1"), (3, "id-1")} + + +def test_identity_overlap_excludes_each_overlapping_identity_source_row( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + mutate_identity_membership_case(manifest, "overlapping_identity") + + packet = validate_point_in_time_universe(manifest, registry) + identity_exclusions = tuple( + row + for row in packet.excluded + if ( + row.contract == "security_identity" + and "identity_interval_overlap" in row.reason_codes + ) + ) + + assert { + (row.source_row, row.row_id) + for row in identity_exclusions + } == {(2, "id-1"), (3, "id-overlap")} + assert ( + packet.exclusion_reason_counts["identity_interval_overlap"] + == 4 + ) + + +@pytest.mark.parametrize( + "contract,row_id,expected_rows", + [ + ("security_identity", "id-1", {2, 3}), + ("membership", "member-bench-1", {2, 4}), + ], +) +def test_mixed_duplicate_and_orphan_reasons_bind_exact_physical_rows( + tmp_path, + contract, + row_id, + expected_rows, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + def add_duplicate_orphan(rows): + parent_field = ( + "supersedes_identity_row_id" + if contract == "security_identity" + else "supersedes_membership_row_id" + ) + rows.append( + { + **rows[0], + "source_ref": f"fixture://{contract}/duplicate-orphan", + parent_field: "missing-parent", + } + ) + + _rewrite_csv_and_manifest(manifest, contract, add_duplicate_orphan) + + packet = validate_point_in_time_universe(manifest, registry) + exclusions = { + row.source_row: row.reason_codes + for row in packet.excluded + if row.contract == contract and row.row_id == row_id + } + + assert set(exclusions) == expected_rows + assert exclusions[min(expected_rows)] == ("lineage_duplicate_id",) + assert exclusions[max(expected_rows)] == ( + "lineage_duplicate_id", + "lineage_missing_parent", + ) + assert packet.exclusion_reason_counts["lineage_duplicate_id"] == 2 + assert packet.exclusion_reason_counts["lineage_missing_parent"] == 1 + + +def test_unrelated_identity_orphan_blocks_global_state_without_erasing_members( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + def add_unrelated_orphan(rows): + rows.append( + { + **rows[0], + "identity_row_id": "id-unrelated-orphan", + "security_id": "sec-2", + "issuer_id": "issuer-2", + "ticker": "UNRELATED", + "source_ref": "fixture://identity/unrelated-orphan", + "supersedes_identity_row_id": "missing-parent", + } + ) + + _rewrite_csv_and_manifest( + manifest, + "security_identity", + add_unrelated_orphan, + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "identity_coverage").status == "blocked" + assert _decision(packet, "identity_coverage").reason_codes == ( + "lineage_missing_parent", + ) + assert { + digest.universe_id: digest.member_count + for digest in packet.membership_digests + } == {"bench-1": 1, "research-1": 1} + assert not any( + row.contract == "membership" + and "lineage_missing_parent" in row.reason_codes + for row in packet.excluded + ) + assert { + (row.contract, row.source_row, row.row_id, row.reason_codes) + for row in packet.excluded + if "lineage_missing_parent" in row.reason_codes + } == { + ( + "security_identity", + 3, + "id-unrelated-orphan", + ("lineage_missing_parent",), + ) + } + assert packet.analysis_eligible is False + assert packet.analysis_eligible_rows == () + + +def test_unrelated_membership_orphan_preserves_other_universe_digest(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + def orphan_benchmark(rows): + rows[0]["supersedes_membership_row_id"] = "missing-parent" + + _rewrite_csv_and_manifest( + manifest, + "membership", + orphan_benchmark, + ) + + packet = validate_point_in_time_universe(manifest, registry) + digests = _digest_by_universe(packet) + + assert _decision(packet, "membership_coverage").status == "blocked" + assert digests["bench-1"].member_count == 0 + assert digests["research-1"].member_count == 1 + assert digests["research-1"].sha256 == _sha256_members("sec-1") + assert ( + "membership", + 2, + "member-bench-1", + ("lineage_missing_parent",), + ) in { + ( + row.contract, + row.source_row, + row.row_id, + row.reason_codes, + ) + for row in packet.excluded + } + assert not any( + row.contract == "membership" + and row.source_row == 3 + and "lineage_missing_parent" in row.reason_codes + for row in packet.excluded + ) + + +def test_event_valid_root_and_orphan_attribute_missing_parent_only_to_orphan( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + def add_orphan(rows): + rows.append( + { + **rows[0], + "event_row_id": "event-orphan", + "source_ref": "fixture://event/orphan", + "source_published_at": "2020-02-01T00:00:00Z", + "retrieved_at": "2020-02-02T00:00:00Z", + "supersedes_event_row_id": "missing-parent", + } + ) + + _rewrite_csv_and_manifest(manifest, "events", add_orphan) + + packet = validate_point_in_time_universe(manifest, registry) + event_exclusions = { + row.source_row: row.reason_codes + for row in packet.excluded + if row.contract == "events" + } + + assert event_exclusions == {3: ("lineage_missing_parent",)} + assert ( + packet.exclusion_reason_counts["lineage_missing_parent"] + == 1 + ) + assert "lineage_missing_parent" in _decision( + packet, + "corporate_action_coverage", + ).reason_codes + + +def test_event_roots_and_orphan_preserve_exact_independent_reason_unions( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + def add_second_root_and_orphan(rows): + rows.extend( + [ + { + **rows[0], + "event_row_id": "event-second-root", + "source_ref": "fixture://event/second-root", + }, + { + **rows[0], + "event_row_id": "event-orphan", + "source_ref": "fixture://event/orphan", + "supersedes_event_row_id": "missing-parent", + }, + ] + ) + + _rewrite_csv_and_manifest( + manifest, + "events", + add_second_root_and_orphan, + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert { + row.source_row: row.reason_codes + for row in packet.excluded + if row.contract == "events" + } == { + 2: ("lineage_multiple_roots",), + 3: ("lineage_multiple_roots",), + 4: ("lineage_missing_parent",), + } + assert packet.exclusion_reason_counts["lineage_multiple_roots"] == 2 + assert packet.exclusion_reason_counts["lineage_missing_parent"] == 1 + + +@pytest.mark.parametrize( + "contract,visible_first", + [ + ("security_identity", True), + ("security_identity", False), + ("membership", True), + ("membership", False), + ], +) +def test_visible_and_post_cutoff_same_id_rows_keep_exact_physical_provenance( + tmp_path, + contract, + visible_first, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + def add_post_cutoff_duplicate(rows): + visible = dict(rows[0]) + future = { + **visible, + "source_ref": f"fixture://{contract}/future-duplicate", + "source_published_at": "2022-01-01T00:00:00Z", + "retrieved_at": "2022-01-02T00:00:00Z", + } + if contract == "security_identity": + future["valid_from"] = "2022-01-01T00:00:00Z" + rows[:] = ( + [visible, future] + if visible_first + else [future, visible] + ) + else: + future["observation_at"] = "2022-01-01T00:00:00Z" + future["effective_from"] = "2022-01-01T00:00:00Z" + research = dict(rows[1]) + rows[:] = ( + [visible, research, future] + if visible_first + else [future, research, visible] + ) + + _rewrite_csv_and_manifest( + manifest, + contract, + add_post_cutoff_duplicate, + ) + + packet = validate_point_in_time_universe(manifest, registry) + row_id = ( + "id-1" + if contract == "security_identity" + else "member-bench-1" + ) + visible_source_row = ( + 2 + if visible_first + else 3 + if contract == "security_identity" + else 4 + ) + future_source_row = ( + 3 + if visible_first and contract == "security_identity" + else 4 + if visible_first + else 2 + ) + eligible_rows = { + row.source_row + for row in packet.analysis_eligible_rows + if ( + row.contract == contract + and row.row_id == row_id + and row.evaluation_row_id == "eval-bench-1" + ) + } + cutoff_exclusions = { + row.source_row + for row in packet.excluded + if ( + row.contract == contract + and row.row_id == row_id + and "cutoff_later_revision_invisible" in row.reason_codes + ) + } + + assert packet.analysis_eligible is True + assert eligible_rows == {visible_source_row} + assert cutoff_exclusions == {future_source_row} + assert eligible_rows.isdisjoint(cutoff_exclusions) + assert _digest_by_universe(packet)["bench-1"].sha256 == _sha256_members( + "sec-1" + ) + + +def test_colon_components_do_not_collapse_distinct_identity_scopes(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + def add_colliding_textual_scope(rows): + rows[0]["issuer_id"] = "issuer:1" + rows.append( + { + **rows[0], + "identity_row_id": "id-colon-peer", + "security_id": "sec-1:issuer", + "issuer_id": "1", + "ticker": "COLON", + "source_ref": "fixture://identity/colon-peer", + } + ) + + _rewrite_csv_and_manifest( + manifest, + "security_identity", + add_colliding_textual_scope, + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "identity_coverage").status == "passed" + assert all( + digest.member_count == 1 + for digest in packet.membership_digests + ) + assert packet.display_tickers == {"sec-1": "AAA"} + + +def test_colon_components_do_not_collapse_distinct_membership_scopes( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + def mutate_identities(rows): + rows[0]["security_id"] = "sec:1" + rows.append( + { + **rows[0], + "identity_row_id": "id-colon-research", + "security_id": "1", + "issuer_id": "issuer-colon-research", + "ticker": "ONE", + "source_ref": "fixture://identity/colon-research", + } + ) + + def mutate_memberships(rows): + rows[0]["security_id"] = "sec:1" + rows[1]["universe_id"] = "bench-1:sec" + rows[1]["security_id"] = "1" + + def mutate_events(rows): + rows[0]["security_id"] = "sec:1" + rows.append( + { + **rows[0], + "event_row_id": "event-colon-research", + "security_id": "1", + "source_ref": "fixture://event/colon-research", + } + ) + + def mutate_evaluations(rows): + rows[1]["universe_id"] = "bench-1:sec" + + _rewrite_csv_and_manifest( + manifest, + "security_identity", + mutate_identities, + ) + _rewrite_csv_and_manifest(manifest, "membership", mutate_memberships) + _rewrite_csv_and_manifest(manifest, "events", mutate_events) + _rewrite_csv_and_manifest( + manifest, + "evaluations", + mutate_evaluations, + ) + _rewrite_manifest( + manifest, + lambda raw: raw["declared_universes"][1].update( + universe_id="bench-1:sec", + ), + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "membership_coverage").status == "passed" + assert { + digest.universe_id: digest.member_count + for digest in packet.membership_digests + } == {"bench-1": 1, "bench-1:sec": 1} + assert packet.analysis_eligible is True + + +def test_colon_security_ids_remain_distinct_event_scopes(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + def add_colon_identity(rows): + rows.append( + { + **rows[0], + "identity_row_id": "id-colon-security", + "security_id": "sec:1", + "issuer_id": "issuer-colon-security", + "ticker": "COLON", + "source_ref": "fixture://identity/colon-security", + } + ) + + def add_colon_event(rows): + rows.append( + { + **rows[0], + "event_row_id": "event-colon-security", + "security_id": "sec:1", + "event_type": "delisting", + "listing_state_after": "delisted", + "source_ref": "fixture://event/colon-security", + } + ) + + _rewrite_csv_and_manifest( + manifest, + "security_identity", + add_colon_identity, + ) + _rewrite_csv_and_manifest(manifest, "events", add_colon_event) + + packet = validate_point_in_time_universe(manifest, registry) + + assert "lineage_multiple_roots" not in { + reason + for decision in packet.decisions.values() + for reason in decision.reason_codes + } + assert not any( + "lineage_multiple_roots" in row.reason_codes + for row in packet.excluded + ) + + +def test_invalid_membership_scopes_do_not_erase_unaffected_universe_digest( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + mutate_integrated_lineage_case(manifest, "membership_security_drift") + + packet = validate_point_in_time_universe(manifest, registry) + + assert "lineage_cross_scope_parent" in _decision( + packet, + "membership_coverage", + ).reason_codes + digests = _digest_by_universe(packet) + assert digests["bench-1"].member_count == 0 + assert digests["research-1"].member_count == 1 + assert digests["research-1"].sha256 == _sha256_members("sec-1") + + +def test_overlapping_non_member_identity_blocks_identity_coverage(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + def add_overlapping_non_member(rows): + root = { + **rows[0], + "identity_row_id": "id-non-member-root", + "security_id": "sec-2", + "issuer_id": "issuer-2", + "source_ref": "fixture://identity/id-non-member-root", + } + rows.extend( + [ + root, + { + **root, + "identity_row_id": "id-non-member-child", + "issuer_id": "issuer-3", + "source_ref": "fixture://identity/id-non-member-child", + "source_published_at": "2020-02-01T00:00:00Z", + "retrieved_at": "2020-02-02T00:00:00Z", + "supersedes_identity_row_id": "", + }, + ] + ) + + _rewrite_csv_and_manifest( + manifest, + "security_identity", + add_overlapping_non_member, + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert "identity_interval_overlap" in _decision( + packet, + "identity_coverage", + ).reason_codes + assert _decision(packet, "membership_coverage").status == "passed" + + +def test_same_ticker_membership_keeps_both_stable_security_ids(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + def add_identity(rows): + rows.append( + { + **rows[0], + "identity_row_id": "id-2", + "security_id": "sec-2", + "issuer_id": "issuer-2", + "source_ref": "fixture://identity/id-2", + } + ) + + def add_memberships(rows): + additions = [] + for row in rows: + additions.append( + { + **row, + "membership_row_id": f"{row['membership_row_id']}-sec-2", + "security_id": "sec-2", + "source_ref": ( + f"fixture://membership/{row['universe_id']}/sec-2" + ), + } + ) + rows.extend(additions) + + _rewrite_csv_and_manifest(manifest, "security_identity", add_identity) + _rewrite_csv_and_manifest(manifest, "membership", add_memberships) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "identity_coverage").status == "passed" + assert _decision(packet, "membership_coverage").status == "passed" + assert packet.display_tickers == {"sec-1": "AAA", "sec-2": "AAA"} + for digest in packet.membership_digests: + assert digest.member_count == 2 + assert digest.sha256 == _sha256_members("sec-1", "sec-2") + + +def test_excluded_membership_revision_has_exact_empty_digest(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + def exclude_benchmark(rows): + prior = next(row for row in rows if row["universe_id"] == "bench-1") + prior["effective_to"] = "2020-06-01T00:00:00Z" + rows.append( + { + **prior, + "membership_row_id": "member-bench-excluded", + "membership_state": "excluded", + "effective_from": "2020-06-01T00:00:00Z", + "effective_to": "", + "source_ref": "fixture://membership/bench-1/excluded", + "source_published_at": "2020-06-01T00:00:00Z", + "retrieved_at": "2020-06-02T00:00:00Z", + "supersedes_membership_row_id": prior["membership_row_id"], + } + ) + + _rewrite_csv_and_manifest(manifest, "membership", exclude_benchmark) + + packet = validate_point_in_time_universe(manifest, registry) + digests = _digest_by_universe(packet) + + assert digests["bench-1"].member_count == 0 + assert digests["bench-1"].sha256 == hashlib.sha256(b"").hexdigest() + assert digests["research-1"].member_count == 1 + assert digests["research-1"].sha256 == _sha256_members("sec-1") + assert "membership_no_eligible_members" in _decision( + packet, + "membership_coverage", + ).reason_codes + + +def test_each_declared_universe_requires_an_eligible_evaluation(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _rewrite_csv_and_manifest( + manifest, + "evaluations", + lambda rows: rows.__setitem__( + slice(None), + [row for row in rows if row["universe_id"] == "bench-1"], + ), + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "membership_coverage").status == "blocked" + assert "membership_no_evaluation" in _decision( + packet, + "membership_coverage", + ).reason_codes + + +def test_reversed_evaluation_rows_have_canonical_digests_and_latest_ticker( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifests = [] + for name, reverse in (("forward", False), ("reversed", True)): + root = tmp_path / name + root.mkdir() + manifest, registry = build_valid_package(root) + + def change_ticker(rows): + prior = dict(rows[0]) + rows[0]["valid_to"] = "2020-06-01T00:00:00Z" + rows.append( + { + **prior, + "identity_row_id": "id-2", + "ticker": "BBB", + "valid_from": "2020-06-01T00:00:00Z", + "valid_to": "", + "source_ref": "fixture://identity/id-2", + "source_published_at": "2020-06-01T00:00:00Z", + "retrieved_at": "2020-06-02T00:00:00Z", + "supersedes_identity_row_id": "id-1", + } + ) + + def change_evaluations(rows): + research = next( + row for row in rows if row["universe_id"] == "research-1" + ) + research["evaluation_at"] = "2020-03-01T00:00:00Z" + research["available_at"] = "2020-03-01T00:00:00Z" + if reverse: + rows.reverse() + + _rewrite_csv_and_manifest( + manifest, + "security_identity", + change_ticker, + ) + _rewrite_csv_and_manifest( + manifest, + "evaluations", + change_evaluations, + ) + manifests.append((manifest, registry)) + + packets = [ + validate_point_in_time_universe(manifest, registry) + for manifest, registry in manifests + ] + + assert packets[0].membership_digests == packets[1].membership_digests + assert tuple( + digest.universe_id for digest in packets[0].membership_digests + ) == ("research-1", "bench-1") + assert packets[0].display_tickers == {"sec-1": "BBB"} + assert packets[1].display_tickers == {"sec-1": "BBB"} + + +def test_latest_active_identity_leaf_replaces_superseded_ticker(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + def add_later_overlap(rows): + rows.append( + { + **rows[0], + "identity_row_id": "id-overlap", + "ticker": "BBB", + "source_ref": "fixture://identity/id-overlap", + "source_published_at": "2020-06-01T00:00:00Z", + "retrieved_at": "2020-06-02T00:00:00Z", + "supersedes_identity_row_id": "id-1", + } + ) + + def add_earlier_evaluation(rows): + research = next( + row for row in rows if row["universe_id"] == "research-1" + ) + research["evaluation_at"] = "2020-03-01T00:00:00Z" + research["available_at"] = "2020-03-01T00:00:00Z" + + _rewrite_csv_and_manifest( + manifest, + "security_identity", + add_later_overlap, + ) + _rewrite_csv_and_manifest( + manifest, + "evaluations", + add_earlier_evaluation, + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "identity_coverage").status == "passed" + assert _decision(packet, "membership_coverage").status == "passed" + assert packet.display_tickers == {"sec-1": "BBB"} + assert packet.analysis_eligible is True + assert { + row.row_id + for row in packet.analysis_eligible_rows + if ( + row.contract == "security_identity" + and row.evaluation_row_id == "eval-bench-1" + ) + } == {"id-overlap"} + + +@pytest.mark.parametrize("top_n", [True, False, -1, 1.5, "1", None]) +def test_top_n_rejects_bool_non_integer_and_negative_values(tmp_path, top_n): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + with pytest.raises(ValueError, match="^top_n_invalid$"): + validate_point_in_time_universe( + manifest, + registry, + top_n=top_n, + ) + + +def test_validation_packet_is_complete_and_independent_of_top_n(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + mutate_identity_membership_case(manifest, "overlapping_identity") + + packets = tuple( + validate_point_in_time_universe( + manifest, + registry, + top_n=top_n, + ) + for top_n in (0, 1, 100) + ) + + assert packets[0] == packets[1] == packets[2] + assert len(packets[0].excluded) > 1 + assert packets[0].excluded_count == len(packets[0].excluded) + + +def test_complete_row_classifications_and_counts_are_deterministic(tmp_path): + from src.point_in_time_universe import ( + RowReference, + validate_point_in_time_universe, + ) + + manifest, registry = build_valid_package(tmp_path) + + first = validate_point_in_time_universe(manifest, registry) + second = validate_point_in_time_universe(manifest, registry) + + expected = ( + RowReference("security_identity", 2, "id-1"), + RowReference("membership", 2, "member-bench-1"), + RowReference("membership", 3, "member-research-1"), + RowReference("events", 2, "event-1"), + RowReference("evaluations", 2, "eval-bench-1"), + RowReference("evaluations", 3, "eval-research-1"), + ) + assert first.raw_rows == expected + assert first.normalized_rows == expected + assert first.raw_count == len(expected) + assert first.normalized_count == len(expected) + assert first.excluded == () + assert first.excluded_count == 0 + assert dict(first.exclusion_reason_counts) == {} + assert first == second + + +def test_analysis_eligible_rows_are_evaluation_scoped_and_fail_closed( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + valid_root = tmp_path / "valid" + valid_root.mkdir() + valid_manifest, valid_registry = build_valid_package(valid_root) + valid = validate_point_in_time_universe( + valid_manifest, + valid_registry, + ) + + assert valid.analysis_eligible is True + assert valid.analysis_eligible_row_count == len( + valid.analysis_eligible_rows + ) + assert { + row.evaluation_row_id + for row in valid.analysis_eligible_rows + } == {"eval-bench-1", "eval-research-1"} + assert { + ( + row.evaluation_row_id, + row.contract, + row.row_id, + ) + for row in valid.analysis_eligible_rows + } == { + ("eval-bench-1", "evaluations", "eval-bench-1"), + ("eval-bench-1", "events", "event-1"), + ("eval-bench-1", "membership", "member-bench-1"), + ("eval-bench-1", "security_identity", "id-1"), + ("eval-research-1", "evaluations", "eval-research-1"), + ("eval-research-1", "events", "event-1"), + ("eval-research-1", "membership", "member-research-1"), + ("eval-research-1", "security_identity", "id-1"), + } + + blocked_root = tmp_path / "blocked" + blocked_root.mkdir() + blocked_manifest, blocked_registry = build_valid_package(blocked_root) + mutate_identity_membership_case(blocked_manifest, "missing_identity") + blocked = validate_point_in_time_universe( + blocked_manifest, + blocked_registry, + ) + + assert blocked.analysis_eligible is False + assert blocked.analysis_eligible_rows == () + assert blocked.analysis_eligible_row_count == 0 + + +def test_complete_reason_counts_equal_full_canonical_exclusions(tmp_path): + from collections import Counter + + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + mutate_identity_membership_case(manifest, "overlapping_identity") + + packet = validate_point_in_time_universe(manifest, registry, top_n=1) + + expected = Counter( + reason + for excluded in packet.excluded + for reason in excluded.reason_codes + ) + assert dict(packet.exclusion_reason_counts) == dict(sorted(expected.items())) + assert packet.excluded_count == len(packet.excluded) + + +def test_split_requires_positive_explicit_ratio_and_does_not_rewrite_membership( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + def mutate(rows): + rows[0].update( + event_type="split", + ratio_numerator="2", + ratio_denominator="1", + listing_state_after="", + ) + + _rewrite_csv_and_manifest(manifest, "events", mutate) + raw = json.loads(manifest.read_text()) + raw["corporate_action_policy"]["listing"] = "not_applicable" + raw["corporate_action_policy"]["split"] = "required" + manifest.write_text(json.dumps(raw), encoding="utf-8") + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "corporate_action_coverage").area == ( + "corporate_action_coverage" + ) + assert _decision(packet, "corporate_action_coverage").status == "passed" + assert _decision(packet, "corporate_action_coverage").reason_codes == () + assert _decision(packet, "delisting_coverage").status == "not_applicable" + assert packet.membership_digests[0].member_count == 1 + + +def test_reverse_split_has_independent_action_coverage(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _rewrite_csv_and_manifest( + manifest, + "events", + lambda rows: rows[0].update( + event_type="reverse_split", + ratio_numerator="1", + ratio_denominator="10", + listing_state_after="", + ), + ) + + def require_reverse_split(raw): + raw["corporate_action_policy"]["listing"] = "not_applicable" + raw["corporate_action_policy"]["reverse_split"] = "required" + + _rewrite_manifest(manifest, require_reverse_split) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "corporate_action_coverage").status == "passed" + assert _decision(packet, "corporate_action_coverage").reason_codes == () + assert _decision(packet, "delisting_coverage").status == "not_applicable" + assert all(digest.member_count == 1 for digest in packet.membership_digests) + + +def test_delisted_historical_member_is_retained_and_not_filtered_by_current_state( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + def mutate(rows): + rows.append( + { + **rows[0], + "event_row_id": "event-2", + "event_type": "delisting", + "effective_at": "2020-06-01T00:00:00Z", + "listing_state_after": "delisted", + "source_ref": "fixture://event/event-2", + "source_published_at": "2020-06-01T00:00:00Z", + "retrieved_at": "2020-06-02T00:00:00Z", + "supersedes_event_row_id": "", + } + ) + + _rewrite_csv_and_manifest(manifest, "events", mutate) + raw = json.loads(manifest.read_text()) + raw["corporate_action_policy"]["delisting"] = "required" + manifest.write_text(json.dumps(raw), encoding="utf-8") + + packet = validate_point_in_time_universe(manifest, registry) + + assert all(d.member_count == 1 for d in packet.membership_digests) + assert packet.decisions["delisting_coverage"].status == "passed" + + +@pytest.mark.parametrize( + "event_type,updates,reason,decision_area,sibling_area,sibling_status", + [ + ( + "merger", + {"successor_security_id": ""}, + "corporate_action_successor_required", + "corporate_action_coverage", + "delisting_coverage", + "not_applicable", + ), + ( + "acquisition", + {"successor_security_id": ""}, + "corporate_action_successor_required", + "corporate_action_coverage", + "delisting_coverage", + "not_applicable", + ), + ( + "spinoff", + {"successor_security_id": ""}, + "corporate_action_successor_required", + "corporate_action_coverage", + "delisting_coverage", + "not_applicable", + ), + ( + "delisting", + {"listing_state_after": "active"}, + "delisting_state_invalid", + "delisting_coverage", + "corporate_action_coverage", + "passed", + ), + ( + "suspension", + {"listing_state_after": "active"}, + "delisting_transition_invalid", + "delisting_coverage", + "corporate_action_coverage", + "passed", + ), + ( + "reactivation", + {"listing_state_after": "active"}, + "delisting_transition_invalid", + "delisting_coverage", + "corporate_action_coverage", + "passed", + ), + ], +) +def test_invalid_action_or_listing_transition_is_blocked( + tmp_path, + event_type, + updates, + reason, + decision_area, + sibling_area, + sibling_status, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + def mutate(rows): + rows[0].update(event_type=event_type, **updates) + + _rewrite_csv_and_manifest(manifest, "events", mutate) + raw = json.loads(manifest.read_text()) + raw["corporate_action_policy"]["listing"] = "not_applicable" + raw["corporate_action_policy"][event_type] = "required" + manifest.write_text(json.dumps(raw), encoding="utf-8") + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, decision_area).status == "blocked" + assert reason in _decision(packet, decision_area).reason_codes + assert _decision(packet, sibling_area).status == sibling_status + assert reason not in _decision(packet, sibling_area).reason_codes + + +def test_present_event_marked_unsupported_is_blocked(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + raw = json.loads(manifest.read_text()) + raw["corporate_action_policy"]["listing"] = "unsupported" + manifest.write_text(json.dumps(raw), encoding="utf-8") + + packet = validate_point_in_time_universe(manifest, registry) + + assert ( + "corporate_action_policy_unsupported" + in packet.decisions["corporate_action_coverage"].reason_codes + ) + assert _decision(packet, "delisting_coverage").status == "not_applicable" + assert _decision(packet, "delisting_coverage").reason_codes == () + + +@pytest.mark.parametrize("event_type", ["suspension", "reactivation"]) +def test_missing_required_listing_transition_blocks_only_delisting_coverage( + tmp_path, + event_type, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _rewrite_manifest( + manifest, + lambda raw: raw["corporate_action_policy"].update( + {event_type: "required"} + ), + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "corporate_action_coverage").status == "passed" + assert _decision(packet, "corporate_action_coverage").reason_codes == () + assert _decision(packet, "delisting_coverage").status == "blocked" + assert _decision(packet, "delisting_coverage").reason_codes == ( + "delisting_evidence_missing", + ) + + +def test_suspension_transition_is_delisting_coverage(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + def add_suspension(rows): + rows.append( + { + **rows[0], + "event_row_id": "event-suspension", + "event_type": "suspension", + "effective_at": "2020-02-01T00:00:00Z", + "listing_state_after": "suspended", + "source_ref": "fixture://event/event-suspension", + "source_published_at": "2020-02-01T00:00:00Z", + "retrieved_at": "2020-02-02T00:00:00Z", + } + ) + + _rewrite_csv_and_manifest(manifest, "events", add_suspension) + _rewrite_manifest( + manifest, + lambda raw: raw["corporate_action_policy"].update( + {"suspension": "required"} + ), + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "delisting_coverage").status == "passed" + assert _decision(packet, "delisting_coverage").reason_codes == () + assert _decision(packet, "corporate_action_coverage").status == "passed" + assert _decision(packet, "corporate_action_coverage").reason_codes == () + + +def test_ordered_suspension_reactivation_pair_passes(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + def add_transitions(rows): + base = rows[0] + rows.extend( + [ + { + **base, + "event_row_id": "event-suspension", + "event_type": "suspension", + "effective_at": "2020-02-01T00:00:00Z", + "listing_state_after": "suspended", + "source_ref": "fixture://event/event-suspension", + "source_published_at": "2020-02-01T00:00:00Z", + "retrieved_at": "2020-02-02T00:00:00Z", + }, + { + **base, + "event_row_id": "event-reactivation", + "event_type": "reactivation", + "effective_at": "2020-03-01T00:00:00Z", + "listing_state_after": "active", + "source_ref": "fixture://event/event-reactivation", + "source_published_at": "2020-03-01T00:00:00Z", + "retrieved_at": "2020-03-02T00:00:00Z", + }, + ] + ) + + _rewrite_csv_and_manifest(manifest, "events", add_transitions) + _rewrite_manifest( + manifest, + lambda raw: raw["corporate_action_policy"].update( + {"suspension": "required", "reactivation": "required"} + ), + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "delisting_coverage").status == "passed" + assert _decision(packet, "delisting_coverage").reason_codes == () + assert _decision(packet, "corporate_action_coverage").status == "passed" + + +@pytest.mark.parametrize( + "suspension_row_id,reactivation_row_id", + [ + ("event-a-suspension", "event-z-reactivation"), + ("event-z-suspension", "event-a-reactivation"), + ], +) +def test_same_timestamp_reactivation_is_blocked_regardless_of_row_id_order( + tmp_path, + suspension_row_id, + reactivation_row_id, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + def add_simultaneous_transitions(rows): + base = rows[0] + rows.extend( + [ + { + **base, + "event_row_id": suspension_row_id, + "event_type": "suspension", + "effective_at": "2020-02-01T00:00:00Z", + "listing_state_after": "suspended", + "source_ref": ( + f"fixture://event/{suspension_row_id}" + ), + "source_published_at": "2020-02-01T00:00:00Z", + "retrieved_at": "2020-02-02T00:00:00Z", + }, + { + **base, + "event_row_id": reactivation_row_id, + "event_type": "reactivation", + "effective_at": "2020-02-01T00:00:00Z", + "listing_state_after": "active", + "source_ref": ( + f"fixture://event/{reactivation_row_id}" + ), + "source_published_at": "2020-02-01T00:00:00Z", + "retrieved_at": "2020-02-02T00:00:00Z", + }, + ] + ) + + _rewrite_csv_and_manifest( + manifest, + "events", + add_simultaneous_transitions, + ) + _rewrite_manifest( + manifest, + lambda raw: raw["corporate_action_policy"].update( + {"suspension": "required", "reactivation": "required"} + ), + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "delisting_coverage").status == "blocked" + assert _decision(packet, "delisting_coverage").reason_codes == ( + "delisting_transition_invalid", + ) + assert _decision(packet, "corporate_action_coverage").status == "passed" + assert _decision(packet, "corporate_action_coverage").reason_codes == () + + +def test_multiple_reactivation_roots_fail_closed_as_ambiguous_lineage( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + def add_transitions(rows): + base = rows[0] + rows.extend( + [ + { + **base, + "event_row_id": "event-suspension", + "event_type": "suspension", + "effective_at": "2020-02-01T00:00:00Z", + "listing_state_after": "suspended", + "source_ref": "fixture://event/event-suspension", + "source_published_at": "2020-02-01T00:00:00Z", + "retrieved_at": "2020-02-02T00:00:00Z", + }, + { + **base, + "event_row_id": "event-reactivation-1", + "event_type": "reactivation", + "effective_at": "2020-03-01T00:00:00Z", + "listing_state_after": "active", + "source_ref": "fixture://event/event-reactivation-1", + "source_published_at": "2020-03-01T00:00:00Z", + "retrieved_at": "2020-03-02T00:00:00Z", + }, + { + **base, + "event_row_id": "event-reactivation-2", + "event_type": "reactivation", + "effective_at": "2020-04-01T00:00:00Z", + "listing_state_after": "active", + "source_ref": "fixture://event/event-reactivation-2", + "source_published_at": "2020-04-01T00:00:00Z", + "retrieved_at": "2020-04-02T00:00:00Z", + }, + ] + ) + + _rewrite_csv_and_manifest(manifest, "events", add_transitions) + _rewrite_manifest( + manifest, + lambda raw: raw["corporate_action_policy"].update( + {"suspension": "required", "reactivation": "required"} + ), + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "delisting_coverage").status == "blocked" + assert _decision(packet, "delisting_coverage").reason_codes == ( + "delisting_evidence_missing", + "lineage_multiple_roots", + ) + assert _decision(packet, "corporate_action_coverage").status == "passed" + + +def test_intervening_active_transition_invalidates_later_reactivation(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + def add_transitions(rows): + base = rows[0] + rows.extend( + [ + { + **base, + "event_row_id": "event-suspension", + "event_type": "suspension", + "effective_at": "2020-02-01T00:00:00Z", + "listing_state_after": "suspended", + "source_ref": "fixture://event/event-suspension", + "source_published_at": "2020-02-01T00:00:00Z", + "retrieved_at": "2020-02-02T00:00:00Z", + }, + { + **base, + "event_row_id": "event-active", + "event_type": "listing", + "effective_at": "2020-03-01T00:00:00Z", + "listing_state_after": "active", + "source_ref": "fixture://event/event-active", + "source_published_at": "2020-03-01T00:00:00Z", + "retrieved_at": "2020-03-02T00:00:00Z", + "supersedes_event_row_id": "event-1", + }, + { + **base, + "event_row_id": "event-reactivation", + "event_type": "reactivation", + "effective_at": "2020-04-01T00:00:00Z", + "listing_state_after": "active", + "source_ref": "fixture://event/event-reactivation", + "source_published_at": "2020-04-01T00:00:00Z", + "retrieved_at": "2020-04-02T00:00:00Z", + }, + ] + ) + + _rewrite_csv_and_manifest(manifest, "events", add_transitions) + _rewrite_manifest( + manifest, + lambda raw: raw["corporate_action_policy"].update( + {"suspension": "required", "reactivation": "required"} + ), + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "delisting_coverage").status == "blocked" + assert _decision(packet, "delisting_coverage").reason_codes == ( + "delisting_transition_invalid", + ) + assert _decision(packet, "corporate_action_coverage").status == "passed" + + +def test_superseded_invalid_event_root_does_not_affect_coverage(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + def replace_with_revision(rows): + rows[0].update( + event_type="suspension", + listing_state_after="active", + effective_at="2020-02-01T00:00:00Z", + source_published_at="2020-02-01T00:00:00Z", + retrieved_at="2020-02-02T00:00:00Z", + ) + rows.append( + { + **rows[0], + "event_row_id": "event-suspension-revision", + "listing_state_after": "suspended", + "source_ref": "fixture://event/suspension-revision", + "source_published_at": "2020-03-01T00:00:00Z", + "retrieved_at": "2020-03-02T00:00:00Z", + "supersedes_event_row_id": rows[0]["event_row_id"], + } + ) + + _rewrite_csv_and_manifest(manifest, "events", replace_with_revision) + _rewrite_manifest( + manifest, + lambda raw: raw["corporate_action_policy"].update( + {"listing": "not_applicable", "suspension": "required"} + ), + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "delisting_coverage").status == "passed" + assert _decision(packet, "delisting_coverage").reason_codes == () + assert not any( + row.row_id == "event-1" + and "delisting_transition_invalid" in row.reason_codes + for row in packet.excluded + ) + + +def test_cutoff_visible_event_revision_is_consumed_leaf(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + def replace_with_revision(rows): + rows[0].update( + event_type="merger", + successor_security_id="", + listing_state_after="", + ) + rows.append( + { + **rows[0], + "event_row_id": "event-merger-revision", + "successor_security_id": "sec-successor", + "source_ref": "fixture://event/merger-revision", + "source_published_at": "2020-02-01T00:00:00Z", + "retrieved_at": "2020-02-02T00:00:00Z", + "supersedes_event_row_id": rows[0]["event_row_id"], + } + ) + + _rewrite_csv_and_manifest(manifest, "events", replace_with_revision) + _rewrite_manifest( + manifest, + lambda raw: raw["corporate_action_policy"].update( + {"listing": "not_applicable", "merger": "required"} + ), + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "corporate_action_coverage").status == "blocked" + assert _decision( + packet, + "corporate_action_coverage", + ).reason_codes == ( + "corporate_action_successor_identity_missing", + ) + + +def test_post_evaluation_invalid_reactivation_does_not_poison_prior_cutoff( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + def add_events(rows): + rows.append( + { + **rows[0], + "event_row_id": "event-suspension", + "event_type": "suspension", + "effective_at": "2020-02-01T00:00:00Z", + "listing_state_after": "suspended", + "source_ref": "fixture://event/suspension", + "source_published_at": "2020-02-01T00:00:00Z", + "retrieved_at": "2020-02-02T00:00:00Z", + } + ) + rows.append( + { + **rows[0], + "event_row_id": "event-future-invalid-reactivation", + "event_type": "reactivation", + "effective_at": "2020-03-01T00:00:00Z", + "listing_state_after": "suspended", + "source_ref": "fixture://event/future-reactivation", + "source_published_at": "2022-01-01T00:00:00Z", + "retrieved_at": "2022-01-02T00:00:00Z", + } + ) + + _rewrite_csv_and_manifest(manifest, "events", add_events) + _rewrite_manifest( + manifest, + lambda raw: raw["corporate_action_policy"].update( + {"suspension": "required"} + ), + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "delisting_coverage").status == "passed" + assert _decision(packet, "delisting_coverage").reason_codes == () + assert { + row.row_id: row.reason_codes + for row in packet.excluded + if row.row_id == "event-future-invalid-reactivation" + } == { + "event-future-invalid-reactivation": ( + "cutoff_unrelated_scope_invisible", + ) + } + + +@pytest.mark.parametrize( + "mutation,expected_reason,decision_area", + [ + ("missing_parent", "lineage_missing_parent", "corporate_action_coverage"), + ("fork", "lineage_fork", "corporate_action_coverage"), + ("cycle", "lineage_cycle", "corporate_action_coverage"), + ("cross_scope", "lineage_cross_scope_parent", "delisting_coverage"), + ("reversed", "lineage_order_reversed", "corporate_action_coverage"), + ], +) +def test_event_lineage_failures_block_through_public_validator( + tmp_path, + mutation, + expected_reason, + decision_area, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + def mutate(rows): + base = rows[0] + if mutation == "missing_parent": + base["supersedes_event_row_id"] = "missing-event" + elif mutation == "fork": + for suffix, month in (("a", "02"), ("b", "03")): + rows.append( + { + **base, + "event_row_id": f"event-child-{suffix}", + "source_ref": f"fixture://event/child-{suffix}", + "source_published_at": ( + f"2020-{month}-01T00:00:00Z" + ), + "retrieved_at": f"2020-{month}-02T00:00:00Z", + "supersedes_event_row_id": base["event_row_id"], + } + ) + elif mutation == "cycle": + base["supersedes_event_row_id"] = "event-cycle" + rows.append( + { + **base, + "event_row_id": "event-cycle", + "source_ref": "fixture://event/cycle", + "source_published_at": "2020-02-01T00:00:00Z", + "retrieved_at": "2020-02-02T00:00:00Z", + "supersedes_event_row_id": "event-1", + } + ) + elif mutation == "cross_scope": + rows.append( + { + **base, + "event_row_id": "event-cross-scope", + "event_type": "suspension", + "listing_state_after": "suspended", + "source_ref": "fixture://event/cross-scope", + "source_published_at": "2020-02-01T00:00:00Z", + "retrieved_at": "2020-02-02T00:00:00Z", + "supersedes_event_row_id": "event-1", + } + ) + elif mutation == "reversed": + rows.append( + { + **base, + "event_row_id": "event-reversed", + "source_ref": "fixture://event/reversed", + "source_published_at": "2020-01-01T00:00:00Z", + "retrieved_at": "2020-01-01T00:00:00Z", + "supersedes_event_row_id": "event-1", + } + ) + + _rewrite_csv_and_manifest(manifest, "events", mutate) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, decision_area).status == "blocked" + assert expected_reason in _decision( + packet, + decision_area, + ).reason_codes + assert any( + expected_reason in row.reason_codes + for row in packet.excluded + if row.contract == "events" + ) + assert packet.analysis_eligible is False + + +def test_duplicate_event_row_ids_are_globally_excluded(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + def duplicate(rows): + rows.append( + { + **rows[0], + "security_id": "sec-duplicate", + "source_ref": "fixture://event/duplicate", + "source_published_at": "2022-01-01T00:00:00Z", + "retrieved_at": "2022-01-02T00:00:00Z", + } + ) + + _rewrite_csv_and_manifest(manifest, "events", duplicate) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "technical_validity").status == "blocked" + assert _decision(packet, "technical_validity").reason_codes == ( + "schema_event_row_id_duplicate", + ) + duplicate_exclusions = [ + row + for row in packet.excluded + if row.contract == "events" and row.row_id == "event-1" + ] + assert len(duplicate_exclusions) == 2 + assert all( + row.reason_codes == ("schema_event_row_id_duplicate",) + for row in duplicate_exclusions + ) + assert packet.analysis_eligible is False + + +def test_event_lineage_aggregates_across_cutoffs_and_canonicalizes_exclusions( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + def add_later_fork(rows): + base = rows[0] + for suffix, month in (("a", "01"), ("b", "02")): + rows.append( + { + **base, + "event_row_id": f"event-later-child-{suffix}", + "source_ref": f"fixture://event/later-child-{suffix}", + "source_published_at": ( + f"2022-{month}-01T00:00:00Z" + ), + "retrieved_at": f"2022-{month}-02T00:00:00Z", + "supersedes_event_row_id": base["event_row_id"], + } + ) + + def add_later_evaluations(rows): + rows.extend( + { + **row, + "evaluation_row_id": ( + f"{row['evaluation_row_id']}-later" + ), + "evaluation_at": "2023-01-01T00:00:00Z", + "available_at": "2023-01-01T00:00:00Z", + "partition": "test", + "source_ref": f"{row['source_ref']}/later", + } + for row in list(rows) + ) + + _rewrite_csv_and_manifest(manifest, "events", add_later_fork) + _rewrite_csv_and_manifest( + manifest, + "evaluations", + add_later_evaluations, + ) + _rewrite_manifest( + manifest, + lambda raw: raw.update( + observation_cutoff_at="2023-01-01T00:00:00Z", + ), + ) + + packet = validate_point_in_time_universe( + manifest, + registry, + top_n=100, + ) + + assert _decision(packet, "corporate_action_coverage").status == "blocked" + assert "lineage_fork" in _decision( + packet, + "corporate_action_coverage", + ).reason_codes + later_child_exclusions = [ + row + for row in packet.excluded + if row.row_id == "event-later-child-a" + ] + assert len(later_child_exclusions) == 1 + assert later_child_exclusions[0].reason_codes == ( + "cutoff_later_revision_invisible", + "lineage_fork", + ) + + +def test_unrelated_security_event_cannot_satisfy_member_event_coverage( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _rewrite_csv_and_manifest( + manifest, + "events", + lambda rows: rows[0].update( + security_id="sec-unrelated", + source_ref="fixture://event/unrelated-security", + ), + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "identity_coverage").status == "passed" + assert _decision(packet, "membership_coverage").status == "passed" + assert _decision(packet, "corporate_action_coverage").status == "blocked" + assert _decision( + packet, + "corporate_action_coverage", + ).reason_codes == ("corporate_action_evidence_missing",) + assert packet.analysis_eligible is False + + +def test_partition_invalid_evaluation_is_not_an_event_cutoff(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + def add_invalid_evaluation(rows): + rows.append( + { + **rows[0], + "evaluation_row_id": "eval-invalid-partition", + "evaluation_at": "2019-01-01T00:00:00Z", + "available_at": "2019-01-01T00:00:00Z", + "partition": "test", + "source_ref": "fixture://evaluation/invalid-partition", + } + ) + + _rewrite_csv_and_manifest( + manifest, + "evaluations", + add_invalid_evaluation, + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "leakage_safe").status == "blocked" + assert "partition_assignment_invalid" in _decision( + packet, + "leakage_safe", + ).reason_codes + assert _decision(packet, "corporate_action_coverage").status == "passed" + assert _decision(packet, "corporate_action_coverage").reason_codes == () + assert _decision(packet, "temporal_validity").status == "passed" + assert _decision(packet, "reproduction_ready").status == "passed" + assert all( + digest.evaluation_at != "2019-01-01T00:00:00Z" + for digest in packet.membership_digests + ) + + +def test_undeclared_evaluation_is_excluded_and_blocks_membership_coverage( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + def add_undeclared_evaluation(rows): + rows.append( + { + **rows[0], + "evaluation_row_id": "eval-undeclared", + "universe_id": "ghost", + "source_ref": "fixture://evaluation/undeclared", + } + ) + + _rewrite_csv_and_manifest( + manifest, + "evaluations", + add_undeclared_evaluation, + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "membership_coverage").status == "blocked" + assert _decision(packet, "membership_coverage").reason_codes == ( + "membership_universe_undeclared", + ) + assert _decision(packet, "temporal_validity").status == "passed" + assert _decision(packet, "reproduction_ready").status == "passed" + assert all( + digest.universe_id != "ghost" + for digest in packet.membership_digests + ) + assert [ + row.reason_codes + for row in packet.excluded + if ( + row.contract == "evaluations" + and row.row_id == "eval-undeclared" + ) + ] == [("membership_universe_undeclared",)] + assert packet.analysis_eligible is False + + +def test_no_valid_evaluation_fails_required_event_coverage_closed(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _replace_contract_rows(manifest, "evaluations", []) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "membership_coverage").status == "blocked" + assert _decision(packet, "corporate_action_coverage").status == "blocked" + assert _decision( + packet, + "corporate_action_coverage", + ).reason_codes == ("corporate_action_evidence_missing",) + assert packet.analysis_eligible is False + + +def test_one_member_event_satisfies_type_level_coverage_for_member_scope( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + def add_identity(rows): + rows.append( + { + **rows[0], + "identity_row_id": "id-2", + "security_id": "sec-2", + "issuer_id": "issuer-2", + "ticker": "BBB", + "source_ref": "fixture://identity/id-2", + } + ) + + def add_memberships(rows): + rows.extend( + { + **row, + "membership_row_id": ( + f"{row['membership_row_id']}-sec-2" + ), + "security_id": "sec-2", + "source_ref": f"{row['source_ref']}/sec-2", + } + for row in list(rows) + ) + + _rewrite_csv_and_manifest( + manifest, + "security_identity", + add_identity, + ) + _rewrite_csv_and_manifest( + manifest, + "membership", + add_memberships, + ) + _rewrite_csv_and_manifest( + manifest, + "events", + lambda rows: rows[0].update( + event_type="split", + ratio_numerator="2", + ratio_denominator="1", + listing_state_after="", + ), + ) + _rewrite_manifest( + manifest, + lambda raw: raw["corporate_action_policy"].update( + {"listing": "not_applicable", "split": "required"} + ), + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "membership_coverage").status == "passed" + assert all( + digest.member_count == 2 + for digest in packet.membership_digests + ) + assert _decision(packet, "corporate_action_coverage").status == "passed" + assert _decision(packet, "corporate_action_coverage").reason_codes == () + assert packet.analysis_eligible is True + + +def test_empty_membership_scope_fails_required_event_coverage_closed(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _replace_contract_rows(manifest, "membership", []) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "membership_coverage").status == "blocked" + assert _decision(packet, "corporate_action_coverage").status == "blocked" + assert _decision( + packet, + "corporate_action_coverage", + ).reason_codes == ("corporate_action_evidence_missing",) + assert packet.analysis_eligible is False + + +def test_ambiguous_membership_scope_fails_required_event_coverage_closed( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + def fork_membership(rows): + base = rows[0] + for suffix, month in (("a", "02"), ("b", "03")): + rows.append( + { + **base, + "membership_row_id": f"member-fork-{suffix}", + "source_ref": f"fixture://membership/fork-{suffix}", + "source_published_at": ( + f"2020-{month}-01T00:00:00Z" + ), + "retrieved_at": f"2020-{month}-02T00:00:00Z", + "supersedes_membership_row_id": ( + base["membership_row_id"] + ), + } + ) + + _rewrite_csv_and_manifest( + manifest, + "membership", + fork_membership, + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "membership_coverage").status == "blocked" + assert "lineage_fork" in _decision( + packet, + "membership_coverage", + ).reason_codes + assert _decision(packet, "corporate_action_coverage").status == "blocked" + assert _decision( + packet, + "corporate_action_coverage", + ).reason_codes == ("corporate_action_evidence_missing",) + assert packet.analysis_eligible is False + + +@pytest.mark.parametrize( + "listing_row_id,suspension_row_id", + [ + ("event-a-listing", "event-z-suspension"), + ("event-z-listing", "event-a-suspension"), + ], +) +def test_simultaneous_conflicting_listing_states_fail_closed_independent_of_ids( + tmp_path, + listing_row_id, + suspension_row_id, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + def add_conflicting_transitions(rows): + rows[0].update(event_row_id=listing_row_id) + base = rows[0] + rows.extend( + [ + { + **base, + "event_row_id": suspension_row_id, + "event_type": "suspension", + "listing_state_after": "suspended", + "source_ref": ( + f"fixture://event/{suspension_row_id}" + ), + }, + { + **base, + "event_row_id": "event-reactivation", + "event_type": "reactivation", + "effective_at": "2020-02-01T00:00:00Z", + "listing_state_after": "active", + "source_ref": "fixture://event/reactivation", + "source_published_at": "2020-02-01T00:00:00Z", + "retrieved_at": "2020-02-02T00:00:00Z", + }, + ] + ) + + _rewrite_csv_and_manifest( + manifest, + "events", + add_conflicting_transitions, + ) + _rewrite_manifest( + manifest, + lambda raw: raw["corporate_action_policy"].update( + {"suspension": "required", "reactivation": "required"} + ), + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "corporate_action_coverage").status == "passed" + assert _decision(packet, "delisting_coverage").status == "blocked" + assert _decision(packet, "delisting_coverage").reason_codes == ( + "delisting_transition_invalid", + ) + assert packet.analysis_eligible is False + + +def test_technical_pass_does_not_promote_unverified_rights(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + registry.write_text( + registry.read_text().replace( + "commercial_use: approved", + "commercial_use: unverified", + ), + encoding="utf-8", + ) + raw = json.loads(manifest.read_text()) + raw["source_rights_registry_sha256"] = hashlib.sha256( + registry.read_bytes() + ).hexdigest() + manifest.write_text(json.dumps(raw), encoding="utf-8") + + packet = validate_point_in_time_universe(manifest, registry) + + assert packet.decisions["technical_validity"].status == "passed" + assert packet.decisions["source_rights_eligibility"].status == "blocked" + assert packet.decisions["source_rights_eligibility"].reason_codes == ( + "source_rights_commercial_rights_unverified", + ) + assert packet.decisions["corporate_action_coverage"].status == "passed" + assert packet.decisions["delisting_coverage"].status == "not_applicable" + assert packet.analysis_eligible is False + + +@pytest.mark.parametrize( + "contract,missing_scope,event_type,expected_delisting_status", + [ + ("security_identity", "security_identity", None, "not_applicable"), + ("membership", "universe_membership", None, "not_applicable"), + ("events", "corporate_actions", "listing", "not_applicable"), + ("events", "delistings", "delisting", "passed"), + ], +) +def test_exact_source_scope_mapping_blocks_only_rights_state( + tmp_path, + contract, + missing_scope, + event_type, + expected_delisting_status, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + source_id = f"{missing_scope}_source" + + def isolate_source(rows): + for row in rows: + row["source_id"] = source_id + if event_type == "delisting": + rows[0].update( + event_type="delisting", + listing_state_after="delisted", + ) + + _rewrite_csv_and_manifest( + manifest, + contract, + isolate_source, + ) + all_scopes = ( + "security_identity", + "universe_membership", + "corporate_actions", + "delistings", + ) + _append_registry_source( + registry, + source_id, + tuple(scope for scope in all_scopes if scope != missing_scope), + ) + + def allow_isolated_source(raw): + raw["allowed_source_ids"].append(source_id) + if event_type == "delisting": + raw["corporate_action_policy"]["listing"] = "not_applicable" + raw["corporate_action_policy"]["delisting"] = "required" + + _rewrite_manifest(manifest, allow_isolated_source) + _refresh_registry_digest(manifest, registry) + + packet = validate_point_in_time_universe(manifest, registry) + + assert packet.decisions["technical_validity"].status == "passed" + assert packet.decisions["identity_coverage"].status == "passed" + assert packet.decisions["membership_coverage"].status == "passed" + assert packet.decisions["corporate_action_coverage"].status == "passed" + assert ( + packet.decisions["delisting_coverage"].status + == expected_delisting_status + ) + assert packet.decisions["source_rights_eligibility"].status == "blocked" + assert packet.decisions["source_rights_eligibility"].reason_codes == ( + "source_rights_field_scope_missing", + ) + assert packet.analysis_eligible is False + + +def test_allowed_unknown_source_has_exact_rights_reasons(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _rewrite_csv_and_manifest( + manifest, + "security_identity", + lambda rows: rows[0].update(source_id="unknown_source"), + ) + _rewrite_manifest( + manifest, + lambda raw: raw["allowed_source_ids"].append("unknown_source"), + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert packet.decisions["technical_validity"].status == "passed" + assert packet.decisions["identity_coverage"].status == "passed" + assert packet.decisions["source_rights_eligibility"].status == "blocked" + assert packet.decisions["source_rights_eligibility"].reason_codes == ( + "source_rights_field_scope_missing", + "source_rights_unknown_source", + ) + assert packet.decisions["corporate_action_coverage"].status == "passed" + assert packet.decisions["delisting_coverage"].status == "not_applicable" + + +def test_registered_approved_source_not_in_allowlist_is_blocked_exactly( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + source_id = "registered_but_not_allowed" + _rewrite_csv_and_manifest( + manifest, + "security_identity", + lambda rows: rows[0].update(source_id=source_id), + ) + _append_registry_source( + registry, + source_id, + ("security_identity",), + ) + _refresh_registry_digest(manifest, registry) + + packet = validate_point_in_time_universe(manifest, registry) + + assert packet.decisions["technical_validity"].status == "passed" + assert packet.decisions["identity_coverage"].status == "passed" + assert packet.decisions["source_rights_eligibility"].status == "blocked" + assert packet.decisions["source_rights_eligibility"].reason_codes == ( + "source_rights_source_not_allowed", + ) + assert packet.decisions["corporate_action_coverage"].status == "passed" + assert packet.decisions["delisting_coverage"].status == "not_applicable" + + +@pytest.mark.parametrize( + "contract,column", + [ + ("security_identity", "source_published_at"), + ("security_identity", "retrieved_at"), + ("membership", "observation_at"), + ("membership", "source_published_at"), + ("membership", "retrieved_at"), + ("events", "effective_at"), + ("events", "source_published_at"), + ("events", "retrieved_at"), + ], +) +def test_post_cutoff_evidence_is_excluded_without_poisoning_independent_states( + tmp_path, + contract, + column, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + def move_after_cutoff(rows): + rows[0][column] = "2022-01-01T00:00:00Z" + if column == "source_published_at": + rows[0]["retrieved_at"] = "2022-01-02T00:00:00Z" + + _rewrite_csv_and_manifest(manifest, contract, move_after_cutoff) + + packet = validate_point_in_time_universe(manifest, registry) + + future_effective_event = ( + contract == "events" + and column == "effective_at" + ) + if future_effective_event: + assert _decision( + packet, + "temporal_validity", + ).status == "passed" + assert _decision(packet, "leakage_safe").status == "passed" + assert any( + row.contract == contract + and row.reason_codes + == ("cutoff_unrelated_scope_invisible",) + for row in packet.excluded + ) + else: + assert _decision( + packet, + "temporal_validity", + ).status == "blocked" + assert _decision(packet, "leakage_safe").status == "blocked" + assert "leakage_post_cutoff_evidence" in _decision( + packet, + "leakage_safe", + ).reason_codes + assert any( + row.contract == contract + and "leakage_post_cutoff_evidence" in row.reason_codes + for row in packet.excluded + ) + expected_cutoff_coverage_block = { + "security_identity": { + "identity_coverage": ("identity_missing",), + }, + "membership": { + "membership_coverage": ( + "membership_no_eligible_members", + ), + }, + }.get(contract, {}) + for independent in ( + "technical_validity", + "identity_coverage", + "membership_coverage", + "source_rights_eligibility", + ): + decision = _decision(packet, independent) + if independent in expected_cutoff_coverage_block: + assert decision.status == "blocked" + assert decision.reason_codes == ( + expected_cutoff_coverage_block[independent] + ) + else: + assert decision.status == "passed" + if contract in {"events", "membership"}: + assert _decision( + packet, + "corporate_action_coverage", + ).reason_codes == ("corporate_action_evidence_missing",) + else: + assert _decision( + packet, + "corporate_action_coverage", + ).status == "passed" + assert _decision(packet, "delisting_coverage").status == "not_applicable" + assert packet.analysis_eligible is False + + +def test_later_revision_is_invisible_at_earlier_evaluation(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + def add_later_revision(rows): + rows.append( + { + **rows[0], + "membership_row_id": "member-late", + "membership_state": "excluded", + "source_ref": "fixture://membership/late", + "source_published_at": "2022-01-01T00:00:00Z", + "retrieved_at": "2022-01-02T00:00:00Z", + "supersedes_membership_row_id": rows[0][ + "membership_row_id" + ], + } + ) + + _rewrite_csv_and_manifest( + manifest, + "membership", + add_later_revision, + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _digest_by_universe(packet)["bench-1"].member_count == 1 + assert _digest_by_universe(packet)["research-1"].member_count == 1 + assert _decision(packet, "temporal_validity").status == "passed" + assert _decision(packet, "leakage_safe").status == "passed" + assert next( + row + for row in packet.excluded + if row.row_id == "member-late" + ).reason_codes == ("cutoff_later_revision_invisible",) + assert packet.analysis_eligible is True + + +def test_repeated_validation_reproduces_all_canonical_outputs(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + first = validate_point_in_time_universe(manifest, registry) + second = validate_point_in_time_universe(manifest, registry) + + assert first.membership_digests == second.membership_digests + assert first.decisions == second.decisions + assert first.excluded == second.excluded + assert tuple(first.decisions) == ( + "manifest_integrity", + "technical_validity", + "temporal_validity", + "identity_coverage", + "membership_coverage", + "corporate_action_coverage", + "delisting_coverage", + "source_rights_eligibility", + "reproduction_ready", + "leakage_safe", + ) + assert { + digest.universe_id: digest.member_count + for digest in first.membership_digests + } == {"bench-1": 1, "research-1": 1} + assert { + digest.universe_id: digest.sha256 + for digest in first.membership_digests + } == { + "bench-1": _sha256_members("sec-1"), + "research-1": _sha256_members("sec-1"), + } + assert all( + decision.reason_codes + == tuple(sorted(set(decision.reason_codes))) + for decision in first.decisions.values() + ) + assert all( + row.reason_codes == tuple(sorted(set(row.reason_codes))) + for row in first.excluded + ) + + +@pytest.mark.parametrize( + "policy,reason", + [ + ( + { + "kind": "train_validation_test", + "train_end_at": "2020-07-01T00:00:00Z", + "validation_start_at": "2020-06-01T00:00:00Z", + "validation_end_at": "2020-09-01T00:00:00Z", + "test_start_at": "2020-10-01T00:00:00Z", + }, + "partition_overlap", + ), + ( + { + "kind": "train_validation_test", + "train_end_at": "2020-06-01T00:00:00Z", + "validation_start_at": "2020-07-01T00:00:00Z", + "validation_end_at": "2020-05-01T00:00:00Z", + "test_start_at": "2020-10-01T00:00:00Z", + }, + "partition_order_invalid", + ), + ( + {"kind": "train_validation_test"}, + "partition_schema_invalid", + ), + ( + {"kind": "walk_forward", "minimum_history_count": 0}, + "partition_minimum_history_invalid", + ), + ], +) +def test_partition_policy_failures_are_canonical_and_fail_closed( + policy, + reason, +): + from src.point_in_time_universe import _partition_decision + + decision = _partition_decision( + SimpleNamespace(evaluation_policy=policy), + (), + ) + + assert decision.area == "leakage_safe" + assert decision.status == "blocked" + assert reason in decision.reason_codes + assert decision.reason_codes == tuple(sorted(set(decision.reason_codes))) + + +@pytest.mark.parametrize( + "contract,digests,reason", + [ + ( + "unsupported_v2", + (), + "reproduction_contract_unsupported", + ), + ( + "membership_count_and_sha256_at_cutoff_v1", + ( + ("bench-1", "2021-01-01T00:00:00Z", 1, "a" * 64), + ("bench-1", "2021-01-01T00:00:00Z", 1, "b" * 64), + ), + "reproduction_duplicate_evaluation", + ), + ( + "membership_count_and_sha256_at_cutoff_v1", + ( + ("bench-1", "2021-01-01T00:00:00Z", 1, "g" * 64), + ), + "reproduction_digest_invalid", + ), + ], +) +def test_reproduction_failures_block_independently( + contract, + digests, + reason, +): + from src.point_in_time_universe import ( + MembershipDigest, + _reproduction_decision, + ) + + decision = _reproduction_decision( + SimpleNamespace(reproduction_contract=contract), + tuple(MembershipDigest(*digest) for digest in digests), + ) + + assert decision.area == "reproduction_ready" + assert decision.status == "blocked" + assert decision.reason_codes == (reason,) + + +@pytest.mark.parametrize( + "mutation,reason", + [ + ("no_evaluations", "membership_no_evaluation"), + ("benchmark_only", "membership_research_universe_missing"), + ("research_only", "membership_benchmark_missing"), + ("all_excluded", "membership_no_eligible_members"), + ], +) +def test_empty_or_one_sided_packages_fail_closed(tmp_path, mutation, reason): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + mutate_package_for_empty_case(manifest, mutation) + + packet = validate_point_in_time_universe(manifest, registry) + + assert packet.analysis_eligible is False + assert reason in { + code + for decision in packet.decisions.values() + for code in decision.reason_codes + } + + +def test_valid_fixture_passes_all_decisions_and_is_analysis_eligible(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + packet = validate_point_in_time_universe(manifest, registry) + + assert tuple(packet.decisions) == ( + "manifest_integrity", + "technical_validity", + "temporal_validity", + "identity_coverage", + "membership_coverage", + "corporate_action_coverage", + "delisting_coverage", + "source_rights_eligibility", + "reproduction_ready", + "leakage_safe", + ) + assert { + area: decision.status + for area, decision in packet.decisions.items() + } == { + "manifest_integrity": "passed", + "technical_validity": "passed", + "temporal_validity": "passed", + "identity_coverage": "passed", + "membership_coverage": "passed", + "corporate_action_coverage": "passed", + "delisting_coverage": "not_applicable", + "source_rights_eligibility": "passed", + "reproduction_ready": "passed", + "leakage_safe": "passed", + } + assert packet.analysis_eligible is True + + +def test_every_emitted_exclusion_uses_an_approved_stable_reason( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + observed_by_case = {} + for case in EXCLUSION_MUTATION_CASES: + case_root = tmp_path / case + case_root.mkdir() + manifest, registry = build_valid_package(case_root) + _mutate_exclusion_case(manifest, case) + packet = validate_point_in_time_universe( + manifest, + registry, + top_n=100, + ) + observed_by_case[case] = { + code + for item in packet.excluded + for code in item.reason_codes + } + + observed = set().union(*observed_by_case.values()) + observed_prefixes = { + prefix + for prefix in EXPECTED_REASON_PREFIXES + if any(code.startswith(prefix) for code in observed) + } + + assert all(observed_by_case.values()) + assert observed == EXPECTED_EXCLUSION_CODES + assert observed_prefixes == EXPECTED_EXCLUSION_PREFIXES + assert all( + any(code.startswith(prefix) for prefix in EXPECTED_REASON_PREFIXES) + for code in observed + ) + + +def test_source_rights_reasons_retain_exact_evidence_row_lineage( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + registry.write_text( + registry.read_text(encoding="utf-8").replace( + "commercial_use: approved", + "commercial_use: unverified", + ), + encoding="utf-8", + ) + _refresh_registry_digest(manifest, registry) + + packet = validate_point_in_time_universe(manifest, registry) + + assert packet.decisions["source_rights_eligibility"].reason_codes == ( + "source_rights_commercial_rights_unverified", + ) + assert { + (item.contract, item.source_row, item.row_id) + for item in packet.excluded + if "source_rights_commercial_rights_unverified" + in item.reason_codes + } == { + ("security_identity", 2, "id-1"), + ("membership", 2, "member-bench-1"), + ("membership", 3, "member-research-1"), + ("events", 2, "event-1"), + } + + +def test_validator_result_is_unchanged_when_current_universe_files_change( + tmp_path, + monkeypatch, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + monkeypatch.chdir(tmp_path) + first = validate_point_in_time_universe(manifest, registry) + data = tmp_path / "data" + data.mkdir() + current = data / "universe.csv" + master = data / "universe_master.csv" + current.write_text("ticker\nZZZ\n", encoding="utf-8") + master.write_text( + "ticker,is_active_listing\nAAA,false\n", + encoding="utf-8", + ) + after_add = validate_point_in_time_universe(manifest, registry) + current.write_text("ticker\nAAA\nBBB\n", encoding="utf-8") + master.write_text( + "ticker,is_active_listing\nZZZ,true\n", + encoding="utf-8", + ) + after_change = validate_point_in_time_universe(manifest, registry) + + assert first == after_add == after_change + + +def test_validator_uses_verified_contract_bytes_when_path_changes_after_load( + tmp_path, + monkeypatch, +): + import src.point_in_time_universe as universe_module + + manifest, registry = build_valid_package(tmp_path) + real_load = universe_module.load_universe_package + + def load_then_replace_contract(manifest_path, registry_path): + package = real_load(manifest_path, registry_path) + identity_path = package.files["security_identity"] + identity_path.write_text( + identity_path.read_text(encoding="utf-8").replace(",AAA,", ",RACED,"), + encoding="utf-8", + ) + return package + + monkeypatch.setattr( + universe_module, + "load_universe_package", + load_then_replace_contract, + ) + + packet = universe_module.validate_point_in_time_universe(manifest, registry) + + assert packet.decisions["manifest_integrity"].status == "passed" + assert packet.display_tickers["sec-1"] == "AAA" + assert "RACED" not in packet.display_tickers.values() + + +def test_validator_uses_verified_registry_bytes_when_path_changes_after_load( + tmp_path, + monkeypatch, +): + import src.point_in_time_universe as universe_module + + manifest, registry = build_valid_package(tmp_path) + real_load = universe_module.load_universe_package + + def load_then_replace_registry(manifest_path, registry_path): + package = real_load(manifest_path, registry_path) + registry.write_text( + registry.read_text(encoding="utf-8").replace( + "commercial_use: approved", + "commercial_use: unverified", + ), + encoding="utf-8", + ) + return package + + monkeypatch.setattr( + universe_module, + "load_universe_package", + load_then_replace_registry, + ) + + packet = universe_module.validate_point_in_time_universe(manifest, registry) + + assert packet.decisions["manifest_integrity"].status == "passed" + assert packet.decisions["source_rights_eligibility"].status == "passed" + assert not packet.decisions["source_rights_eligibility"].reason_codes + + +def test_blocked_independent_decision_prevents_final_eligibility(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + registry.write_text( + registry.read_text().replace( + "commercial_use: approved", + "commercial_use: unverified", + ), + encoding="utf-8", + ) + _refresh_registry_digest(manifest, registry) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "source_rights_eligibility").status == "blocked" + assert _decision(packet, "delisting_coverage").status == "not_applicable" + assert packet.analysis_eligible is False + + +def test_not_applicable_is_accepted_only_for_delisting_contract(tmp_path): + from src.point_in_time_universe import ( + Decision, + _final_eligibility, + validate_point_in_time_universe, + ) + + manifest, registry = build_valid_package(tmp_path) + packet = validate_point_in_time_universe(manifest, registry) + decisions = dict(packet.decisions) + decisions["temporal_validity"] = Decision( + "temporal_validity", + "not_applicable", + (), + ) + declared = json.loads(manifest.read_text())["declared_universes"] + + assert _decision(packet, "delisting_coverage").status == "not_applicable" + assert packet.analysis_eligible is True + assert _final_eligibility( + decisions, + packet.membership_digests, + declared, + ) is False + + +def test_evaluation_after_manifest_cutoff_is_classified_not_silently_dropped( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + def add_post_manifest_evaluation(rows): + rows.append( + { + **rows[-1], + "evaluation_row_id": "eval-after-manifest-cutoff", + "evaluation_at": "2022-01-01T00:00:00Z", + "available_at": "2022-01-01T00:00:00Z", + "source_ref": "fixture://evaluation/after-manifest-cutoff", + } + ) + + _rewrite_csv_and_manifest( + manifest, + "evaluations", + add_post_manifest_evaluation, + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "temporal_validity").status == "blocked" + assert _decision(packet, "temporal_validity").reason_codes == ( + "cutoff_evaluation_after_manifest", + ) + assert _decision(packet, "leakage_safe").status == "blocked" + assert ( + "leakage_evaluation_after_manifest_cutoff" + in _decision(packet, "leakage_safe").reason_codes + ) + assert _decision(packet, "reproduction_ready").status == "passed" + assert _decision(packet, "reproduction_ready").reason_codes == () + assert any( + row.contract == "evaluations" + and row.row_id == "eval-after-manifest-cutoff" + and row.reason_codes + == ( + "cutoff_evaluation_after_manifest", + "leakage_evaluation_after_manifest_cutoff", + ) + for row in packet.excluded + ) + assert tuple( + digest.universe_id + for digest in packet.membership_digests + ) == ("bench-1", "research-1") + assert packet.analysis_eligible is False + + +def test_walk_forward_policy_rejects_non_walk_forward_evaluation_rows( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _rewrite_manifest( + manifest, + lambda raw: raw.update( + evaluation_policy={ + "kind": "walk_forward", + "minimum_history_count": 1, + } + ), + ) + _rewrite_csv_and_manifest( + manifest, + "evaluations", + lambda rows: [ + row.update(partition="walk_forward") + for row in rows + ], + ) + _rewrite_csv_and_manifest( + manifest, + "evaluations", + lambda rows: [ + row.update(partition="test") + for row in rows + ], + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "leakage_safe").status == "blocked" + assert _decision(packet, "leakage_safe").reason_codes == ( + "partition_assignment_invalid", + ) + assert { + row.row_id: row.reason_codes + for row in packet.excluded + if row.contract == "evaluations" + } == { + "eval-bench-1": ("partition_assignment_invalid",), + "eval-research-1": ("partition_assignment_invalid",), + } + assert packet.analysis_eligible is False + + +def test_train_validation_test_policy_enforces_row_time_boundaries(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _rewrite_manifest( + manifest, + lambda raw: raw.update( + evaluation_policy={ + "kind": "train_validation_test", + "train_end_at": "2020-06-01T00:00:00Z", + "validation_start_at": "2020-08-01T00:00:00Z", + "validation_end_at": "2021-06-01T00:00:00Z", + "test_start_at": "2021-08-01T00:00:00Z", + } + ), + ) + _rewrite_csv_and_manifest( + manifest, + "evaluations", + lambda rows: [ + row.update( + evaluation_at="2020-07-01T00:00:00Z", + available_at="2020-07-01T00:00:00Z", + partition="train", + ) + for row in rows + ], + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "leakage_safe").status == "blocked" + assert _decision(packet, "leakage_safe").reason_codes == ( + "partition_boundary_unassigned", + ) + assert all( + row.reason_codes == ("partition_boundary_unassigned",) + for row in packet.excluded + if row.contract == "evaluations" + ) + assert packet.analysis_eligible is False + + +def test_train_validation_test_policy_rejects_wrong_row_assignment(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _rewrite_manifest( + manifest, + lambda raw: raw.update( + evaluation_policy={ + "kind": "train_validation_test", + "train_end_at": "2020-06-01T00:00:00Z", + "validation_start_at": "2020-08-01T00:00:00Z", + "validation_end_at": "2021-06-01T00:00:00Z", + "test_start_at": "2021-08-01T00:00:00Z", + } + ), + ) + _rewrite_csv_and_manifest( + manifest, + "evaluations", + lambda rows: [ + row.update(partition="train") + for row in rows + ], + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "leakage_safe").status == "blocked" + assert _decision(packet, "leakage_safe").reason_codes == ( + "partition_assignment_invalid", + ) + assert all( + row.reason_codes == ("partition_assignment_invalid",) + for row in packet.excluded + if row.contract == "evaluations" + ) + assert packet.analysis_eligible is False + + +def test_walk_forward_minimum_history_uses_actual_universe_evaluations( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _rewrite_manifest( + manifest, + lambda raw: raw.update( + evaluation_policy={ + "kind": "walk_forward", + "minimum_history_count": 2, + } + ), + ) + _rewrite_csv_and_manifest( + manifest, + "evaluations", + lambda rows: [ + row.update(partition="walk_forward") + for row in rows + ], + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "leakage_safe").status == "passed" + assert _decision(packet, "leakage_safe").reason_codes == () + assert _decision(packet, "membership_coverage").status == "blocked" + assert "membership_no_evaluation" in _decision( + packet, + "membership_coverage", + ).reason_codes + assert packet.membership_digests == () + assert { + row.row_id: row.reason_codes + for row in packet.excluded + if row.contract == "evaluations" + } == { + "eval-bench-1": ("partition_minimum_history_unmet",), + "eval-research-1": ("partition_minimum_history_unmet",), + } + assert packet.analysis_eligible is False + + +def test_later_identity_and_event_revisions_do_not_poison_earlier_evaluation( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + def add_later_identity_revision(rows): + rows.append( + { + **rows[0], + "identity_row_id": "id-later-revision", + "ticker": "BBB", + "valid_from": "2022-01-01T00:00:00Z", + "source_ref": "fixture://identity/later-revision", + "source_published_at": "2022-01-01T00:00:00Z", + "retrieved_at": "2022-01-02T00:00:00Z", + "supersedes_identity_row_id": rows[0][ + "identity_row_id" + ], + } + ) + + def add_later_event_revision(rows): + rows.append( + { + **rows[0], + "event_row_id": "event-later-revision", + "effective_at": "2022-01-01T00:00:00Z", + "source_ref": "fixture://event/later-revision", + "source_published_at": "2022-01-01T00:00:00Z", + "retrieved_at": "2022-01-02T00:00:00Z", + "supersedes_event_row_id": rows[0]["event_row_id"], + } + ) + + _rewrite_csv_and_manifest( + manifest, + "security_identity", + add_later_identity_revision, + ) + _rewrite_csv_and_manifest( + manifest, + "events", + add_later_event_revision, + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "temporal_validity").status == "passed" + assert _decision(packet, "leakage_safe").status == "passed" + assert _decision(packet, "identity_coverage").status == "passed" + assert _decision(packet, "corporate_action_coverage").status == "passed" + assert packet.display_tickers == {"sec-1": "AAA"} + assert { + row.row_id: row.reason_codes + for row in packet.excluded + if row.row_id + in {"id-later-revision", "event-later-revision"} + } == { + "event-later-revision": ("cutoff_later_revision_invisible",), + "id-later-revision": ("cutoff_later_revision_invisible",), + } + assert packet.analysis_eligible is True + + +def test_unrelated_future_identity_and_event_scopes_do_not_poison_evaluation( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + def add_unrelated_identity(rows): + rows.append( + { + **rows[0], + "identity_row_id": "id-unrelated-future", + "security_id": "sec-unrelated", + "issuer_id": "issuer-unrelated", + "ticker": "ZZZ", + "valid_from": "2022-01-01T00:00:00Z", + "source_ref": "fixture://identity/unrelated-future", + "source_published_at": "2022-01-01T00:00:00Z", + "retrieved_at": "2022-01-02T00:00:00Z", + "supersedes_identity_row_id": "", + } + ) + + def add_unrelated_event(rows): + rows.append( + { + **rows[0], + "event_row_id": "event-unrelated-future", + "security_id": "sec-unrelated", + "effective_at": "2022-01-01T00:00:00Z", + "source_ref": "fixture://event/unrelated-future", + "source_published_at": "2022-01-01T00:00:00Z", + "retrieved_at": "2022-01-02T00:00:00Z", + "supersedes_event_row_id": "", + } + ) + + _rewrite_csv_and_manifest( + manifest, + "security_identity", + add_unrelated_identity, + ) + _rewrite_csv_and_manifest( + manifest, + "events", + add_unrelated_event, + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "temporal_validity").status == "passed" + assert _decision(packet, "leakage_safe").status == "passed" + assert _decision(packet, "identity_coverage").status == "passed" + assert _decision(packet, "corporate_action_coverage").status == "passed" + assert { + row.row_id: row.reason_codes + for row in packet.excluded + if row.row_id + in {"id-unrelated-future", "event-unrelated-future"} + } == { + "event-unrelated-future": ("cutoff_unrelated_scope_invisible",), + "id-unrelated-future": ("cutoff_unrelated_scope_invisible",), + } + assert packet.analysis_eligible is True + + +def test_complete_snapshot_valid_fixture_remains_eligible_and_deterministic( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + first = validate_point_in_time_universe(manifest, registry) + second = validate_point_in_time_universe(manifest, registry) + + assert _decision(first, "membership_coverage").status == "passed" + assert _decision(first, "reproduction_ready").status == "passed" + assert first.membership_digests == second.membership_digests + assert first.analysis_eligible is True + + +def test_complete_snapshot_does_not_infer_exclusion_from_omission( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + def add_second_identity(rows): + rows.append( + { + **rows[0], + "identity_row_id": "id-2", + "security_id": "sec-2", + "issuer_id": "issuer-2", + "ticker": "BBB", + "source_ref": "fixture://identity/id-2", + } + ) + + def add_later_benchmark_snapshot(rows): + rows.append( + { + **rows[0], + "membership_row_id": "member-bench-later", + "security_id": "sec-2", + "effective_from": "2020-06-01T00:00:00Z", + "observation_at": "2020-06-01T00:00:00Z", + "source_ref": "fixture://membership/bench-later", + "source_published_at": "2020-06-01T00:00:00Z", + "retrieved_at": "2020-06-02T00:00:00Z", + } + ) + + def add_second_listing_event(rows): + rows.append( + { + **rows[0], + "event_row_id": "event-sec-2-listing", + "security_id": "sec-2", + "source_ref": "fixture://event/sec-2-listing", + } + ) + + _rewrite_csv_and_manifest( + manifest, + "security_identity", + add_second_identity, + ) + _rewrite_csv_and_manifest( + manifest, + "membership", + add_later_benchmark_snapshot, + ) + _rewrite_csv_and_manifest( + manifest, + "events", + add_second_listing_event, + ) + + packet = validate_point_in_time_universe(manifest, registry) + digests = _digest_by_universe(packet) + + assert _decision(packet, "membership_coverage").status == "blocked" + assert _decision(packet, "membership_coverage").reason_codes == ( + "membership_snapshot_omission_unexplained", + ) + assert _decision(packet, "reproduction_ready").status == "blocked" + assert _decision(packet, "reproduction_ready").reason_codes == ( + "reproduction_digest_missing", + ) + assert "bench-1" not in digests + assert digests["research-1"].member_count == 1 + assert digests["research-1"].sha256 == _sha256_members("sec-1") + assert next( + row + for row in packet.excluded + if row.row_id == "member-bench-1" + ).reason_codes == ("membership_snapshot_omission_unexplained",) + assert packet.analysis_eligible is False + assert packet.analysis_eligible_rows == () + + +def test_event_history_is_blocked_without_a_trusted_membership_digest( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _rewrite_manifest( + manifest, + lambda raw: raw.update(coverage_semantics="event_history"), + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "membership_coverage").status == "blocked" + assert _decision(packet, "membership_coverage").reason_codes == ( + "membership_coverage_semantics_unsupported", + ) + assert _decision(packet, "reproduction_ready").status == "blocked" + assert _decision(packet, "reproduction_ready").reason_codes == ( + "reproduction_coverage_semantics_unsupported", + ) + assert packet.membership_digests == () + assert packet.analysis_eligible is False + + +@pytest.mark.parametrize("member", [True, False]) +def test_reversed_identity_interval_is_excluded_for_member_and_non_member( + tmp_path, + member, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + def reverse_interval(rows): + if member: + rows[0].update( + valid_from="2020-06-01T00:00:00Z", + valid_to="2020-05-01T00:00:00Z", + ) + else: + rows.append( + { + **rows[0], + "identity_row_id": "id-reversed-non-member", + "security_id": "sec-non-member", + "issuer_id": "issuer-non-member", + "source_ref": ( + "fixture://identity/id-reversed-non-member" + ), + "valid_from": "2020-06-01T00:00:00Z", + "valid_to": "2020-05-01T00:00:00Z", + } + ) + + _rewrite_csv_and_manifest( + manifest, + "security_identity", + reverse_interval, + ) + + packet = validate_point_in_time_universe(manifest, registry) + row_id = "id-1" if member else "id-reversed-non-member" + + assert _decision(packet, "technical_validity").status == "blocked" + assert _decision(packet, "technical_validity").reason_codes == ( + "schema_identity_interval_reversed", + ) + assert ( + "security_identity", + row_id, + ("schema_identity_interval_reversed",), + ) in { + (row.contract, row.row_id, row.reason_codes) + for row in packet.excluded + } + assert packet.analysis_eligible is False + + +@pytest.mark.parametrize("active", [True, False]) +def test_reversed_membership_interval_is_excluded_when_active_or_irrelevant( + tmp_path, + active, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + def reverse_interval(rows): + if active: + rows[0].update( + effective_from="2020-06-01T00:00:00Z", + effective_to="2020-05-01T00:00:00Z", + ) + else: + rows.append( + { + **rows[0], + "membership_row_id": "member-reversed-irrelevant", + "security_id": "sec-non-member", + "membership_state": "excluded", + "effective_from": "2020-06-01T00:00:00Z", + "effective_to": "2020-05-01T00:00:00Z", + "source_ref": ( + "fixture://membership/reversed-irrelevant" + ), + } + ) + + _rewrite_csv_and_manifest( + manifest, + "membership", + reverse_interval, + ) + + packet = validate_point_in_time_universe(manifest, registry) + row_id = ( + "member-bench-1" if active else "member-reversed-irrelevant" + ) + + assert _decision(packet, "technical_validity").status == "blocked" + assert _decision(packet, "technical_validity").reason_codes == ( + "schema_membership_interval_reversed", + ) + assert ( + "membership", + row_id, + ("schema_membership_interval_reversed",), + ) in { + (row.contract, row.row_id, row.reason_codes) + for row in packet.excluded + } + assert packet.analysis_eligible is False + + +@pytest.mark.parametrize("across_universes", [False, True]) +def test_duplicate_evaluation_ids_exclude_every_ambiguous_row( + tmp_path, + across_universes, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + def duplicate_evaluation(rows): + if across_universes: + rows[1]["evaluation_row_id"] = rows[0]["evaluation_row_id"] + else: + rows.append( + { + **rows[0], + "source_ref": "fixture://evaluation/bench-duplicate", + } + ) + + _rewrite_csv_and_manifest( + manifest, + "evaluations", + duplicate_evaluation, + ) + + packet = validate_point_in_time_universe(manifest, registry) + duplicate_id = "eval-bench-1" + duplicate_exclusions = [ + row + for row in packet.excluded + if row.contract == "evaluations" and row.row_id == duplicate_id + ] + + assert _decision(packet, "technical_validity").status == "blocked" + assert _decision(packet, "technical_validity").reason_codes == ( + "schema_evaluation_row_id_duplicate", + ) + assert len(duplicate_exclusions) == 2 + assert all( + row.reason_codes == ("schema_evaluation_row_id_duplicate",) + for row in duplicate_exclusions + ) + assert all( + digest.universe_id != "bench-1" + for digest in packet.membership_digests + ) + if across_universes: + assert packet.membership_digests == () + assert packet.analysis_eligible is False + + +def test_distinct_evaluation_ids_at_same_timestamp_remain_valid(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + def add_distinct_same_cutoff(rows): + rows.append( + { + **rows[0], + "evaluation_row_id": "eval-bench-distinct-2", + } + ) + + _rewrite_csv_and_manifest( + manifest, + "evaluations", + add_distinct_same_cutoff, + ) + + packet = validate_point_in_time_universe(manifest, registry) + benchmark_digests = [ + digest + for digest in packet.membership_digests + if ( + digest.universe_id == "bench-1" + and digest.evaluation_at == "2021-01-01T00:00:00Z" + ) + ] + + assert _decision(packet, "technical_validity").status == "passed" + assert _decision(packet, "reproduction_ready").status == "passed" + assert len(benchmark_digests) == 1 + assert { + digest.universe_id for digest in packet.membership_digests + } == {"bench-1", "research-1"} + assert packet.analysis_eligible is True diff --git a/tests/test_point_in_time_universe_cli.py b/tests/test_point_in_time_universe_cli.py new file mode 100644 index 00000000..fd05eb4c --- /dev/null +++ b/tests/test_point_in_time_universe_cli.py @@ -0,0 +1,895 @@ +import csv +import hashlib +import json +import os +from pathlib import Path +import subprocess +import sys + +import pytest + +from tests.point_in_time_universe_remediation_fixtures import ( + walk_forward_rows, +) +from tests.point_in_time_universe_fixture import build_valid_package +from tests.test_point_in_time_universe import mutate_identity_membership_case +from tests.test_point_in_time_universe import ( + _replace_contract_rows, + _rewrite_manifest, +) +from tests.test_point_in_time_universe_contracts import ( + _rewrite_csv_and_manifest, +) + + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +TARGETS = ( + "point-in-time-universe-status", + "point-in-time-universe-preview", +) +WRITE_LIKE_OPTIONS = ( + "--output", + "--apply", + "--record", + "--stage", + "--refresh", + "--write", +) +WRITE_LIKE_MODES = ("apply", "record", "stage", "refresh", "write") + + +def _snapshot(root: Path) -> dict[str, tuple[str, bytes]]: + snapshot: dict[str, tuple[str, bytes]] = {} + for path in sorted(root.rglob("*")): + relative = path.relative_to(root).as_posix() + if path.is_symlink(): + snapshot[relative] = ("symlink", os.fsencode(os.readlink(path))) + elif path.is_dir(): + snapshot[relative] = ("directory", b"") + elif path.is_file(): + snapshot[relative] = ("file", path.read_bytes()) + else: + snapshot[relative] = ("other", b"") + return snapshot + + +def _run_cli(*arguments: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, "-m", "src.point_in_time_universe", *arguments], + cwd=PROJECT_ROOT, + check=False, + capture_output=True, + text=True, + env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"}, + ) + + +def _run_make(*arguments: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["make", "--no-print-directory", *arguments], + cwd=PROJECT_ROOT, + check=False, + capture_output=True, + text=True, + env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"}, + ) + + +def _block_source_rights(manifest: Path, registry: Path) -> None: + registry.write_text( + registry.read_text(encoding="utf-8").replace( + "commercial_use: approved", + "commercial_use: unverified", + ), + encoding="utf-8", + ) + raw = json.loads(manifest.read_text(encoding="utf-8")) + raw["source_rights_registry_sha256"] = hashlib.sha256( + registry.read_bytes() + ).hexdigest() + manifest.write_text(json.dumps(raw), encoding="utf-8") + + +def _replace_with_deeply_nested_input( + manifest: Path, + registry: Path, + resource: str, + depth: int = 2_000, +) -> str: + nested = ("[" * depth + "0" + "]" * depth).encode("utf-8") + if resource == "manifest": + manifest.write_bytes(nested) + return "manifest_unreadable" + + registry.write_bytes(nested) + raw = json.loads(manifest.read_text(encoding="utf-8")) + raw["source_rights_registry_sha256"] = hashlib.sha256(nested).hexdigest() + manifest.write_text(json.dumps(raw), encoding="utf-8") + return "source_rights_registry_unreadable" + + +def test_renderers_and_cli_are_deterministic_complete_and_read_only(tmp_path): + from src.point_in_time_universe import ( + DECISION_ORDER, + render_preview, + render_status, + validate_point_in_time_universe, + ) + + manifest, registry = build_valid_package(tmp_path) + before = _snapshot(tmp_path) + packet = validate_point_in_time_universe(manifest, registry, top_n=5) + + status = render_status(packet) + preview = render_preview(packet, top_n=5) + assert render_status(packet) == status + assert render_preview(packet, top_n=5) == preview + + for output in (status, preview): + assert ( + "Read-only: validates one supplied immutable package; it does not " + "fetch, write, apply, refresh, or rebuild data." + ) in output + assert ( + "Research-only: this does not activate readiness, backtesting, " + "calibration, or probability and is not investment advice." + ) in output + assert "dataset_id: fixture-dataset" in output + assert "manifest_id: fixture-manifest" in output + assert "analysis_eligible: true" in output + assert "raw_count: 6" in output + assert "normalized_count: 6" in output + assert "excluded_count: 0" in output + assert "analysis_eligible_row_count: 8" in output + assert f"boundary: {packet.boundary}" in output + for name in DECISION_ORDER: + decision = packet.decisions[name] + reasons = ",".join(decision.reason_codes) or "none" + assert f"{name}: {decision.status}; reasons={reasons}" in output + + assert len(DECISION_ORDER) == 10 + assert "Membership reproduction:" in preview + assert "Exclusion reason counts:" in preview + assert "- none" in preview + for digest in packet.membership_digests: + assert ( + f"- {digest.universe_id} @ {digest.evaluation_at}: " + f"members={digest.member_count}; sha256={digest.sha256}" + ) in preview + assert "Excluded sample:" in preview + + status_results = [ + _run_cli( + "status", + "--manifest", + str(manifest), + "--registry", + str(registry), + "--top-n", + "5", + ) + for _ in range(2) + ] + preview_results = [ + _run_cli( + "preview", + "--manifest", + str(manifest), + "--registry", + str(registry), + "--top-n", + "5", + ) + for _ in range(2) + ] + + assert [result.returncode for result in status_results] == [0, 0] + assert [result.returncode for result in preview_results] == [0, 0] + assert status_results[0].stdout == status_results[1].stdout == f"{status}\n" + assert ( + preview_results[0].stdout + == preview_results[1].stdout + == f"{preview}\n" + ) + assert all(not result.stderr for result in status_results + preview_results) + assert _snapshot(tmp_path) == before + + +def test_synthetic_package_status_is_local_software_evidence_only(tmp_path): + from src.point_in_time_universe import ( + render_status, + validate_point_in_time_universe, + ) + + manifest, registry = build_valid_package(tmp_path) + + output = render_status( + validate_point_in_time_universe(manifest, registry), + ) + + assert ( + "Synthetic or technically valid packages are local software " + "evidence only." + ) in output + assert ( + "Priority 4 still requires one independently reviewed, permitted " + "real dataset." + ) in output + for prohibited_claim in ( + "Priority 4 complete", + "real-world validation complete", + "readiness activated", + "backtesting activated", + "calibration complete", + "probability available", + "recommendation:", + "investment advice:", + ): + assert prohibited_claim not in output + + +def test_preview_caps_canonically_sorted_exclusions_without_writing(tmp_path): + from src.point_in_time_universe import ( + render_preview, + validate_point_in_time_universe, + ) + + manifest, registry = build_valid_package(tmp_path) + mutate_identity_membership_case(manifest, "overlapping_identity") + before = _snapshot(tmp_path) + packet = validate_point_in_time_universe( + manifest, + registry, + top_n=0, + ) + + one = _run_cli( + "preview", + "--manifest", + str(manifest), + "--registry", + str(registry), + "--top-n", + "1", + ) + none = _run_cli( + "preview", + "--manifest", + str(manifest), + "--registry", + str(registry), + "--top-n", + "0", + ) + + assert one.returncode == none.returncode == 0 + assert "Membership reproduction:" in one.stdout + one_exclusions = one.stdout.partition("Excluded sample:\n")[2].splitlines() + no_exclusions = none.stdout.partition("Excluded sample:\n")[2].splitlines() + assert one_exclusions == [ + "- membership:2:member-bench-1; reasons=identity_interval_overlap" + ] + assert no_exclusions == [] + assert len(packet.excluded) > 1 + assert packet.excluded_count == len(packet.excluded) + assert ( + "identity_interval_overlap: 4" + in render_preview(packet, top_n=1) + ) + for proprietary_value in ( + "AAA", + "fixture://identity/id-1", + "fixture://membership/bench-1", + ): + assert proprietary_value not in one.stdout + assert _snapshot(tmp_path) == before + + +def test_readable_blocked_package_returns_zero_with_blocked_truth(tmp_path): + manifest, registry = build_valid_package(tmp_path) + _block_source_rights(manifest, registry) + before = _snapshot(tmp_path) + + result = _run_cli( + "status", + "--manifest", + str(manifest), + "--registry", + str(registry), + ) + + assert result.returncode == 0 + assert "analysis_eligible: false" in result.stdout + assert ( + "source_rights_eligibility: blocked; " + "reasons=source_rights_commercial_rights_unverified" + ) in result.stdout + assert "Traceback" not in result.stderr + assert _snapshot(tmp_path) == before + + +def test_publication_chronology_failure_is_readable_and_write_free(tmp_path): + manifest, registry = build_valid_package(tmp_path) + _rewrite_csv_and_manifest( + manifest, + "events", + lambda rows: rows[0].update( + source_published_at="2020-01-03T00:00:00Z", + retrieved_at="2020-01-02T00:00:00Z", + ), + ) + before = _snapshot(tmp_path) + + result = _run_cli( + "status", + "--manifest", + str(manifest), + "--registry", + str(registry), + ) + + assert result.returncode == 0 + assert ( + "technical_validity: blocked; " + "reasons=schema_retrieved_before_publication" + ) in result.stdout + assert "analysis_eligible: false" in result.stdout + assert "Traceback" not in result.stderr + assert _snapshot(tmp_path) == before + + +def test_walk_forward_preview_shows_bootstrap_exclusions_and_later_unlock_write_free( + tmp_path, +): + manifest, registry = build_valid_package(tmp_path) + _replace_contract_rows( + manifest, + "evaluations", + list(reversed(walk_forward_rows())), + ) + _rewrite_manifest( + manifest, + lambda raw: raw.update( + observation_cutoff_at="2023-01-01T00:00:00Z", + evaluation_policy={ + "kind": "walk_forward", + "minimum_history_count": 2, + }, + ), + ) + before = _snapshot(tmp_path) + + result = _run_cli( + "preview", + "--manifest", + str(manifest), + "--registry", + str(registry), + "--top-n", + "10", + ) + + assert result.returncode == 0 + assert "analysis_eligible: true" in result.stdout + assert "leakage_safe: passed; reasons=none" in result.stdout + assert ( + "- bench-1 @ 2023-01-01T00:00:00Z: members=1;" + in result.stdout + ) + assert ( + "- research-1 @ 2023-01-01T00:00:00Z: members=1;" + in result.stdout + ) + assert "eval-bench-1-2021" in result.stdout + assert "eval-bench-1-2022" in result.stdout + assert "eval-bench-1-2023" not in result.stdout.partition( + "Excluded sample:\n" + )[2] + assert "Traceback" not in result.stderr + assert _snapshot(tmp_path) == before + + +def test_missing_manifest_is_nonzero_without_traceback(): + result = _run_cli("status") + + assert result.returncode == 2 + assert "MANIFEST is required" in result.stderr + assert "Traceback" not in result.stderr + + +@pytest.mark.parametrize( + "top_n", + ("-1", "101", "true", "false", "1.5", "not-a-number"), +) +def test_invalid_top_n_is_nonzero_without_traceback(tmp_path, top_n): + manifest, registry = build_valid_package(tmp_path) + before = _snapshot(tmp_path) + + result = _run_cli( + "preview", + "--manifest", + str(manifest), + "--registry", + str(registry), + "--top-n", + top_n, + ) + + assert result.returncode != 0 + assert "Traceback" not in result.stderr + assert _snapshot(tmp_path) == before + + +def test_preview_top_n_boundaries_and_packet_independence(tmp_path): + from src.point_in_time_universe import ( + render_preview, + validate_point_in_time_universe, + ) + + manifest, registry = build_valid_package(tmp_path) + before = _snapshot(tmp_path) + packet_zero = validate_point_in_time_universe( + manifest, + registry, + top_n=0, + ) + packet_max = validate_point_in_time_universe( + manifest, + registry, + top_n=100, + ) + + assert packet_zero == packet_max + assert render_preview(packet_zero, top_n=0) + assert render_preview(packet_max, top_n=100) + for invalid in (-1, 101, True, 1.5, "1"): + with pytest.raises(ValueError, match="top_n_invalid"): + validate_point_in_time_universe( + manifest, + registry, + top_n=invalid, + ) + with pytest.raises(ValueError, match="top_n_invalid"): + render_preview(packet_zero, top_n=invalid) + assert _snapshot(tmp_path) == before + + +@pytest.mark.parametrize( + "resource,error", + [ + ("manifest", "manifest_size_limit_exceeded"), + ("contract", "manifest_file_size_limit_exceeded"), + ("combined", "manifest_total_snapshot_size_limit_exceeded"), + ("registry", "manifest_registry_size_limit_exceeded"), + ("rows", "manifest_row_count_limit_exceeded"), + ("traversal", "manifest_package_entry_limit_exceeded"), + ], +) +def test_cli_and_make_resource_failures_are_readable_nonwriting( + tmp_path, + resource, + error, +): + from src.point_in_time_universe_manifest import ( + MAX_CONTRACT_SNAPSHOT_BYTES, + MAX_MANIFEST_BYTES, + MAX_PACKAGE_TRAVERSAL_ENTRIES, + MAX_RIGHTS_REGISTRY_BYTES, + MAX_TOTAL_CONTRACT_SNAPSHOT_BYTES, + ) + + manifest, registry = build_valid_package(tmp_path) + raw = json.loads(manifest.read_text(encoding="utf-8")) + records = { + item["contract"]: item + for item in raw["files"] + } + if resource == "manifest": + manifest.write_bytes( + manifest.read_bytes() + + b" " * (MAX_MANIFEST_BYTES + 1 - manifest.stat().st_size) + ) + elif resource == "contract": + identity = manifest.parent / records["security_identity"]["path"] + identity.write_bytes(b"\n" * (MAX_CONTRACT_SNAPSHOT_BYTES + 1)) + elif resource == "combined": + target_size = MAX_TOTAL_CONTRACT_SNAPSHOT_BYTES // 3 + 1 + for contract in ("security_identity", "membership", "events"): + path = manifest.parent / records[contract]["path"] + path.write_bytes( + path.read_bytes() + + b"\n" * (target_size - path.stat().st_size) + ) + records[contract]["sha256"] = hashlib.sha256( + path.read_bytes() + ).hexdigest() + manifest.write_text(json.dumps(raw), encoding="utf-8") + elif resource == "registry": + registry.write_bytes( + registry.read_bytes() + + b" " * (MAX_RIGHTS_REGISTRY_BYTES + 1 - registry.stat().st_size) + ) + elif resource == "rows": + records["security_identity"]["row_count"] = 250_001 + manifest.write_text(json.dumps(raw), encoding="utf-8") + else: + current_entries = sum(1 for _ in manifest.parent.iterdir()) + for index in range( + MAX_PACKAGE_TRAVERSAL_ENTRIES - current_entries + 1 + ): + (manifest.parent / f"empty-{index:02d}").mkdir() + + before = _snapshot(tmp_path) + cli = _run_cli( + "preview", + "--manifest", + str(manifest), + "--registry", + str(registry), + ) + make = _run_make( + "point-in-time-universe-preview", + f"MANIFEST={manifest}", + f"REGISTRY={registry}", + ) + + for result in (cli, make): + assert result.returncode != 0 + assert error in result.stderr + assert "Traceback" not in result.stderr + assert _snapshot(tmp_path) == before + + +@pytest.mark.parametrize("mode", ("unknown", *WRITE_LIKE_MODES)) +def test_unknown_and_write_like_modes_are_rejected_without_traceback( + tmp_path, + mode, +): + manifest, registry = build_valid_package(tmp_path) + before = _snapshot(tmp_path) + + result = _run_cli( + mode, + "--manifest", + str(manifest), + "--registry", + str(registry), + ) + + assert result.returncode != 0 + assert "Traceback" not in result.stderr + assert _snapshot(tmp_path) == before + + +@pytest.mark.parametrize("option", WRITE_LIKE_OPTIONS) +def test_write_like_options_are_rejected_without_traceback(tmp_path, option): + manifest, registry = build_valid_package(tmp_path) + before = _snapshot(tmp_path) + + result = _run_cli( + "status", + "--manifest", + str(manifest), + "--registry", + str(registry), + option, + ) + + assert result.returncode != 0 + assert "Traceback" not in result.stderr + assert _snapshot(tmp_path) == before + + +def test_invalid_package_is_nonzero_without_traceback_or_writes(tmp_path): + manifest, registry = build_valid_package(tmp_path) + manifest.write_text("{not valid json", encoding="utf-8") + before = _snapshot(tmp_path) + + result = _run_cli( + "status", + "--manifest", + str(manifest), + "--registry", + str(registry), + ) + + assert result.returncode == 2 + assert "manifest_unreadable" in result.stderr + assert "Traceback" not in result.stderr + assert _snapshot(tmp_path) == before + + +@pytest.mark.parametrize("resource", ("manifest", "registry")) +def test_cli_and_make_deep_parser_failures_are_readable_nonwriting( + tmp_path, + resource, +): + manifest, registry = build_valid_package(tmp_path) + error = _replace_with_deeply_nested_input( + manifest, + registry, + resource, + ) + before = _snapshot(tmp_path) + + cli = _run_cli( + "status", + "--manifest", + str(manifest), + "--registry", + str(registry), + ) + make = _run_make( + "point-in-time-universe-status", + f"MANIFEST={manifest}", + f"REGISTRY={registry}", + ) + + for result in (cli, make): + assert result.returncode == 2 + assert error in result.stderr + assert "Traceback" not in result.stderr + assert _snapshot(tmp_path) == before + + +@pytest.mark.parametrize("mode", ("status", "preview")) +def test_oversized_manifest_bound_csv_is_controlled_and_read_only( + tmp_path, + mode, +): + manifest, registry = build_valid_package(tmp_path) + oversized = "A" * (csv.field_size_limit() + 1) + _rewrite_csv_and_manifest( + manifest, + "security_identity", + lambda rows: rows[0].update(ticker=oversized), + ) + before = _snapshot(tmp_path) + + result = _run_cli( + mode, + "--manifest", + str(manifest), + "--registry", + str(registry), + ) + + assert result.returncode == 2 + assert "field larger than field limit" in result.stderr + assert "Traceback" not in result.stderr + assert _snapshot(tmp_path) == before + + +@pytest.mark.parametrize("mode", ("status", "preview")) +def test_manifest_referenced_self_loop_symlink_is_controlled_and_read_only( + tmp_path, + mode, +): + manifest, registry = build_valid_package(tmp_path) + raw = json.loads(manifest.read_text(encoding="utf-8")) + identity_record = next( + item + for item in raw["files"] + if item["contract"] == "security_identity" + ) + identity = manifest.parent / identity_record["path"] + identity.unlink() + identity.symlink_to(identity.name) + before = _snapshot(tmp_path) + + result = _run_cli( + mode, + "--manifest", + str(manifest), + "--registry", + str(registry), + ) + + assert result.returncode == 2 + assert "symlink" in result.stderr.lower() + assert "Traceback" not in result.stderr + assert _snapshot(tmp_path) == before + + +def test_make_targets_match_cli_and_leave_package_root_byte_identical(tmp_path): + manifest, registry = build_valid_package(tmp_path) + before = _snapshot(tmp_path) + + for mode, target in zip(("status", "preview"), TARGETS, strict=True): + cli = _run_cli( + mode, + "--manifest", + str(manifest), + "--registry", + str(registry), + "--top-n", + "5", + ) + make = _run_make( + target, + f"MANIFEST={manifest}", + f"REGISTRY={registry}", + "TOP_N=5", + ) + assert cli.returncode == make.returncode == 0 + assert make.stdout == cli.stdout + assert not make.stderr + + assert _snapshot(tmp_path) == before + + +def test_all_entry_paths_preserve_supplied_root_symlink_inventory(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + target = tmp_path / "inventory-target.txt" + target.write_text("immutable supplied-root marker\n", encoding="utf-8") + link = tmp_path / "inventory-link" + link.symlink_to(target.name) + before = _snapshot(tmp_path) + + assert before["inventory-link"] == ( + "symlink", + os.fsencode(target.name), + ) + validate_point_in_time_universe(manifest, registry) + for mode, target_name in zip( + ("status", "preview"), + TARGETS, + strict=True, + ): + cli = _run_cli( + mode, + "--manifest", + str(manifest), + "--registry", + str(registry), + ) + make = _run_make( + target_name, + f"MANIFEST={manifest}", + f"REGISTRY={registry}", + ) + assert cli.returncode == make.returncode == 0 + + assert link.is_symlink() + assert os.readlink(link) == target.name + assert _snapshot(tmp_path) == before + + +def test_make_targets_are_phony_listed_in_help_and_require_manifest(): + makefile = (PROJECT_ROOT / "Makefile").read_text(encoding="utf-8") + phony = { + target + for line in makefile.splitlines() + if line.startswith(".PHONY:") + for target in line.partition(":")[2].split() + } + help_result = _run_make("help") + + assert help_result.returncode == 0 + for target in TARGETS: + assert target in phony + assert target in help_result.stdout + missing = _run_make(target) + assert missing.returncode != 0 + assert "MANIFEST is required" in missing.stderr + assert "Traceback" not in missing.stderr + + +@pytest.mark.parametrize( + "case,reason", + [ + ( + "event_history", + "membership_coverage_semantics_unsupported", + ), + ( + "reversed_identity", + "schema_identity_interval_reversed", + ), + ( + "reversed_membership", + "schema_membership_interval_reversed", + ), + ( + "duplicate_evaluation", + "schema_evaluation_row_id_duplicate", + ), + ], +) +def test_remediation_2_blockers_are_traceback_free_and_read_only( + tmp_path, + case, + reason, +): + manifest, registry = build_valid_package(tmp_path) + + if case == "event_history": + raw = json.loads(manifest.read_text(encoding="utf-8")) + raw["coverage_semantics"] = "event_history" + manifest.write_text(json.dumps(raw), encoding="utf-8") + elif case == "reversed_identity": + _rewrite_csv_and_manifest( + manifest, + "security_identity", + lambda rows: rows[0].update( + valid_from="2020-06-01T00:00:00Z", + valid_to="2020-05-01T00:00:00Z", + ), + ) + elif case == "reversed_membership": + _rewrite_csv_and_manifest( + manifest, + "membership", + lambda rows: rows[0].update( + effective_from="2020-06-01T00:00:00Z", + effective_to="2020-05-01T00:00:00Z", + ), + ) + else: + _rewrite_csv_and_manifest( + manifest, + "evaluations", + lambda rows: rows[1].update( + evaluation_row_id=rows[0]["evaluation_row_id"], + ), + ) + before = _snapshot(tmp_path) + + result = _run_cli( + "preview", + "--manifest", + str(manifest), + "--registry", + str(registry), + ) + + assert result.returncode == 0 + assert "analysis_eligible: false" in result.stdout + assert reason in result.stdout + assert "Traceback" not in result.stderr + if case in {"event_history", "duplicate_evaluation"}: + assert "sha256=" not in result.stdout + assert _snapshot(tmp_path) == before + + +def test_distinct_evaluation_ids_share_one_read_only_cutoff_digest( + tmp_path, +): + manifest, registry = build_valid_package(tmp_path) + + def add_distinct_same_cutoff(rows): + rows.append( + { + **rows[0], + "evaluation_row_id": "eval-bench-distinct-2", + } + ) + + _rewrite_csv_and_manifest( + manifest, + "evaluations", + add_distinct_same_cutoff, + ) + before = _snapshot(tmp_path) + + result = _run_cli( + "preview", + "--manifest", + str(manifest), + "--registry", + str(registry), + ) + + assert result.returncode == 0 + assert "analysis_eligible: true" in result.stdout + assert ( + "reproduction_ready: passed; reasons=none" + in result.stdout + ) + assert result.stdout.count( + "- bench-1 @ 2021-01-01T00:00:00Z:" + ) == 1 + assert "Traceback" not in result.stderr + assert _snapshot(tmp_path) == before diff --git a/tests/test_point_in_time_universe_contract_safety.py b/tests/test_point_in_time_universe_contract_safety.py new file mode 100644 index 00000000..dd233484 --- /dev/null +++ b/tests/test_point_in_time_universe_contract_safety.py @@ -0,0 +1,1213 @@ +from __future__ import annotations + +import hashlib +import os +from pathlib import Path +import subprocess +import sys +import pytest +from tests.point_in_time_universe_fixture import build_valid_package +from tests.test_point_in_time_universe import ( + _decision, + _refresh_contract_digest, + _refresh_registry_digest, + _rewrite_manifest, +) +from tests.test_point_in_time_universe_cli import ( + _run_cli, + _run_make, + _snapshot, +) +from tests.test_point_in_time_universe_contracts import ( + _rewrite_csv_and_manifest, +) +from tests.point_in_time_universe_remediation_fixtures import ( + add_successor_identity, + append_identity_correction, + replace_membership_with_successor, + require_event, + set_identity_valid_from, + set_successor_event, +) + + +PROJECT_ROOT = Path(__file__).resolve().parent.parent + + +def _duplicate_manifest_key(manifest, depth: str) -> None: + text = manifest.read_text(encoding="utf-8") + if depth == "top": + needle = '"dataset_id": "fixture-dataset"' + else: + needle = '"contract": "security_identity"' + manifest.write_text( + text.replace(needle, f"{needle}, {needle}", 1), + encoding="utf-8", + ) + + +def _duplicate_registry_key(manifest, registry, depth: str) -> None: + text = registry.read_text(encoding="utf-8") + if depth == "top": + duplicate = text.replace("sources:\n", "sources_duplicate:\n", 1) + text = f"{text}{duplicate.replace('sources_duplicate:', 'sources:', 1)}" + else: + needle = " - source_id: fixture_source\n" + duplicate = " source_id: fixture_source\n" + text = text.replace(needle, f"{needle}{duplicate}", 1) + registry.write_text(text, encoding="utf-8") + _refresh_registry_digest(manifest, registry) + + +@pytest.mark.parametrize( + ("resource", "depth", "error"), + [ + ("manifest", "top", "manifest_duplicate_key"), + ("manifest", "nested", "manifest_duplicate_key"), + ("registry", "top", "source_rights_registry_duplicate_key"), + ("registry", "nested", "source_rights_registry_duplicate_key"), + ], +) +def test_duplicate_mapping_keys_fail_validator_cli_and_make_without_writes( + tmp_path, + resource, + depth, + error, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + if resource == "manifest": + _duplicate_manifest_key(manifest, depth) + else: + _duplicate_registry_key(manifest, registry, depth) + before = _snapshot(tmp_path) + + with pytest.raises(ValueError, match=f"^{error}$"): + validate_point_in_time_universe(manifest, registry) + + cli = _run_cli( + "status", + "--manifest", + str(manifest), + "--registry", + str(registry), + ) + make = _run_make( + "point-in-time-universe-status", + f"MANIFEST={manifest}", + f"REGISTRY={registry}", + ) + + for result in (cli, make): + assert result.returncode == 2 + assert error in result.stderr + assert "Traceback" not in result.stderr + assert _snapshot(tmp_path) == before + + +def test_duplicate_rights_keys_fail_rights_cli_and_make_without_writes( + tmp_path, +): + manifest, registry = build_valid_package(tmp_path) + _duplicate_registry_key(manifest, registry, "nested") + before = _snapshot(tmp_path) + + cli = subprocess.run( + [ + sys.executable, + "-m", + "src.commercial_source_rights", + "--config", + str(registry), + ], + cwd=PROJECT_ROOT, + check=False, + capture_output=True, + text=True, + env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"}, + ) + make = _run_make( + "commercial-source-rights", + f"CONFIG={registry}", + ) + + for result in (cli, make): + assert result.returncode == 2 + assert "source_rights_registry_duplicate_key" in result.stderr + assert "Traceback" not in result.stderr + assert _snapshot(tmp_path) == before + + +@pytest.mark.parametrize( + ("location", "value", "error"), + [ + ( + "manifest_created_at", + "2030-W01-2T00:00:00Z", + "manifest_created_at_invalid", + ), + ( + "manifest_created_at", + "2030-01-01T00:00Z", + "manifest_created_at_invalid", + ), + ( + "train_end_at", + "2020-W53-5T00:00:00Z", + "manifest_evaluation_policy_invalid", + ), + ( + "train_end_at", + "2021-01-01T00:00Z", + "manifest_evaluation_policy_invalid", + ), + ], +) +def test_manifest_and_policy_timestamps_require_strict_rfc3339_utc( + tmp_path, + location, + value, + error, +): + from src.point_in_time_universe_manifest import load_universe_package + + manifest, registry = build_valid_package(tmp_path) + + def mutate(raw): + if location == "train_end_at": + raw["evaluation_policy"][location] = value + else: + raw[location] = value + + _rewrite_manifest(manifest, mutate) + + with pytest.raises(ValueError, match=f"^{error}$"): + load_universe_package(manifest, registry) + + +def test_malformed_contract_header_stops_validator_cli_and_make_without_writes( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + identity = manifest.parent / "identity.csv" + identity.write_text( + identity.read_text(encoding="utf-8").replace("issuer_id,", "", 1), + encoding="utf-8", + ) + _refresh_contract_digest(manifest, "security_identity") + before = _snapshot(tmp_path) + + with pytest.raises( + ValueError, + match="^package_csv_columns_invalid$", + ): + validate_point_in_time_universe(manifest, registry) + + cli = _run_cli( + "preview", + "--manifest", + str(manifest), + "--registry", + str(registry), + ) + make = _run_make( + "point-in-time-universe-preview", + f"MANIFEST={manifest}", + f"REGISTRY={registry}", + ) + + for result in (cli, make): + assert result.returncode == 2 + assert "package_csv_columns_invalid" in result.stderr + assert "Traceback" not in result.stderr + assert _snapshot(tmp_path) == before + + + + + + + + + + +def test_present_event_cannot_contradict_not_applicable_manifest_policy( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _rewrite_manifest( + manifest, + lambda raw: raw["corporate_action_policy"].update( + {"listing": "not_applicable"} + ), + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "technical_validity").status == "passed" + assert _decision(packet, "corporate_action_coverage").status == "blocked" + assert _decision( + packet, + "corporate_action_coverage", + ).reason_codes == ("corporate_action_policy_not_applicable",) + assert packet.analysis_eligible is False + + +@pytest.mark.parametrize( + ("event_type", "listing_state_after", "updates"), + [ + ("listing", "delisted", {}), + ("listing", "suspended", {}), + ], +) +def test_corporate_action_listing_state_contract_is_explicit( + tmp_path, + event_type, + listing_state_after, + updates, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _rewrite_csv_and_manifest( + manifest, + "events", + lambda rows: rows[0].update( + event_type=event_type, + listing_state_after=listing_state_after, + **updates, + ), + ) + if event_type != "listing": + require_event(manifest, event_type) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "technical_validity").status == "passed" + assert _decision(packet, "corporate_action_coverage").status == "blocked" + assert _decision( + packet, + "corporate_action_coverage", + ).reason_codes == ("corporate_action_listing_state_invalid",) + assert _decision(packet, "delisting_coverage").status == "not_applicable" + assert packet.analysis_eligible is False + + +def test_active_split_remains_analysis_eligible(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _rewrite_csv_and_manifest( + manifest, + "events", + lambda rows: rows[0].update( + event_type="split", + ratio_numerator="2", + ratio_denominator="1", + listing_state_after="active", + ), + ) + require_event(manifest, "split") + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "technical_validity").status == "passed" + assert _decision(packet, "corporate_action_coverage").status == "passed" + assert _decision(packet, "corporate_action_coverage").reason_codes == () + assert packet.analysis_eligible is True + + +@pytest.mark.parametrize( + ("successor_security_id", "reason"), + [ + ("sec-1", "corporate_action_successor_self"), + ("unknown", "corporate_action_successor_invalid"), + ("ambiguous", "corporate_action_successor_invalid"), + ("sec-without-identity", "corporate_action_successor_identity_missing"), + ], +) +def test_successor_must_be_distinct_explicit_and_cutoff_resolvable( + tmp_path, + successor_security_id, + reason, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + set_successor_event(manifest, successor_security_id) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "technical_validity").status == "passed" + assert _decision(packet, "corporate_action_coverage").status == "blocked" + assert reason in _decision( + packet, + "corporate_action_coverage", + ).reason_codes + assert packet.analysis_eligible is False + + +def test_ambiguous_cutoff_valid_successor_identity_is_blocked(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + add_successor_identity(manifest, "sec-successor") + + def add_ambiguous_identity(rows): + rows.append( + { + **rows[-1], + "identity_row_id": "id-successor-ambiguous", + "issuer_id": "issuer-successor-ambiguous", + "ticker": "OTHER", + "source_ref": "fixture://identity/id-successor-ambiguous", + } + ) + + _rewrite_csv_and_manifest( + manifest, + "security_identity", + add_ambiguous_identity, + ) + set_successor_event(manifest, "sec-successor") + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "corporate_action_coverage").status == "blocked" + assert ( + "corporate_action_successor_identity_ambiguous" + in _decision(packet, "corporate_action_coverage").reason_codes + ) + assert packet.analysis_eligible is False + + +def test_resolved_successor_cannot_leave_stale_original_membership_digest( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + add_successor_identity(manifest, "sec-successor") + set_successor_event(manifest, "sec-successor") + + packet = validate_point_in_time_universe(manifest, registry) + + assert {item.sha256 for item in packet.membership_digests} == { + hashlib.sha256(b"sec-1").hexdigest() + } + assert _decision(packet, "corporate_action_coverage").status == "blocked" + assert ( + "corporate_action_successor_membership_inconsistent" + in _decision(packet, "corporate_action_coverage").reason_codes + ) + assert packet.analysis_eligible is False + + +def test_resolved_spinoff_successor_can_retain_parent_membership(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + add_successor_identity(manifest, "sec-spinoff") + set_successor_event( + manifest, + "sec-spinoff", + event_type="spinoff", + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert {item.sha256 for item in packet.membership_digests} == { + hashlib.sha256(b"sec-1").hexdigest() + } + assert _decision(packet, "technical_validity").status == "passed" + assert _decision(packet, "corporate_action_coverage").status == "passed" + assert _decision(packet, "corporate_action_coverage").reason_codes == () + assert packet.analysis_eligible is True + + +def test_valid_unicode_successor_transition_is_deterministic_without_inference( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + successor_security_id = "後継-𐐷-🚀" + add_successor_identity(manifest, successor_security_id) + set_successor_event(manifest, successor_security_id) + replace_membership_with_successor( + manifest, + successor_security_id, + ) + + first = validate_point_in_time_universe(manifest, registry) + second = validate_point_in_time_universe(manifest, registry) + expected_digest = hashlib.sha256( + successor_security_id.encode("utf-8") + ).hexdigest() + + assert first == second + assert first.analysis_eligible is True + assert _decision(first, "corporate_action_coverage").status == "passed" + assert {item.sha256 for item in first.membership_digests} == { + expected_digest + } + assert all( + reference.row_id != "member-bench-1" + and reference.row_id != "member-research-1" + for reference in first.analysis_eligible_rows + ) + + +@pytest.mark.parametrize( + ("location", "error"), + [ + ("manifest_created_at", "manifest_created_at_invalid"), + ("train_end_at", "manifest_evaluation_policy_invalid"), + ], +) +def test_manifest_timestamp_precision_above_microseconds_is_rejected( + tmp_path, + location, + error, +): + from src.point_in_time_universe_manifest import load_universe_package + + manifest, registry = build_valid_package(tmp_path) + + def mutate(raw): + value = "2030-01-01T00:00:00.0000001Z" + if location == "train_end_at": + raw["evaluation_policy"][location] = ( + "2021-01-01T00:00:00.0000001Z" + ) + else: + raw[location] = value + + _rewrite_manifest(manifest, mutate) + + with pytest.raises(ValueError, match=f"^{error}$"): + load_universe_package(manifest, registry) + + +def test_contract_timestamp_100ns_after_cutoff_cannot_truncate_into_cutoff( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _rewrite_csv_and_manifest( + manifest, + "security_identity", + lambda rows: rows[0].update( + retrieved_at="2021-01-01T00:00:00.0000001Z", + ), + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "technical_validity").status == "blocked" + assert _decision(packet, "technical_validity").reason_codes == ( + "schema_timestamp_invalid", + ) + assert packet.analysis_eligible is False + + +@pytest.mark.parametrize("fraction", (".1", ".123456")) +def test_manifest_and_contract_timestamp_precision_boundaries_are_valid( + tmp_path, + fraction, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _rewrite_manifest( + manifest, + lambda raw: raw.update( + manifest_created_at=( + f"2030-01-01T00:00:00{fraction}Z" + ), + observation_cutoff_at=( + f"2021-01-01T00:00:00{fraction}Z" + ), + ), + ) + _rewrite_csv_and_manifest( + manifest, + "security_identity", + lambda rows: rows[0].update( + valid_from=f"2020-01-01T00:00:00{fraction}Z", + ), + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "manifest_integrity").status == "passed" + assert _decision(packet, "technical_validity").status == "passed" + assert packet.analysis_eligible is True + + + + +def _set_identity_valid_to( + manifest, + security_id: str, + valid_to: str, +) -> None: + def mutate(rows): + target = next( + row + for row in rows + if row["security_id"] == security_id + ) + target["valid_to"] = valid_to + + _rewrite_csv_and_manifest( + manifest, + "security_identity", + mutate, + ) + + +def _append_later_identity_interval( + manifest, + security_id: str, +) -> None: + def mutate(rows): + prior = next( + row + for row in rows + if row["security_id"] == security_id + ) + prior["valid_to"] = "2020-07-01T00:00:00Z" + rows.append( + { + **prior, + "identity_row_id": ( + f"{prior['identity_row_id']}-later-interval" + ), + "valid_from": "2020-07-01T00:00:00Z", + "valid_to": "", + "source_ref": ( + f"{prior['source_ref']}/later-interval" + ), + "source_published_at": "2020-07-01T00:00:00Z", + "retrieved_at": "2020-07-02T00:00:00Z", + "supersedes_identity_row_id": prior["identity_row_id"], + } + ) + + _rewrite_csv_and_manifest( + manifest, + "security_identity", + mutate, + ) + + + + +def _append_identity_child_correction( + manifest, + security_id: str, + *, + valid_from: str, + visible_at_cutoff: bool = True, +) -> None: + def mutate(rows): + prior = [ + row + for row in rows + if row["security_id"] == security_id + ][-1] + published = ( + "2020-08-01T00:00:00Z" + if visible_at_cutoff + else "2022-01-01T00:00:00Z" + ) + retrieved = ( + "2020-08-02T00:00:00Z" + if visible_at_cutoff + else "2022-01-02T00:00:00Z" + ) + rows.append( + { + **prior, + "identity_row_id": ( + f"{prior['identity_row_id']}-correction" + ), + "ticker": f"{prior['ticker']}-CORRECTED", + "exchange": "ARCX", + "valid_from": valid_from, + "valid_to": "", + "source_ref": f"{prior['source_ref']}/correction", + "source_published_at": published, + "retrieved_at": retrieved, + "supersedes_identity_row_id": prior["identity_row_id"], + } + ) + + _rewrite_csv_and_manifest( + manifest, + "security_identity", + mutate, + ) + + +def _action_package( + tmp_path, + event_type: str, + identity_role: str, +): + manifest, registry = build_valid_package(tmp_path) + successor = f"sec-{event_type}-correction-{identity_role}" + add_successor_identity(manifest, successor) + set_successor_event( + manifest, + successor, + event_type=event_type, + ) + if event_type in {"merger", "acquisition"}: + replace_membership_with_successor(manifest, successor) + target = "sec-1" if identity_role == "predecessor" else successor + return manifest, registry, target + + +@pytest.mark.parametrize( + ("identity_role", "reason"), + [ + ( + "predecessor", + "corporate_action_predecessor_identity_missing", + ), + ( + "successor", + "corporate_action_successor_identity_missing", + ), + ], +) +def test_action_identities_must_resolve_at_event_effective_time( + tmp_path, + identity_role, + reason, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + add_successor_identity(manifest, "sec-successor") + set_successor_event(manifest, "sec-successor") + replace_membership_with_successor(manifest, "sec-successor") + target_security_id = ( + "sec-1" + if identity_role == "predecessor" + else "sec-successor" + ) + set_identity_valid_from( + manifest, + target_security_id, + "2020-07-01T00:00:00Z", + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "technical_validity").status == "passed" + assert _decision(packet, "identity_coverage").status == "passed" + assert _decision(packet, "corporate_action_coverage").status == ( + "blocked" + ) + assert reason in _decision( + packet, + "corporate_action_coverage", + ).reason_codes + assert packet.analysis_eligible is False + + +@pytest.mark.parametrize( + "event_type", + ("merger", "acquisition", "spinoff"), +) +def test_valid_action_identities_resolve_at_event_effective_time( + tmp_path, + event_type, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + successor = f"sec-{event_type}" + add_successor_identity(manifest, successor) + set_successor_event( + manifest, + successor, + event_type=event_type, + ) + if event_type in {"merger", "acquisition"}: + replace_membership_with_successor(manifest, successor) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "identity_coverage").status == "passed" + assert _decision(packet, "corporate_action_coverage").status == "passed" + assert packet.analysis_eligible is True + + +@pytest.mark.parametrize("event_type", ("merger", "acquisition")) +def test_replacement_identity_boundary_resolves_both_endpoints( + tmp_path, + event_type, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + successor = f"sec-{event_type}-boundary" + add_successor_identity(manifest, successor) + set_successor_event( + manifest, + successor, + event_type=event_type, + ) + replace_membership_with_successor(manifest, successor) + _set_identity_valid_to( + manifest, + "sec-1", + "2020-06-01T00:00:00Z", + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "identity_coverage").status == "passed" + assert _decision(packet, "corporate_action_coverage").status == "passed" + assert packet.analysis_eligible is True + + +@pytest.mark.parametrize( + ("event_type", "identity_role"), + [ + ("merger", "predecessor"), + ("merger", "successor"), + ("acquisition", "predecessor"), + ("acquisition", "successor"), + ("spinoff", "predecessor"), + ("spinoff", "successor"), + ], +) +def test_action_endpoint_uses_earlier_lineage_valid_identity_interval( + tmp_path, + event_type, + identity_role, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + successor = f"sec-{event_type}-history-{identity_role}" + add_successor_identity(manifest, successor) + set_successor_event( + manifest, + successor, + event_type=event_type, + ) + if event_type in {"merger", "acquisition"}: + replace_membership_with_successor(manifest, successor) + _append_later_identity_interval( + manifest, + "sec-1" if identity_role == "predecessor" else successor, + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "identity_coverage").status == "passed" + assert _decision(packet, "corporate_action_coverage").status == "passed" + assert packet.analysis_eligible is True + + +@pytest.mark.parametrize( + ("event_type", "identity_role", "reason"), + [ + ( + "merger", + "predecessor", + "corporate_action_predecessor_identity_missing", + ), + ( + "merger", + "successor", + "corporate_action_successor_identity_missing", + ), + ( + "acquisition", + "predecessor", + "corporate_action_predecessor_identity_missing", + ), + ( + "acquisition", + "successor", + "corporate_action_successor_identity_missing", + ), + ( + "spinoff", + "predecessor", + "corporate_action_predecessor_identity_missing", + ), + ( + "spinoff", + "successor", + "corporate_action_successor_identity_missing", + ), + ], +) +def test_visible_identity_correction_replaces_superseded_interval( + tmp_path, + event_type, + identity_role, + reason, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry, target = _action_package( + tmp_path, + event_type, + identity_role, + ) + append_identity_correction( + manifest, + target, + valid_from=( + "2020-01-01T00:00:00Z" + if identity_role == "predecessor" + else "2020-07-01T00:00:00Z" + ), + valid_to=( + "2020-05-01T00:00:00Z" + if identity_role == "predecessor" + else "" + ), + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "corporate_action_coverage").status == ( + "blocked" + ) + assert _decision( + packet, + "corporate_action_coverage", + ).reason_codes == (reason,) + assert packet.analysis_eligible is False + + +@pytest.mark.parametrize( + ("event_type", "identity_role"), + [ + ("merger", "predecessor"), + ("merger", "successor"), + ("acquisition", "predecessor"), + ("acquisition", "successor"), + ("spinoff", "predecessor"), + ("spinoff", "successor"), + ], +) +def test_visible_identity_correction_covering_event_is_valid( + tmp_path, + event_type, + identity_role, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry, target = _action_package( + tmp_path, + event_type, + identity_role, + ) + append_identity_correction( + manifest, + target, + valid_from=( + "2020-01-01T00:00:00Z" + if identity_role == "predecessor" + else "2020-06-01T00:00:00Z" + ), + valid_to=( + "2020-06-01T00:00:00Z" + if ( + identity_role == "predecessor" + and event_type in {"merger", "acquisition"} + ) + else "" + ), + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "corporate_action_coverage").status == "passed" + + +@pytest.mark.parametrize( + ("event_type", "identity_role"), + [ + ("merger", "predecessor"), + ("merger", "successor"), + ("acquisition", "predecessor"), + ("acquisition", "successor"), + ("spinoff", "predecessor"), + ("spinoff", "successor"), + ], +) +def test_post_cutoff_identity_correction_does_not_change_event_view( + tmp_path, + event_type, + identity_role, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry, target = _action_package( + tmp_path, + event_type, + identity_role, + ) + append_identity_correction( + manifest, + target, + valid_from=( + "2020-01-01T00:00:00Z" + if identity_role == "predecessor" + else "2020-07-01T00:00:00Z" + ), + valid_to=( + "2020-05-01T00:00:00Z" + if identity_role == "predecessor" + else "" + ), + visible_at_cutoff=False, + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "corporate_action_coverage").status == "passed" + + +@pytest.mark.parametrize( + ("event_type", "identity_role", "reason"), + [ + ( + "merger", + "predecessor", + "corporate_action_predecessor_identity_missing", + ), + ( + "merger", + "successor", + "corporate_action_successor_identity_missing", + ), + ( + "acquisition", + "predecessor", + "corporate_action_predecessor_identity_missing", + ), + ( + "acquisition", + "successor", + "corporate_action_successor_identity_missing", + ), + ( + "spinoff", + "predecessor", + "corporate_action_predecessor_identity_missing", + ), + ( + "spinoff", + "successor", + "corporate_action_successor_identity_missing", + ), + ], +) +def test_child_correction_cannot_leave_obsolete_ancestor_effective( + tmp_path, + event_type, + identity_role, + reason, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry, target = _action_package( + tmp_path, + event_type, + identity_role, + ) + _append_later_identity_interval(manifest, target) + _append_identity_child_correction( + manifest, + target, + valid_from="2020-08-01T00:00:00Z", + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision( + packet, + "corporate_action_coverage", + ).reason_codes == (reason,) + + +def test_post_cutoff_child_correction_preserves_prior_contiguous_view( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry, target = _action_package( + tmp_path, + "merger", + "successor", + ) + _append_later_identity_interval(manifest, target) + _append_identity_child_correction( + manifest, + target, + valid_from="2020-08-01T00:00:00Z", + visible_at_cutoff=False, + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "corporate_action_coverage").status == "passed" + + +def test_spinoff_predecessor_end_boundary_is_not_active_at_event( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry, target = _action_package( + tmp_path, + "spinoff", + "predecessor", + ) + _set_identity_valid_to( + manifest, + target, + "2020-06-01T00:00:00Z", + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision( + packet, + "corporate_action_coverage", + ).reason_codes == ( + "corporate_action_predecessor_identity_missing", + ) + + +def _append_listing_state_events( + manifest, + events: tuple[tuple[str, str, str], ...], +) -> None: + def mutate(rows): + base = rows[0] + for event_type, listing_state_after, date in events: + rows.append( + { + **base, + "event_row_id": f"event-{event_type}-{date}", + "event_type": event_type, + "effective_at": f"{date}T00:00:00Z", + "listing_state_after": listing_state_after, + "source_ref": ( + f"fixture://event/{event_type}/{date}" + ), + "source_published_at": f"{date}T00:00:00Z", + "retrieved_at": f"{date}T12:00:00Z", + "supersedes_event_row_id": "", + } + ) + + _rewrite_csv_and_manifest(manifest, "events", mutate) + _rewrite_manifest( + manifest, + lambda raw: raw["corporate_action_policy"].update( + { + event_type: "required" + for event_type, _, _ in events + } + ), + ) + + +@pytest.mark.parametrize( + ("event_type", "listing_state_after"), + [ + ("delisting", "delisted"), + ("suspension", "suspended"), + ], +) +def test_first_observed_listing_state_does_not_invent_prior_history( + tmp_path, + event_type, + listing_state_after, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _rewrite_csv_and_manifest( + manifest, + "events", + lambda rows: rows[0].update( + event_type=event_type, + listing_state_after=listing_state_after, + ), + ) + require_event(manifest, event_type) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "delisting_coverage").status == "passed" + assert _decision(packet, "delisting_coverage").reason_codes == () + assert packet.analysis_eligible is True + + +def test_active_delisted_suspended_transition_is_rejected(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _append_listing_state_events( + manifest, + ( + ("delisting", "delisted", "2020-06-01"), + ("suspension", "suspended", "2020-07-01"), + ), + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "delisting_coverage").status == "blocked" + assert _decision(packet, "delisting_coverage").reason_codes == ( + "delisting_transition_invalid", + ) + assert packet.analysis_eligible is False + + +def test_listing_suspension_reactivation_delisting_chronology_is_valid( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _append_listing_state_events( + manifest, + ( + ("suspension", "suspended", "2020-04-01"), + ("reactivation", "active", "2020-05-01"), + ("delisting", "delisted", "2020-06-01"), + ), + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "technical_validity").status == "passed" + assert _decision(packet, "delisting_coverage").status == "passed" + assert _decision(packet, "delisting_coverage").reason_codes == () + assert packet.analysis_eligible is True + assert {item.sha256 for item in packet.membership_digests} == { + hashlib.sha256(b"sec-1").hexdigest() + } diff --git a/tests/test_point_in_time_universe_contracts.py b/tests/test_point_in_time_universe_contracts.py new file mode 100644 index 00000000..f21ddda8 --- /dev/null +++ b/tests/test_point_in_time_universe_contracts.py @@ -0,0 +1,475 @@ +import csv +import hashlib +import json +from dataclasses import FrozenInstanceError + +import pytest + +from tests.point_in_time_universe_fixture import build_valid_package + + +def _rewrite_csv_and_manifest(manifest, contract, mutate): + raw = json.loads(manifest.read_text()) + entry = next(item for item in raw["files"] if item["contract"] == contract) + path = manifest.parent / entry["path"] + with path.open(encoding="utf-8", newline="") as handle: + rows = list(csv.DictReader(handle)) + mutate(rows) + with path.open("w", encoding="utf-8", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=list(rows[0])) + writer.writeheader() + writer.writerows(rows) + entry["sha256"] = hashlib.sha256(path.read_bytes()).hexdigest() + entry["row_count"] = len(rows) + manifest.write_text(json.dumps(raw), encoding="utf-8") + + +def _update_manifest_entry(manifest, contract, path, *, row_count=1): + raw = json.loads(manifest.read_text()) + entry = next(item for item in raw["files"] if item["contract"] == contract) + entry["path"] = path.relative_to(manifest.parent).as_posix() + entry["sha256"] = hashlib.sha256(path.read_bytes()).hexdigest() + entry["row_count"] = row_count + manifest.write_text(json.dumps(raw), encoding="utf-8") + + +def _reason_codes(parsed): + return {code for finding in parsed.findings for code in finding.reason_codes} + + +def test_parser_preserves_raw_order_and_normalizes_display_ticker(tmp_path): + from src.point_in_time_universe_contracts import parse_universe_evidence + from src.point_in_time_universe_manifest import load_universe_package + + manifest, registry = build_valid_package(tmp_path) + _rewrite_csv_and_manifest(manifest, "security_identity", lambda rows: rows[0].update(ticker=" aaa ")) + parsed = parse_universe_evidence(load_universe_package(manifest, registry)) + + assert parsed.identities[0].ticker == "AAA" + assert parsed.raw[0].contract == "security_identity" + assert parsed.raw[0].source_row == 2 + + +@pytest.mark.parametrize( + "contract,column,value,reason", + [ + ("security_identity", "valid_from", "2020-01-01", "schema_timestamp_invalid"), + ("membership", "membership_state", "maybe", "schema_enum_invalid"), + ("membership", "universe_kind", "portfolio", "schema_enum_invalid"), + ("events", "event_type", "dividend", "schema_enum_invalid"), + ("events", "ratio_numerator", "nan", "schema_ratio_invalid"), + ("evaluations", "partition", "future", "schema_enum_invalid"), + ], +) +def test_invalid_values_become_stable_technical_findings(tmp_path, contract, column, value, reason): + from src.point_in_time_universe_contracts import parse_universe_evidence + from src.point_in_time_universe_manifest import load_universe_package + + manifest, registry = build_valid_package(tmp_path) + _rewrite_csv_and_manifest(manifest, contract, lambda rows: rows[0].update({column: value})) + parsed = parse_universe_evidence(load_universe_package(manifest, registry)) + + assert reason in _reason_codes(parsed) + + +def test_split_requires_both_positive_ratio_values(tmp_path): + from src.point_in_time_universe_contracts import parse_universe_evidence + from src.point_in_time_universe_manifest import load_universe_package + + manifest, registry = build_valid_package(tmp_path) + + def mutate(rows): + rows[0].update(event_type="split", ratio_numerator="2", ratio_denominator="") + + _rewrite_csv_and_manifest(manifest, "events", mutate) + parsed = parse_universe_evidence(load_universe_package(manifest, registry)) + + assert "schema_ratio_pair_required" in _reason_codes(parsed) + + +def test_unexpected_or_missing_columns_block_contract(tmp_path): + from src.point_in_time_universe_contracts import parse_universe_evidence + from src.point_in_time_universe_manifest import load_universe_package + + manifest, registry = build_valid_package(tmp_path) + path = manifest.parent / "identity.csv" + path.write_text(path.read_text().replace("issuer_id,", ""), encoding="utf-8") + raw = json.loads(manifest.read_text()) + entry = next(item for item in raw["files"] if item["contract"] == "security_identity") + entry["sha256"] = hashlib.sha256(path.read_bytes()).hexdigest() + manifest.write_text(json.dumps(raw), encoding="utf-8") + with pytest.raises( + ValueError, + match="^package_csv_columns_invalid$", + ): + parse_universe_evidence( + load_universe_package(manifest, registry) + ) + + +@pytest.mark.parametrize( + "value", + [ + "2020-01-01 00:00:00Z", + "20200101T000000Z", + "2020-01-01T00:00Z", + "2020-01-01T00:00:00+00:00Z", + "2020-02-30T00:00:00Z", + "2020-01-01T00:00:00.Z", + "2020-01-01T00:00:00Zgarbage", + ], +) +def test_timestamp_contract_rejects_tolerant_and_malformed_utc_forms(tmp_path, value): + from src.point_in_time_universe_contracts import parse_universe_evidence + from src.point_in_time_universe_manifest import load_universe_package + + manifest, registry = build_valid_package(tmp_path) + _rewrite_csv_and_manifest( + manifest, + "security_identity", + lambda rows: rows[0].update(valid_from=value), + ) + parsed = parse_universe_evidence(load_universe_package(manifest, registry)) + + assert "schema_timestamp_invalid" in _reason_codes(parsed) + + +def test_timestamp_contract_accepts_rfc3339_utc_fractional_seconds(tmp_path): + from src.point_in_time_universe_contracts import parse_universe_evidence + from src.point_in_time_universe_manifest import load_universe_package + + manifest, registry = build_valid_package(tmp_path) + _rewrite_csv_and_manifest( + manifest, + "security_identity", + lambda rows: rows[0].update(valid_from="2020-01-01T00:00:00.123Z"), + ) + parsed = parse_universe_evidence(load_universe_package(manifest, registry)) + + assert not parsed.findings + assert parsed.identities[0].valid_from.microsecond == 123000 + + +@pytest.mark.parametrize("event_type", ["split", "reverse_split"]) +def test_split_events_require_ratio_pair_when_both_values_are_blank(tmp_path, event_type): + from src.point_in_time_universe_contracts import parse_universe_evidence + from src.point_in_time_universe_manifest import load_universe_package + + manifest, registry = build_valid_package(tmp_path) + _rewrite_csv_and_manifest( + manifest, + "events", + lambda rows: rows[0].update(event_type=event_type, ratio_numerator="", ratio_denominator=""), + ) + parsed = parse_universe_evidence(load_universe_package(manifest, registry)) + + assert "schema_ratio_pair_required" in _reason_codes(parsed) + + +def test_delisting_requires_delisted_listing_state(tmp_path): + from src.point_in_time_universe_contracts import parse_universe_evidence + from src.point_in_time_universe_manifest import load_universe_package + + manifest, registry = build_valid_package(tmp_path) + _rewrite_csv_and_manifest( + manifest, + "events", + lambda rows: rows[0].update(event_type="delisting", listing_state_after="active"), + ) + parsed = parse_universe_evidence(load_universe_package(manifest, registry)) + + assert "schema_delisting_listing_state_invalid" in _reason_codes(parsed) + + +def test_parser_preserves_case_sensitive_opaque_identifiers(tmp_path): + from src.point_in_time_universe_contracts import parse_universe_evidence + from src.point_in_time_universe_manifest import load_universe_package + + manifest, registry = build_valid_package(tmp_path) + _rewrite_csv_and_manifest( + manifest, + "security_identity", + lambda rows: rows[0].update( + identity_row_id="Id-1a", + security_id="Sec-Case", + issuer_id="Issuer-Case", + ticker="aAa", + exchange="xNyS", + source_id="Source-Case", + source_ref="fixture://Identity/Case", + supersedes_identity_row_id="Prior-Id", + ), + ) + parsed = parse_universe_evidence(load_universe_package(manifest, registry)) + identity = parsed.identities[0] + + assert not parsed.findings + assert identity.identity_row_id == "Id-1a" + assert identity.security_id == "Sec-Case" + assert identity.issuer_id == "Issuer-Case" + assert identity.ticker == "AAA" + assert identity.exchange == "xNyS" + assert identity.source_id == "Source-Case" + assert identity.source_ref == "fixture://Identity/Case" + assert identity.supersedes_identity_row_id == "Prior-Id" + + +@pytest.mark.parametrize( + "contract,column", + [ + ("security_identity", "security_id"), + ("security_identity", "source_ref"), + ("membership", "universe_id"), + ("membership", "universe_kind"), + ("events", "event_type"), + ("evaluations", "partition"), + ], +) +def test_padded_required_identifiers_and_enums_are_rejected(tmp_path, contract, column): + from src.point_in_time_universe_contracts import parse_universe_evidence + from src.point_in_time_universe_manifest import load_universe_package + + manifest, registry = build_valid_package(tmp_path) + _rewrite_csv_and_manifest( + manifest, + contract, + lambda rows: rows[0].update({column: f" {rows[0][column]} "}), + ) + parsed = parse_universe_evidence(load_universe_package(manifest, registry)) + + assert "schema_whitespace_invalid" in _reason_codes(parsed) + + +@pytest.mark.parametrize( + "contract,column", + [ + ("security_identity", "supersedes_identity_row_id"), + ("membership", "supersedes_membership_row_id"), + ("events", "successor_security_id"), + ("events", "supersedes_event_row_id"), + ], +) +def test_padded_optional_opaque_identifiers_are_rejected(tmp_path, contract, column): + from src.point_in_time_universe_contracts import parse_universe_evidence + from src.point_in_time_universe_manifest import load_universe_package + + manifest, registry = build_valid_package(tmp_path) + _rewrite_csv_and_manifest(manifest, contract, lambda rows: rows[0].update({column: " Parent-Id "})) + parsed = parse_universe_evidence(load_universe_package(manifest, registry)) + + assert "schema_whitespace_invalid" in _reason_codes(parsed) + + +@pytest.mark.parametrize("extra", [False, True]) +def test_missing_or_surplus_cells_block_the_csv_contract(tmp_path, extra): + from src.point_in_time_universe_contracts import parse_universe_evidence + from src.point_in_time_universe_manifest import load_universe_package + + manifest, registry = build_valid_package(tmp_path) + path = manifest.parent / "identity.csv" + with path.open(encoding="utf-8", newline="") as handle: + row = next(csv.DictReader(handle)) + headers = list(row) + values = [row[header] for header in headers] + if extra: + values.append("surplus") + else: + values.pop() + path.write_text(",".join(headers) + "\n" + ",".join(values) + "\n", encoding="utf-8") + _update_manifest_entry(manifest, "security_identity", path) + parsed = parse_universe_evidence(load_universe_package(manifest, registry)) + + assert "schema_columns_invalid" in _reason_codes(parsed) + malformed_raw = next(raw for raw in parsed.raw if raw.contract == "security_identity") + assert parsed.raw[0] is malformed_raw + assert malformed_raw.source_row == 2 + assert not parsed.identities + assert all(isinstance(key, str) and isinstance(value, str) for key, value in malformed_raw.values.items()) + assert malformed_raw.values["identity_row_id"] == "id-1" + assert malformed_raw.values["security_id"] == "sec-1" + if extra: + assert malformed_raw.values["__surplus_cell_0__"] == "surplus" + else: + assert malformed_raw.values["supersedes_identity_row_id"] == "__missing_csv_cell__" + + +def test_raw_evidence_preserves_manifest_relative_source_file(tmp_path): + from src.point_in_time_universe_contracts import parse_universe_evidence + from src.point_in_time_universe_manifest import load_universe_package + + manifest, registry = build_valid_package(tmp_path) + source = manifest.parent / "identity.csv" + nested = manifest.parent / "nested" / "identity.csv" + nested.parent.mkdir() + source.rename(nested) + _update_manifest_entry(manifest, "security_identity", nested) + parsed = parse_universe_evidence(load_universe_package(manifest, registry)) + + assert parsed.raw[0].source_file == "nested/identity.csv" + + +def test_records_and_raw_values_are_immutable(tmp_path): + from src.point_in_time_universe_contracts import parse_universe_evidence + from src.point_in_time_universe_manifest import load_universe_package + + manifest, registry = build_valid_package(tmp_path) + parsed = parse_universe_evidence(load_universe_package(manifest, registry)) + + with pytest.raises(FrozenInstanceError): + parsed.identities[0].ticker = "CHANGED" + with pytest.raises(FrozenInstanceError): + parsed.raw[0].source_file = "changed.csv" + with pytest.raises(TypeError): + parsed.raw[0].values["ticker"] = "CHANGED" + + +def test_repeated_parsing_uses_verified_snapshots_after_paths_disappear(tmp_path): + from src.point_in_time_universe_contracts import parse_universe_evidence + from src.point_in_time_universe_manifest import load_universe_package + + manifest, registry = build_valid_package(tmp_path) + loaded = load_universe_package(manifest, registry) + first = parse_universe_evidence(loaded) + + for path in loaded.files.values(): + path.unlink() + registry.unlink() + + second = parse_universe_evidence(loaded) + + assert second == first + + +@pytest.mark.parametrize( + "contract,start_column,end_column,reason", + [ + ( + "security_identity", + "valid_from", + "valid_to", + "schema_identity_interval_reversed", + ), + ( + "membership", + "effective_from", + "effective_to", + "schema_membership_interval_reversed", + ), + ], +) +def test_reversed_intervals_become_schema_findings( + tmp_path, + contract, + start_column, + end_column, + reason, +): + from src.point_in_time_universe_contracts import parse_universe_evidence + from src.point_in_time_universe_manifest import load_universe_package + + manifest, registry = build_valid_package(tmp_path) + _rewrite_csv_and_manifest( + manifest, + contract, + lambda rows: rows[0].update( + { + start_column: "2020-06-01T00:00:00Z", + end_column: "2020-05-01T00:00:00Z", + } + ), + ) + + parsed = parse_universe_evidence( + load_universe_package(manifest, registry) + ) + + finding = next( + item + for item in parsed.findings + if reason in item.reason_codes + ) + assert finding.contract == contract + assert finding.source_row == 2 + assert finding.row_id in {"id-1", "member-bench-1"} + normalized = ( + parsed.identities + if contract == "security_identity" + else parsed.memberships + ) + assert all( + getattr( + row, + ( + "identity_row_id" + if contract == "security_identity" + else "membership_row_id" + ), + ) + != finding.row_id + for row in normalized + ) + + +@pytest.mark.parametrize("across_universes", [False, True]) +def test_duplicate_evaluation_ids_are_globally_excluded( + tmp_path, + across_universes, +): + from src.point_in_time_universe_contracts import parse_universe_evidence + from src.point_in_time_universe_manifest import load_universe_package + + manifest, registry = build_valid_package(tmp_path) + + def duplicate(rows): + if across_universes: + rows[1]["evaluation_row_id"] = rows[0]["evaluation_row_id"] + else: + rows.append( + { + **rows[0], + "source_ref": "fixture://evaluation/bench-duplicate", + } + ) + + _rewrite_csv_and_manifest(manifest, "evaluations", duplicate) + + parsed = parse_universe_evidence( + load_universe_package(manifest, registry) + ) + duplicate_findings = [ + item + for item in parsed.findings + if item.row_id == "eval-bench-1" + ] + + assert len(duplicate_findings) == 2 + assert all( + item.reason_codes + == ("schema_evaluation_row_id_duplicate",) + for item in duplicate_findings + ) + assert all( + evaluation.evaluation_row_id != "eval-bench-1" + for evaluation in parsed.evaluations + ) + + +def test_same_time_evaluations_with_distinct_ids_remain_valid(tmp_path): + from src.point_in_time_universe_contracts import parse_universe_evidence + from src.point_in_time_universe_manifest import load_universe_package + + manifest, registry = build_valid_package(tmp_path) + + parsed = parse_universe_evidence( + load_universe_package(manifest, registry) + ) + + assert not parsed.findings + assert { + evaluation.evaluation_row_id + for evaluation in parsed.evaluations + } == {"eval-bench-1", "eval-research-1"} + assert len({ + evaluation.evaluation_at + for evaluation in parsed.evaluations + }) == 1 diff --git a/tests/test_point_in_time_universe_corporate_actions.py b/tests/test_point_in_time_universe_corporate_actions.py new file mode 100644 index 00000000..8009b68d --- /dev/null +++ b/tests/test_point_in_time_universe_corporate_actions.py @@ -0,0 +1,464 @@ +from __future__ import annotations + +import pytest +from tests.point_in_time_universe_fixture import build_valid_package +from tests.test_point_in_time_universe import ( + _append_registry_source, + _decision, + _refresh_registry_digest, + _rewrite_manifest, +) +from tests.test_point_in_time_universe_contracts import ( + _rewrite_csv_and_manifest, +) +from tests.point_in_time_universe_remediation_fixtures import ( + add_successor_identity, + replace_membership_with_successor, + require_event, + set_successor_event, +) + + +NON_STATE_ACTIONS = ( + "ticker_change", + "exchange_change", + "split", + "reverse_split", + "merger", + "acquisition", + "spinoff", +) + + +def _configure_non_state_action( + manifest, + event_type: str, + listing_state_after: str, +) -> None: + if event_type in {"merger", "acquisition", "spinoff"}: + successor = f"sec-{event_type}-successor" + add_successor_identity(manifest, successor) + set_successor_event( + manifest, + successor, + event_type=event_type, + ) + if event_type in {"merger", "acquisition"}: + replace_membership_with_successor(manifest, successor) + _rewrite_csv_and_manifest( + manifest, + "events", + lambda rows: rows[0].update( + listing_state_after=listing_state_after, + ), + ) + return + + def mutate(rows): + rows[0].update( + event_type=event_type, + effective_at="2020-06-01T00:00:00Z", + successor_security_id="", + ratio_numerator=( + "2" + if event_type == "split" + else "1" + if event_type == "reverse_split" + else "" + ), + ratio_denominator=( + "1" + if event_type == "split" + else "10" + if event_type == "reverse_split" + else "" + ), + listing_state_after=listing_state_after, + source_published_at="2020-06-01T00:00:00Z", + retrieved_at="2020-06-02T00:00:00Z", + ) + + _rewrite_csv_and_manifest(manifest, "events", mutate) + require_event(manifest, event_type) + + +def _append_matching_state_event( + manifest, + listing_state_after: str, + *, + security_id: str | None = None, + effective_at: str | None = None, + visible_at_cutoff: bool = True, + required_policy: bool = True, +) -> None: + event_type = ( + "delisting" + if listing_state_after == "delisted" + else "suspension" + ) + + def mutate(rows): + action = rows[0] + state_effective_at = effective_at or action["effective_at"] + rows.append( + { + **action, + "event_row_id": f"event-matching-{event_type}", + "security_id": security_id or action["security_id"], + "event_type": event_type, + "effective_at": state_effective_at, + "successor_security_id": "", + "ratio_numerator": "", + "ratio_denominator": "", + "listing_state_after": listing_state_after, + "source_ref": f"fixture://event/matching/{event_type}", + "source_published_at": ( + state_effective_at + if visible_at_cutoff + else "2022-01-01T00:00:00Z" + ), + "retrieved_at": ( + state_effective_at + if visible_at_cutoff + else "2022-01-02T00:00:00Z" + ), + "supersedes_event_row_id": "", + } + ) + + _rewrite_csv_and_manifest(manifest, "events", mutate) + _rewrite_manifest( + manifest, + lambda raw: raw["corporate_action_policy"].update( + { + event_type: ( + "required" + if required_policy + else "not_applicable" + ) + } + ), + ) + + +@pytest.mark.parametrize("event_type", NON_STATE_ACTIONS) +@pytest.mark.parametrize( + "listing_state_after", + ("delisted", "suspended"), +) +def test_restrictive_non_state_action_requires_matching_state_event( + tmp_path, + event_type, + listing_state_after, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _configure_non_state_action( + manifest, + event_type, + listing_state_after, + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "technical_validity").status == "passed" + assert _decision(packet, "corporate_action_coverage").status == "passed" + assert _decision(packet, "delisting_coverage").status == "blocked" + assert _decision( + packet, + "delisting_coverage", + ).reason_codes == ("delisting_evidence_missing",) + assert any( + row.contract == "events" + and row.row_id == "event-1" + and row.reason_codes == ("delisting_evidence_missing",) + for row in packet.excluded + ) + assert packet.analysis_eligible is False + + +@pytest.mark.parametrize("event_type", NON_STATE_ACTIONS) +@pytest.mark.parametrize( + "listing_state_after", + ("delisted", "suspended"), +) +def test_matching_state_event_closes_restrictive_non_state_action( + tmp_path, + event_type, + listing_state_after, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _configure_non_state_action( + manifest, + event_type, + listing_state_after, + ) + _append_matching_state_event(manifest, listing_state_after) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "technical_validity").status == "passed" + assert _decision(packet, "corporate_action_coverage").status == "passed" + assert _decision(packet, "delisting_coverage").status == "passed" + assert _decision(packet, "delisting_coverage").reason_codes == () + assert packet.analysis_eligible is True + + +@pytest.mark.parametrize( + ("mutation", "matching_options"), + [ + ("wrong_security", {"security_id": "sec-other"}), + ( + "wrong_time", + {"effective_at": "2020-07-01T00:00:00Z"}, + ), + ("future", {"visible_at_cutoff": False}), + ("not_applicable", {"required_policy": False}), + ], +) +def test_matching_state_event_must_match_scope_time_visibility_and_policy( + tmp_path, + mutation, + matching_options, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _configure_non_state_action( + manifest, + "ticker_change", + "delisted", + ) + _append_matching_state_event( + manifest, + "delisted", + **matching_options, + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "technical_validity").status == "passed" + assert _decision(packet, "delisting_coverage").status == "blocked" + if mutation != "not_applicable": + assert "delisting_evidence_missing" in _decision( + packet, + "delisting_coverage", + ).reason_codes + assert packet.analysis_eligible is False, mutation + + +def test_active_non_state_action_remains_compatible(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _configure_non_state_action(manifest, "split", "active") + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "corporate_action_coverage").status == "passed" + assert _decision(packet, "delisting_coverage").status == "not_applicable" + assert packet.analysis_eligible is True + + +@pytest.mark.parametrize( + "listing_state_after", + ("delisted", "suspended"), +) +def test_listing_event_cannot_carry_restrictive_state( + tmp_path, + listing_state_after, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _rewrite_csv_and_manifest( + manifest, + "events", + lambda rows: rows[0].update( + listing_state_after=listing_state_after, + ), + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "corporate_action_coverage").status == "blocked" + assert _decision( + packet, + "corporate_action_coverage", + ).reason_codes == ("corporate_action_listing_state_invalid",) + assert packet.analysis_eligible is False + + +def _configure_rights_state_events( + manifest, + event_type: str, + source_id: str, +) -> None: + def mutate(rows): + rows[0].update( + event_type="suspension", + effective_at="2020-06-01T00:00:00Z", + listing_state_after="suspended", + source_id=source_id, + source_ref="fixture://event/suspension", + source_published_at="2020-06-01T00:00:00Z", + retrieved_at="2020-06-02T00:00:00Z", + ) + if event_type == "reactivation": + rows.append( + { + **rows[0], + "event_row_id": "event-reactivation", + "event_type": "reactivation", + "effective_at": "2020-07-01T00:00:00Z", + "listing_state_after": "active", + "source_ref": "fixture://event/reactivation", + "source_published_at": "2020-07-01T00:00:00Z", + "retrieved_at": "2020-07-02T00:00:00Z", + "supersedes_event_row_id": "", + } + ) + + _rewrite_csv_and_manifest(manifest, "events", mutate) + _rewrite_manifest( + manifest, + lambda raw: raw["corporate_action_policy"].update( + { + "listing": "not_applicable", + "suspension": "required", + event_type: "required", + } + ), + ) + + +@pytest.mark.parametrize( + ("event_type", "supported_fields", "expected_status"), + [ + ("suspension", ("corporate_actions",), "blocked"), + ( + "suspension", + ("corporate_actions", "delistings"), + "passed", + ), + ("reactivation", ("corporate_actions",), "blocked"), + ( + "reactivation", + ("corporate_actions", "delistings"), + "passed", + ), + ], +) +def test_suspension_and_reactivation_require_both_rights_scopes( + tmp_path, + event_type, + supported_fields, + expected_status, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + source_id = f"{event_type}-source" + _configure_rights_state_events(manifest, event_type, source_id) + _append_registry_source(registry, source_id, supported_fields) + _rewrite_manifest( + manifest, + lambda raw: raw["allowed_source_ids"].append(source_id), + ) + _refresh_registry_digest(manifest, registry) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision( + packet, + "source_rights_eligibility", + ).status == expected_status + if expected_status == "blocked": + assert _decision( + packet, + "source_rights_eligibility", + ).reason_codes == ("source_rights_field_scope_missing",) + assert packet.analysis_eligible is False + else: + assert _decision( + packet, + "source_rights_eligibility", + ).reason_codes == () + assert packet.analysis_eligible is True + + +def test_delisting_rights_mapping_remains_delistings_only(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + source_id = "delisting-only-source" + _rewrite_csv_and_manifest( + manifest, + "events", + lambda rows: rows[0].update( + event_type="delisting", + listing_state_after="delisted", + source_id=source_id, + ), + ) + _rewrite_manifest( + manifest, + lambda raw: raw["corporate_action_policy"].update( + { + "listing": "not_applicable", + "delisting": "required", + } + ), + ) + _append_registry_source( + registry, + source_id, + ("delistings",), + ) + _rewrite_manifest( + manifest, + lambda raw: raw["allowed_source_ids"].append(source_id), + ) + _refresh_registry_digest(manifest, registry) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision( + packet, + "source_rights_eligibility", + ).status == "passed" + assert packet.analysis_eligible is True + + +def test_pre_action_evaluation_does_not_poison_later_required_coverage( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _configure_non_state_action(manifest, "split", "") + _rewrite_csv_and_manifest( + manifest, + "evaluations", + lambda rows: rows[0].update( + evaluation_at="2020-03-01T00:00:00Z", + available_at="2020-03-01T00:00:00Z", + ), + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision( + packet, + "corporate_action_coverage", + ).status == "passed" + assert _decision( + packet, + "corporate_action_coverage", + ).reason_codes == () + assert _decision(packet, "temporal_validity").status == "passed" + assert _decision(packet, "leakage_safe").status == "passed" + assert packet.analysis_eligible is True diff --git a/tests/test_point_in_time_universe_evaluation_provenance.py b/tests/test_point_in_time_universe_evaluation_provenance.py new file mode 100644 index 00000000..9d3b6740 --- /dev/null +++ b/tests/test_point_in_time_universe_evaluation_provenance.py @@ -0,0 +1,513 @@ +from __future__ import annotations + +from tests.point_in_time_universe_fixture import build_valid_package +from tests.test_point_in_time_universe import ( + _decision, + _rewrite_manifest, + _sha256_members, +) +from tests.test_point_in_time_universe_cli import _run_cli +from tests.test_point_in_time_universe_contracts import ( + _rewrite_csv_and_manifest, +) +import pytest +from tests.point_in_time_universe_remediation_fixtures import ( + add_successor_identity, + append_identity_correction, + replace_membership_with_successor, + set_identity_valid_from, + set_successor_event, +) + + +EVALUATION_TIMESTAMPS = ( + "2020-03-01T00:00:00Z", + "2020-06-01T00:00:00Z", + "2021-01-01T00:00:00Z", +) + + +def _configure_walk_forward_package( + manifest, + *, + reverse_input: bool = False, +) -> None: + _rewrite_manifest( + manifest, + lambda raw: raw.update( + evaluation_policy={ + "kind": "walk_forward", + "minimum_history_count": 2, + } + ), + ) + + def replace_evaluations(rows): + templates = { + row["universe_id"]: row + for row in rows + } + replacement = [] + for evaluation_number, evaluation_at in enumerate( + EVALUATION_TIMESTAMPS, + start=1, + ): + for universe_id in ("bench-1", "research-1"): + replacement.append( + { + **templates[universe_id], + "evaluation_row_id": ( + f"eval-{universe_id}-{evaluation_number}" + ), + "evaluation_at": evaluation_at, + "available_at": evaluation_at, + "partition": "walk_forward", + "source_ref": ( + "fixture://evaluation/" + f"{universe_id}/{evaluation_number}" + ), + } + ) + rows[:] = ( + list(reversed(replacement)) + if reverse_input + else replacement + ) + + _rewrite_csv_and_manifest( + manifest, + "evaluations", + replace_evaluations, + ) + + +def _bootstrap_exclusions(packet): + return { + row.row_id: row.reason_codes + for row in packet.excluded + if row.contract == "evaluations" + } + + +def test_walk_forward_bootstrap_exclusions_allow_later_eligible_evaluations( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _configure_walk_forward_package(manifest) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "leakage_safe").status == "passed" + assert _decision(packet, "leakage_safe").reason_codes == () + assert _bootstrap_exclusions(packet) == { + "eval-bench-1-1": ("partition_minimum_history_unmet",), + "eval-bench-1-2": ("partition_minimum_history_unmet",), + "eval-research-1-1": ("partition_minimum_history_unmet",), + "eval-research-1-2": ("partition_minimum_history_unmet",), + } + assert { + ( + digest.universe_id, + digest.evaluation_at, + digest.member_count, + digest.sha256, + ) + for digest in packet.membership_digests + } == { + ( + "bench-1", + "2021-01-01T00:00:00Z", + 1, + _sha256_members("sec-1"), + ), + ( + "research-1", + "2021-01-01T00:00:00Z", + 1, + _sha256_members("sec-1"), + ), + } + assert packet.analysis_eligible is True + + +def test_walk_forward_bootstrap_semantics_are_input_order_independent( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + (tmp_path / "forward").mkdir() + (tmp_path / "reverse").mkdir() + forward_manifest, forward_registry = build_valid_package( + tmp_path / "forward" + ) + reverse_manifest, reverse_registry = build_valid_package( + tmp_path / "reverse" + ) + _configure_walk_forward_package(forward_manifest) + _configure_walk_forward_package( + reverse_manifest, + reverse_input=True, + ) + + forward = validate_point_in_time_universe( + forward_manifest, + forward_registry, + ) + reverse = validate_point_in_time_universe( + reverse_manifest, + reverse_registry, + ) + + assert forward.membership_digests == reverse.membership_digests + assert _bootstrap_exclusions(forward) == _bootstrap_exclusions(reverse) + assert forward.decisions == reverse.decisions + assert forward.analysis_eligible is True + assert reverse.analysis_eligible is True + + +def test_walk_forward_preview_shows_bootstrap_exclusions_and_later_digests( + tmp_path, +): + manifest, registry = build_valid_package(tmp_path) + _configure_walk_forward_package(manifest) + + result = _run_cli( + "preview", + "--manifest", + str(manifest), + "--registry", + str(registry), + "--top-n", + "20", + ) + + assert result.returncode == 0 + assert "analysis_eligible: true" in result.stdout + assert "leakage_safe: passed; reasons=none" in result.stdout + assert "- partition_minimum_history_unmet: 4" in result.stdout + assert ( + "- bench-1 @ 2021-01-01T00:00:00Z: members=1; sha256=" + f"{_sha256_members('sec-1')}" + ) in result.stdout + assert ( + "- research-1 @ 2021-01-01T00:00:00Z: members=1; sha256=" + f"{_sha256_members('sec-1')}" + ) in result.stdout + for row_id in ( + "eval-bench-1-1", + "eval-bench-1-2", + "eval-research-1-1", + "eval-research-1-2", + ): + assert ( + f"{row_id}; reasons=partition_minimum_history_unmet" + in result.stdout + ) + + +def _semantic_provenance(packet): + return tuple( + sorted( + ( + row.evaluation_row_id, + row.contract, + row.row_id, + ) + for row in packet.analysis_eligible_rows + ) + ) + + +def _configure_successor_action( + manifest, + successor_security_id: str, + event_type: str, +) -> None: + add_successor_identity(manifest, successor_security_id) + set_successor_event( + manifest, + successor_security_id, + event_type=event_type, + ) + if event_type in {"merger", "acquisition"}: + replace_membership_with_successor( + manifest, + successor_security_id, + ) + + +def test_parent_retaining_spinoff_includes_both_endpoint_identities( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _configure_successor_action( + manifest, + "sec-spinoff-successor", + "spinoff", + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "corporate_action_coverage").status == "passed" + assert packet.analysis_eligible is True + assert set(_semantic_provenance(packet)) == { + ("eval-bench-1", "evaluations", "eval-bench-1"), + ("eval-bench-1", "events", "event-1"), + ("eval-bench-1", "membership", "member-bench-1"), + ("eval-bench-1", "security_identity", "id-1"), + ("eval-bench-1", "security_identity", "id-successor"), + ("eval-research-1", "evaluations", "eval-research-1"), + ("eval-research-1", "events", "event-1"), + ( + "eval-research-1", + "membership", + "member-research-1", + ), + ("eval-research-1", "security_identity", "id-1"), + ( + "eval-research-1", + "security_identity", + "id-successor", + ), + } + + +def test_replacement_merger_deduplicates_successor_identity_provenance( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _configure_successor_action( + manifest, + "sec-merger-successor", + "merger", + ) + + packet = validate_point_in_time_universe(manifest, registry) + semantic = _semantic_provenance(packet) + + assert packet.analysis_eligible is True + for evaluation_row_id in ("eval-bench-1", "eval-research-1"): + assert semantic.count( + ( + evaluation_row_id, + "security_identity", + "id-successor", + ) + ) == 1 + assert ( + evaluation_row_id, + "security_identity", + "id-1", + ) in semantic + + +def test_unicode_successor_provenance_is_deterministic_under_input_order( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + forward_root = tmp_path / "forward" + reverse_root = tmp_path / "reverse" + forward_root.mkdir() + reverse_root.mkdir() + forward_manifest, forward_registry = build_valid_package(forward_root) + reverse_manifest, reverse_registry = build_valid_package(reverse_root) + successor_security_id = "後継-𐐷-🚀" + for manifest in (forward_manifest, reverse_manifest): + _configure_successor_action( + manifest, + successor_security_id, + "spinoff", + ) + _rewrite_csv_and_manifest( + reverse_manifest, + "security_identity", + lambda rows: rows.reverse(), + ) + _rewrite_csv_and_manifest( + reverse_manifest, + "events", + lambda rows: rows.reverse(), + ) + + forward = validate_point_in_time_universe( + forward_manifest, + forward_registry, + ) + reverse = validate_point_in_time_universe( + reverse_manifest, + reverse_registry, + ) + + assert forward.analysis_eligible is True + assert reverse.analysis_eligible is True + assert _semantic_provenance(forward) == _semantic_provenance(reverse) + assert { + row.row_id + for row in forward.analysis_eligible_rows + if row.contract == "security_identity" + } == {"id-1", "id-successor"} + + +@pytest.mark.parametrize( + "endpoint_state", + ("missing", "ambiguous", "future", "superseded"), +) +def test_unresolved_successor_has_zero_eligible_provenance( + tmp_path, + endpoint_state, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + successor_security_id = f"sec-{endpoint_state}-successor" + if endpoint_state == "missing": + set_successor_event(manifest, successor_security_id) + else: + _configure_successor_action( + manifest, + successor_security_id, + "merger", + ) + if endpoint_state == "ambiguous": + _rewrite_csv_and_manifest( + manifest, + "security_identity", + lambda rows: rows.append( + { + **rows[-1], + "identity_row_id": "id-successor-ambiguous", + "issuer_id": "issuer-successor-ambiguous", + "source_ref": ( + "fixture://identity/successor-ambiguous" + ), + "supersedes_identity_row_id": "", + } + ), + ) + elif endpoint_state == "future": + set_identity_valid_from( + manifest, + successor_security_id, + "2020-07-01T00:00:00Z", + ) + else: + append_identity_correction( + manifest, + successor_security_id, + valid_from="2020-07-01T00:00:00Z", + valid_to="", + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert packet.analysis_eligible is False + assert packet.analysis_eligible_rows == () + assert packet.analysis_eligible_row_count == 0 + + +def test_event_time_historical_successor_identity_is_included_when_consumed( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + successor_security_id = "sec-historical-successor" + _configure_successor_action( + manifest, + successor_security_id, + "spinoff", + ) + + def append_later_successor(rows): + prior = next( + row + for row in rows + if row["security_id"] == successor_security_id + ) + prior["valid_to"] = "2020-07-01T00:00:00Z" + rows.append( + { + **prior, + "identity_row_id": "id-successor-later", + "valid_from": "2020-07-01T00:00:00Z", + "valid_to": "", + "source_ref": "fixture://identity/successor-later", + "source_published_at": "2020-07-01T00:00:00Z", + "retrieved_at": "2020-07-02T00:00:00Z", + "supersedes_identity_row_id": prior["identity_row_id"], + } + ) + + _rewrite_csv_and_manifest( + manifest, + "security_identity", + append_later_successor, + ) + + packet = validate_point_in_time_universe(manifest, registry) + successor_provenance_ids = { + row.row_id + for row in packet.analysis_eligible_rows + if ( + row.contract == "security_identity" + and row.row_id.startswith("id-successor") + ) + } + + assert packet.analysis_eligible is True + assert successor_provenance_ids == {"id-successor"} + + +def test_required_state_event_used_by_decision_is_in_eligible_provenance( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _configure_successor_action( + manifest, + "sec-merger-successor", + "merger", + ) + + def add_delisting_evidence(rows): + action = rows[0] + action["listing_state_after"] = "delisted" + rows.append( + { + **action, + "event_row_id": "event-matching-delisting", + "event_type": "delisting", + "successor_security_id": "", + "listing_state_after": "delisted", + "source_ref": "fixture://event/matching/delisting", + } + ) + + _rewrite_csv_and_manifest( + manifest, + "events", + add_delisting_evidence, + ) + _rewrite_manifest( + manifest, + lambda raw: raw["corporate_action_policy"].update( + delisting="required", + ), + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert packet.analysis_eligible is True + assert { + row.row_id + for row in packet.analysis_eligible_rows + if row.contract == "events" + } == {"event-1", "event-matching-delisting"} diff --git a/tests/test_point_in_time_universe_identity_continuity.py b/tests/test_point_in_time_universe_identity_continuity.py new file mode 100644 index 00000000..2e32dd6f --- /dev/null +++ b/tests/test_point_in_time_universe_identity_continuity.py @@ -0,0 +1,2462 @@ +from __future__ import annotations + +import pytest +from tests.point_in_time_universe_fixture import build_valid_package +from tests.test_point_in_time_universe import ( + _decision, + _mutate_identity_lineage_exclusion, + _read_contract_rows, + _replace_contract_rows, + _rewrite_manifest, + _sha256_members, +) +from tests.test_point_in_time_universe_cli import ( + _run_cli, + _snapshot, +) +from tests.test_point_in_time_universe_contracts import ( + _rewrite_csv_and_manifest, +) +from tests.point_in_time_universe_remediation_fixtures import ( + STABLE_MEMBER_DIGEST, + add_second_security_evidence, + append_action_event, +) + + +REUSE_REASON = "identity_security_id_reused_across_issuers" + + +def _append_identity_interval( + manifest, + *, + issuer_id: str, + security_id: str = "sec-1", + valid_from: str = "2020-06-01T00:00:00Z", + visible_at_cutoff: bool = True, + linked: bool = True, +) -> None: + def mutate(rows): + prior = next( + row + for row in rows + if row["security_id"] == security_id + ) + prior["valid_to"] = valid_from + rows.append( + { + **prior, + "identity_row_id": f"{prior['identity_row_id']}-later", + "issuer_id": issuer_id, + "valid_from": valid_from, + "valid_to": "", + "source_ref": f"{prior['source_ref']}/later", + "source_published_at": ( + valid_from + if visible_at_cutoff + else "2022-01-01T00:00:00Z" + ), + "retrieved_at": ( + valid_from + if visible_at_cutoff + else "2022-01-02T00:00:00Z" + ), + "supersedes_identity_row_id": ( + prior["identity_row_id"] if linked else "" + ), + } + ) + + _rewrite_csv_and_manifest( + manifest, + "security_identity", + mutate, + ) + + +def _append_same_effective_issuer_correction(manifest) -> None: + def mutate(rows): + erroneous = rows[0] + erroneous["issuer_id"] = "issuer-erroneous" + rows.append( + { + **erroneous, + "identity_row_id": "id-issuer-correction", + "issuer_id": "issuer-1", + "source_ref": "fixture://identity/issuer-correction", + "source_published_at": "2020-06-01T00:00:00Z", + "retrieved_at": "2020-06-02T00:00:00Z", + "supersedes_identity_row_id": ( + erroneous["identity_row_id"] + ), + } + ) + + _rewrite_csv_and_manifest( + manifest, + "security_identity", + mutate, + ) + + +def _move_evaluations_to_2023(manifest) -> None: + _rewrite_csv_and_manifest( + manifest, + "evaluations", + lambda rows: [ + row.update( + evaluation_at="2023-01-01T00:00:00Z", + available_at="2023-01-01T00:00:00Z", + ) + for row in rows + ], + ) + _rewrite_manifest( + manifest, + lambda raw: raw.update( + observation_cutoff_at="2023-01-01T00:00:00Z", + evaluation_policy={ + "kind": "train_validation_test", + "train_end_at": "2023-01-01T00:00:00Z", + "validation_start_at": "2023-01-02T00:00:00Z", + "validation_end_at": "2023-01-03T00:00:00Z", + "test_start_at": "2023-01-04T00:00:00Z", + }, + ), + ) + + +@pytest.mark.parametrize("with_action_event", (False, True)) +def test_security_id_cannot_cross_issuers_even_with_action_event( + tmp_path, + with_action_event, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _append_identity_interval(manifest, issuer_id="issuer-2") + if with_action_event: + append_action_event(manifest, "ticker_change") + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "identity_coverage").status == "blocked" + assert _decision(packet, "identity_coverage").reason_codes == ( + REUSE_REASON, + ) + assert _decision(packet, "membership_coverage").status == "passed" + assert _decision(packet, "reproduction_ready").status == "blocked" + assert _decision(packet, "reproduction_ready").reason_codes == ( + "reproduction_digest_missing", + ) + assert { + row.row_id: row.reason_codes + for row in packet.excluded + if REUSE_REASON in row.reason_codes + } == {"id-1-later": (REUSE_REASON,)} + assert packet.membership_digests == () + assert packet.display_tickers == {} + assert packet.analysis_eligible is False + assert packet.analysis_eligible_rows == () + + +def test_unlinked_adjacent_cross_issuer_intervals_cannot_silently_pass( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _append_identity_interval( + manifest, + issuer_id="issuer-2", + linked=False, + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "identity_coverage").status == "blocked" + assert REUSE_REASON in _decision( + packet, + "identity_coverage", + ).reason_codes + assert packet.membership_digests == () + assert packet.analysis_eligible_rows == () + + +def test_same_issuer_identity_history_remains_valid(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _append_identity_interval(manifest, issuer_id="issuer-1") + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "identity_coverage").status == "passed" + assert _decision(packet, "reproduction_ready").status == "passed" + assert packet.display_tickers == {"sec-1": "AAA"} + assert packet.membership_digests + assert packet.analysis_eligible is True + + +def test_same_effective_supersession_can_correct_erroneous_issuer( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _append_same_effective_issuer_correction(manifest) + + packet = validate_point_in_time_universe(manifest, registry) + eligible_identity_ids = { + row.row_id + for row in packet.analysis_eligible_rows + if row.contract == "security_identity" + } + + assert _decision(packet, "identity_coverage").status == "passed" + assert _decision(packet, "reproduction_ready").status == "passed" + assert eligible_identity_ids == {"id-issuer-correction"} + assert packet.analysis_eligible is True + + +def test_adjacent_interval_on_separate_security_id_is_independent(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + def append_other_security(rows): + rows.append( + { + **rows[0], + "identity_row_id": "id-other-security", + "security_id": "sec-other", + "issuer_id": "issuer-other", + "valid_from": "2020-06-01T00:00:00Z", + "source_ref": "fixture://identity/other-security", + "supersedes_identity_row_id": "", + } + ) + + _rewrite_csv_and_manifest( + manifest, + "security_identity", + append_other_security, + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "identity_coverage").status == "passed" + assert packet.analysis_eligible is True + + +def test_post_cutoff_issuer_change_is_invisible_until_later_evaluation( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + early_root = tmp_path / "early" + early_root.mkdir() + early_manifest, early_registry = build_valid_package(early_root) + _append_identity_interval( + early_manifest, + issuer_id="issuer-2", + valid_from="2022-01-01T00:00:00Z", + visible_at_cutoff=False, + ) + + early = validate_point_in_time_universe( + early_manifest, + early_registry, + ) + + later_root = tmp_path / "later" + later_root.mkdir() + later_manifest, later_registry = build_valid_package(later_root) + _append_identity_interval( + later_manifest, + issuer_id="issuer-2", + valid_from="2022-01-01T00:00:00Z", + visible_at_cutoff=False, + ) + _move_evaluations_to_2023(later_manifest) + + later = validate_point_in_time_universe( + later_manifest, + later_registry, + ) + + assert _decision(early, "identity_coverage").status == "passed" + assert early.analysis_eligible is True + assert _decision(later, "identity_coverage").reason_codes == ( + REUSE_REASON, + ) + assert later.membership_digests == () + assert later.analysis_eligible is False + + +def test_cross_issuer_identity_fork_remains_lineage_blocked(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + def append_fork(rows): + prior = rows[0] + prior["valid_to"] = "2020-06-01T00:00:00Z" + for suffix, issuer_id in ( + ("a", "issuer-2"), + ("b", "issuer-3"), + ): + rows.append( + { + **prior, + "identity_row_id": f"id-fork-{suffix}", + "issuer_id": issuer_id, + "valid_from": "2020-06-01T00:00:00Z", + "valid_to": "", + "source_ref": f"fixture://identity/fork-{suffix}", + "source_published_at": "2020-06-01T00:00:00Z", + "retrieved_at": "2020-06-02T00:00:00Z", + "supersedes_identity_row_id": prior["identity_row_id"], + } + ) + + _rewrite_csv_and_manifest( + manifest, + "security_identity", + append_fork, + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "identity_coverage").status == "blocked" + assert _decision(packet, "identity_coverage").reason_codes == ( + "lineage_fork", + ) + assert packet.analysis_eligible is False + + +def test_unicode_issuer_change_is_stable_id_reuse(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _rewrite_csv_and_manifest( + manifest, + "security_identity", + lambda rows: rows[0].update(issuer_id="發行人-甲-🚀"), + ) + _append_identity_interval( + manifest, + issuer_id="發行人-乙-🚀", + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "identity_coverage").reason_codes == ( + REUSE_REASON, + ) + assert packet.membership_digests == () + assert packet.analysis_eligible_rows == () + + +@pytest.mark.parametrize("command", ("status", "preview")) +def test_stable_id_reuse_is_cli_readable_and_write_free( + tmp_path, + command, +): + manifest, registry = build_valid_package(tmp_path) + _append_identity_interval(manifest, issuer_id="issuer-2") + before = _snapshot(tmp_path) + + result = _run_cli( + command, + "--manifest", + str(manifest), + "--registry", + str(registry), + ) + + assert result.returncode == 0 + assert "identity_coverage: blocked" in result.stdout + assert REUSE_REASON in result.stdout + assert "reproduction_ready: blocked" in result.stdout + assert "reproduction_digest_missing" in result.stdout + assert "Traceback" not in result.stderr + assert _snapshot(tmp_path) == before + + +def _append_cross_issuer_fork(manifest) -> None: + def mutate(rows): + prior = rows[0] + prior["valid_to"] = "2020-06-01T00:00:00Z" + for suffix, issuer_id in ( + ("a", "issuer-2"), + ("b", "issuer-3"), + ): + rows.append( + { + **prior, + "identity_row_id": f"id-fork-{suffix}", + "issuer_id": issuer_id, + "valid_from": "2020-06-01T00:00:00Z", + "valid_to": "", + "source_ref": f"fixture://identity/fork-{suffix}", + "source_published_at": "2020-06-01T00:00:00Z", + "retrieved_at": "2020-06-02T00:00:00Z", + "supersedes_identity_row_id": prior["identity_row_id"], + } + ) + + _rewrite_csv_and_manifest( + manifest, + "security_identity", + mutate, + ) + + +def _make_research_consume_second_security(manifest) -> None: + add_second_security_evidence(manifest) + + def mutate(rows): + research = next( + row + for row in rows + if row["universe_id"] == "research-1" + ) + research.update( + membership_row_id="member-research-sec-2", + security_id="sec-2", + source_ref="fixture://membership/research-sec-2", + ) + + _rewrite_csv_and_manifest(manifest, "membership", mutate) + + +def _reverse_contract_rows(manifest) -> None: + for contract in ( + "security_identity", + "membership", + "events", + "evaluations", + ): + _replace_contract_rows( + manifest, + contract, + list(reversed(_read_contract_rows(manifest, contract))), + ) + + +def _digest_keys(packet): + return { + (digest.universe_id, digest.evaluation_at) + for digest in packet.membership_digests + } + + +def test_cross_issuer_fork_suppresses_every_consuming_packet_digest( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _append_cross_issuer_fork(manifest) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "identity_coverage").reason_codes == ( + "lineage_fork", + ) + assert packet.membership_digests == () + assert _decision(packet, "reproduction_ready").reason_codes == ( + "reproduction_digest_missing", + ) + assert packet.analysis_eligible is False + assert packet.analysis_eligible_rows == () + + +@pytest.mark.parametrize("reverse", (False, True)) +def test_reuse_suppression_is_packet_scoped_and_order_deterministic( + tmp_path, + reverse, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _make_research_consume_second_security(manifest) + _append_identity_interval(manifest, issuer_id="issuer-reused") + if reverse: + _reverse_contract_rows(manifest) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "identity_coverage").reason_codes == ( + REUSE_REASON, + ) + assert len(packet.membership_digests) == 1 + research = packet.membership_digests[0] + assert ( + research.universe_id, + research.evaluation_at, + research.member_count, + research.sha256, + ) == ( + "research-1", + "2021-01-01T00:00:00Z", + 1, + _sha256_members("sec-2"), + ) + assert _decision(packet, "reproduction_ready").reason_codes == ( + "reproduction_digest_missing", + ) + assert packet.analysis_eligible is False + assert packet.analysis_eligible_rows == () + + +def test_fork_suppression_is_scoped_across_multiple_evaluations( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _make_research_consume_second_security(manifest) + _append_cross_issuer_fork(manifest) + + def add_early_benchmark_evaluation(rows): + benchmark = next( + row + for row in rows + if row["universe_id"] == "bench-1" + ) + rows.append( + { + **benchmark, + "evaluation_row_id": "eval-bench-early", + "evaluation_at": "2020-03-01T00:00:00Z", + "available_at": "2020-03-01T00:00:00Z", + "source_ref": "fixture://evaluation/bench-early", + } + ) + + _rewrite_csv_and_manifest( + manifest, + "evaluations", + add_early_benchmark_evaluation, + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _digest_keys(packet) == { + ("bench-1", "2020-03-01T00:00:00Z"), + ("research-1", "2021-01-01T00:00:00Z"), + } + assert all( + not ( + digest.universe_id == "bench-1" + and digest.evaluation_at == "2021-01-01T00:00:00Z" + ) + for digest in packet.membership_digests + ) + assert _decision(packet, "reproduction_ready").reason_codes == ( + "reproduction_digest_missing", + ) + assert packet.analysis_eligible is False + assert packet.analysis_eligible_rows == () + + +def _append_deep_cross_issuer_fork(manifest) -> None: + def mutate(rows): + root = rows[0] + root["valid_to"] = "2020-06-01T00:00:00Z" + middle = { + **root, + "identity_row_id": "id-middle-issuer-2", + "issuer_id": "issuer-2", + "valid_from": "2020-06-01T00:00:00Z", + "valid_to": "2020-09-01T00:00:00Z", + "source_ref": "fixture://identity/middle-issuer-2", + "source_published_at": "2020-06-01T00:00:00Z", + "retrieved_at": "2020-06-02T00:00:00Z", + "supersedes_identity_row_id": root["identity_row_id"], + } + rows.append(middle) + branch_a = { + **middle, + "identity_row_id": "id-branch-a-issuer-2", + "valid_from": "2020-09-01T00:00:00Z", + "valid_to": "2020-12-01T00:00:00Z", + "source_ref": "fixture://identity/branch-a-issuer-2", + "source_published_at": "2020-09-01T00:00:00Z", + "retrieved_at": "2020-09-02T00:00:00Z", + "supersedes_identity_row_id": middle["identity_row_id"], + } + branch_b = { + **middle, + "identity_row_id": "id-branch-b-issuer-2", + "valid_from": "2020-09-01T00:00:00Z", + "valid_to": "", + "source_ref": "fixture://identity/branch-b-issuer-2", + "source_published_at": "2020-09-01T00:00:00Z", + "retrieved_at": "2020-09-02T00:00:00Z", + "supersedes_identity_row_id": middle["identity_row_id"], + } + rows.extend((branch_a, branch_b)) + rows.append( + { + **branch_a, + "identity_row_id": "id-grandchild-issuer-2", + "valid_from": "2020-12-01T00:00:00Z", + "valid_to": "", + "source_ref": "fixture://identity/grandchild-issuer-2", + "source_published_at": "2020-12-01T00:00:00Z", + "retrieved_at": "2020-12-02T00:00:00Z", + "supersedes_identity_row_id": branch_a[ + "identity_row_id" + ], + } + ) + + _rewrite_csv_and_manifest( + manifest, + "security_identity", + mutate, + ) + + +def _append_later_corrected_issuer_history(manifest) -> None: + def mutate(rows): + correction = next( + row + for row in rows + if row["identity_row_id"] == "id-issuer-correction" + ) + erroneous = next( + row + for row in rows + if row["identity_row_id"] + == correction["supersedes_identity_row_id"] + ) + erroneous["valid_to"] = "2020-09-01T00:00:00Z" + correction["valid_to"] = "2020-09-01T00:00:00Z" + rows.append( + { + **correction, + "identity_row_id": "id-corrected-issuer-later", + "valid_from": "2020-09-01T00:00:00Z", + "valid_to": "", + "source_ref": "fixture://identity/corrected-later", + "source_published_at": "2020-09-01T00:00:00Z", + "retrieved_at": "2020-09-02T00:00:00Z", + "supersedes_identity_row_id": correction[ + "identity_row_id" + ], + } + ) + + _rewrite_csv_and_manifest( + manifest, + "security_identity", + mutate, + ) + + +def test_deep_cross_issuer_fork_propagates_through_descendants(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _append_deep_cross_issuer_fork(manifest) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "identity_coverage").reason_codes == ( + "lineage_fork", + ) + assert packet.membership_digests == () + assert _decision(packet, "reproduction_ready").reason_codes == ( + "reproduction_digest_missing", + ) + assert packet.analysis_eligible_rows == () + + +@pytest.mark.parametrize("reverse", (False, True)) +def test_deep_reuse_suppression_is_scoped_and_order_deterministic( + tmp_path, + reverse, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _make_research_consume_second_security(manifest) + _append_deep_cross_issuer_fork(manifest) + if reverse: + _reverse_contract_rows(manifest) + + packet = validate_point_in_time_universe(manifest, registry) + + assert len(packet.membership_digests) == 1 + research = packet.membership_digests[0] + assert ( + research.universe_id, + research.evaluation_at, + research.member_count, + research.sha256, + ) == ( + "research-1", + "2021-01-01T00:00:00Z", + 1, + _sha256_members("sec-2"), + ) + assert _decision(packet, "reproduction_ready").reason_codes == ( + "reproduction_digest_missing", + ) + assert packet.analysis_eligible_rows == () + + +def test_deep_reuse_propagation_is_cutoff_and_evaluation_scoped( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _make_research_consume_second_security(manifest) + _append_deep_cross_issuer_fork(manifest) + + def add_early_benchmark_evaluation(rows): + benchmark = next( + row + for row in rows + if row["universe_id"] == "bench-1" + ) + rows.append( + { + **benchmark, + "evaluation_row_id": "eval-bench-before-reuse", + "evaluation_at": "2020-03-01T00:00:00Z", + "available_at": "2020-03-01T00:00:00Z", + "source_ref": "fixture://evaluation/bench-before-reuse", + } + ) + + _rewrite_csv_and_manifest( + manifest, + "evaluations", + add_early_benchmark_evaluation, + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _digest_keys(packet) == { + ("bench-1", "2020-03-01T00:00:00Z"), + ("research-1", "2021-01-01T00:00:00Z"), + } + assert _decision(packet, "reproduction_ready").reason_codes == ( + "reproduction_digest_missing", + ) + assert packet.analysis_eligible_rows == () + + +def _append_invalid_same_effective_correction( + manifest, + mode: str, +) -> None: + def mutate(rows): + root = rows[0] + root["valid_to"] = "2020-06-01T00:00:00Z" + middle = { + **root, + "identity_row_id": "id-issuer-2-interval", + "issuer_id": "issuer-2", + "valid_from": "2020-06-01T00:00:00Z", + "valid_to": "", + "source_ref": "fixture://identity/issuer-2-interval", + "source_published_at": "2020-06-01T00:00:00Z", + "retrieved_at": "2020-06-02T00:00:00Z", + "supersedes_identity_row_id": root["identity_row_id"], + } + correction = { + **middle, + "identity_row_id": "id-invalid-issuer-1-correction", + "issuer_id": "issuer-1", + "source_ref": "fixture://identity/invalid-correction", + "source_published_at": "2020-07-01T00:00:00Z", + "retrieved_at": "2020-07-02T00:00:00Z", + "supersedes_identity_row_id": middle["identity_row_id"], + } + if mode == "lineage_order_reversed": + correction.update( + source_published_at="2020-06-01T00:00:00Z", + retrieved_at="2020-06-01T00:00:00Z", + ) + elif mode == "unavailable": + correction.update( + source_published_at="2022-01-01T00:00:00Z", + retrieved_at="2022-01-02T00:00:00Z", + ) + elif mode == "lineage_cross_scope_parent": + correction.update( + security_id="sec-other", + source_ref="fixture://identity/cross-scope-correction", + ) + elif mode == "lineage_cycle": + middle["supersedes_identity_row_id"] = correction[ + "identity_row_id" + ] + elif mode != "lineage_fork": + raise AssertionError(f"unsupported mode: {mode}") + + rows.extend((middle, correction)) + if mode == "lineage_fork": + rows.append( + { + **correction, + "identity_row_id": "id-second-issuer-1-correction", + "source_ref": ( + "fixture://identity/second-invalid-correction" + ), + "source_published_at": "2020-08-01T00:00:00Z", + "retrieved_at": "2020-08-02T00:00:00Z", + } + ) + + _rewrite_csv_and_manifest( + manifest, + "security_identity", + mutate, + ) + + +def test_reversed_same_effective_correction_cannot_erase_raw_issuer( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _make_research_consume_second_security(manifest) + _append_invalid_same_effective_correction( + manifest, + "lineage_order_reversed", + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert "lineage_order_reversed" in _decision( + packet, + "identity_coverage", + ).reason_codes + assert len(packet.membership_digests) == 1 + research = packet.membership_digests[0] + assert ( + research.universe_id, + research.member_count, + research.sha256, + ) == ( + "research-1", + 1, + _sha256_members("sec-2"), + ) + assert _decision(packet, "reproduction_ready").reason_codes == ( + "reproduction_digest_missing", + ) + assert packet.analysis_eligible_rows == () + + +@pytest.mark.parametrize( + "mode", + ( + "lineage_order_reversed", + "unavailable", + "lineage_cross_scope_parent", + "lineage_fork", + "lineage_cycle", + ), +) +def test_invalid_same_effective_correction_preserves_raw_issuer_evidence( + tmp_path, + mode, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _append_invalid_same_effective_correction(manifest, mode) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "identity_coverage").status == "blocked" + assert packet.membership_digests == () + assert _decision(packet, "reproduction_ready").reason_codes == ( + "reproduction_digest_missing", + ) + assert packet.analysis_eligible_rows == () + + +def test_valid_same_effective_correction_remains_authoritative(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _append_same_effective_issuer_correction(manifest) + _append_later_corrected_issuer_history(manifest) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "identity_coverage").status == "passed" + assert _decision(packet, "reproduction_ready").status == "passed" + assert len(packet.membership_digests) == 2 + assert packet.analysis_eligible is True + + +def test_generic_same_issuer_fork_keeps_legacy_empty_digests(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _mutate_identity_lineage_exclusion(manifest, "lineage_fork") + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "identity_coverage").reason_codes == ( + "lineage_fork", + ) + assert len(packet.membership_digests) == 2 + assert all( + digest.member_count == 0 + and digest.sha256 == _sha256_members() + for digest in packet.membership_digests + ) + assert _decision(packet, "reproduction_ready").status == "passed" + + +def _append_later_same_issuer_fork(manifest) -> None: + def mutate(rows): + parent = next( + row + for row in rows + if row["identity_row_id"] == "id-corrected-issuer-later" + ) + parent["valid_to"] = "2020-12-01T00:00:00Z" + for suffix in ("a", "b"): + rows.append( + { + **parent, + "identity_row_id": f"id-later-fork-{suffix}", + "valid_from": "2020-12-01T00:00:00Z", + "valid_to": "", + "source_ref": ( + f"fixture://identity/later-fork-{suffix}" + ), + "source_published_at": "2020-12-01T00:00:00Z", + "retrieved_at": "2020-12-02T00:00:00Z", + "supersedes_identity_row_id": parent[ + "identity_row_id" + ], + } + ) + + _rewrite_csv_and_manifest( + manifest, + "security_identity", + mutate, + ) + + +def _append_valid_correction_then_later_fork(manifest) -> None: + _append_same_effective_issuer_correction(manifest) + _append_later_corrected_issuer_history(manifest) + _append_later_same_issuer_fork(manifest) + + +@pytest.mark.parametrize("reverse", (False, True)) +def test_later_fork_does_not_resurrect_corrected_parent( + tmp_path, + reverse, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _append_valid_correction_then_later_fork(manifest) + if reverse: + _reverse_contract_rows(manifest) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "identity_coverage").reason_codes == ( + "lineage_fork", + ) + assert len(packet.membership_digests) == 2 + assert all( + digest.member_count == 0 + and digest.sha256 == _sha256_members() + for digest in packet.membership_digests + ) + assert _decision(packet, "reproduction_ready").status == "passed" + assert packet.analysis_eligible is False + assert packet.analysis_eligible_rows == () + + +def test_later_fork_preserves_unrelated_scope_digest(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _make_research_consume_second_security(manifest) + _append_valid_correction_then_later_fork(manifest) + + packet = validate_point_in_time_universe(manifest, registry) + digests = { + digest.universe_id: digest + for digest in packet.membership_digests + } + + assert set(digests) == {"bench-1", "research-1"} + assert ( + digests["bench-1"].member_count, + digests["bench-1"].sha256, + ) == (0, _sha256_members()) + assert ( + digests["research-1"].member_count, + digests["research-1"].sha256, + ) == (1, _sha256_members("sec-2")) + assert _decision(packet, "reproduction_ready").status == "passed" + assert packet.analysis_eligible_rows == () + + +def _append_direct_same_issuer_fork_from_correction(manifest) -> None: + _append_same_effective_issuer_correction(manifest) + + def mutate(rows): + correction = next( + row + for row in rows + if row["identity_row_id"] == "id-issuer-correction" + ) + erroneous = next( + row + for row in rows + if row["identity_row_id"] + == correction["supersedes_identity_row_id"] + ) + erroneous["valid_to"] = "2020-06-01T00:00:00Z" + correction["valid_to"] = "2020-06-01T00:00:00Z" + for suffix in ("a", "b"): + rows.append( + { + **correction, + "identity_row_id": f"id-direct-fork-{suffix}", + "valid_from": "2020-06-01T00:00:00Z", + "valid_to": "", + "source_ref": ( + f"fixture://identity/direct-fork-{suffix}" + ), + "source_published_at": "2020-07-01T00:00:00Z", + "retrieved_at": "2020-07-02T00:00:00Z", + "supersedes_identity_row_id": correction[ + "identity_row_id" + ], + } + ) + + _rewrite_csv_and_manifest( + manifest, + "security_identity", + mutate, + ) + + +def _assert_generic_fork_reproduction(packet) -> None: + assert _decision(packet, "identity_coverage").reason_codes == ( + "lineage_fork", + ) + assert len(packet.membership_digests) == 2 + assert all( + digest.member_count == 0 + and digest.sha256 == _sha256_members() + for digest in packet.membership_digests + ) + assert _decision(packet, "reproduction_ready").status == "passed" + assert packet.analysis_eligible_rows == () + + +@pytest.mark.parametrize("reverse", (False, True)) +def test_direct_fork_from_correction_keeps_correction_authoritative( + tmp_path, + reverse, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _append_direct_same_issuer_fork_from_correction(manifest) + if reverse: + _reverse_contract_rows(manifest) + + packet = validate_point_in_time_universe(manifest, registry) + + _assert_generic_fork_reproduction(packet) + + +def test_competing_corrections_from_erroneous_parent_still_block_digest( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _append_invalid_same_effective_correction( + manifest, + "lineage_fork", + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert packet.membership_digests == () + assert _decision(packet, "reproduction_ready").reason_codes == ( + "reproduction_digest_missing", + ) + + +def test_intervening_corrected_interval_before_fork_remains_authoritative( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _append_valid_correction_then_later_fork(manifest) + + packet = validate_point_in_time_universe(manifest, registry) + + _assert_generic_fork_reproduction(packet) + + +def _append_outgoing_descendant_error( + manifest, + mode: str, +) -> None: + _append_same_effective_issuer_correction(manifest) + + def mutate(rows): + correction = next( + row + for row in rows + if row["identity_row_id"] == "id-issuer-correction" + ) + erroneous = next( + row + for row in rows + if row["identity_row_id"] + == correction["supersedes_identity_row_id"] + ) + descendant = { + **correction, + "identity_row_id": f"id-outgoing-{mode}", + "valid_from": "2020-06-01T00:00:00Z", + "valid_to": "", + "source_ref": f"fixture://identity/outgoing-{mode}", + "source_published_at": "2020-07-01T00:00:00Z", + "retrieved_at": "2020-07-02T00:00:00Z", + "supersedes_identity_row_id": correction[ + "identity_row_id" + ], + } + if mode == "lineage_order_reversed": + erroneous["valid_to"] = "2020-06-01T00:00:00Z" + correction["valid_to"] = "2020-06-01T00:00:00Z" + descendant.update( + source_published_at="2020-06-01T00:00:00Z", + retrieved_at="2020-06-01T00:00:00Z", + ) + elif mode == "lineage_cross_scope_parent": + erroneous["valid_to"] = "2020-06-01T00:00:00Z" + correction["valid_to"] = "2020-06-01T00:00:00Z" + descendant["security_id"] = "sec-descendant-other" + elif mode == "unavailable": + descendant.update( + source_published_at="2022-01-01T00:00:00Z", + retrieved_at="2022-01-02T00:00:00Z", + ) + elif mode != "identity_interval_overlap": + raise AssertionError(f"unsupported mode: {mode}") + rows.append(descendant) + + _rewrite_csv_and_manifest( + manifest, + "security_identity", + mutate, + ) + + +def _append_multigeneration_corrections_with_outgoing_error( + manifest, +) -> None: + _append_same_effective_issuer_correction(manifest) + + def mutate(rows): + first = next( + row + for row in rows + if row["identity_row_id"] == "id-issuer-correction" + ) + erroneous = next( + row + for row in rows + if row["identity_row_id"] + == first["supersedes_identity_row_id"] + ) + erroneous["valid_to"] = "2020-06-01T00:00:00Z" + first["valid_to"] = "2020-06-01T00:00:00Z" + second = { + **first, + "identity_row_id": "id-second-valid-correction", + "issuer_id": "issuer-final", + "source_ref": "fixture://identity/second-valid-correction", + "source_published_at": "2020-08-01T00:00:00Z", + "retrieved_at": "2020-08-02T00:00:00Z", + "supersedes_identity_row_id": first["identity_row_id"], + } + rows.append(second) + rows.append( + { + **second, + "identity_row_id": "id-second-cross-scope-descendant", + "security_id": "sec-descendant-other", + "valid_from": "2020-06-01T00:00:00Z", + "valid_to": "", + "source_ref": ( + "fixture://identity/second-cross-scope-descendant" + ), + "source_published_at": "2020-09-01T00:00:00Z", + "retrieved_at": "2020-09-02T00:00:00Z", + "supersedes_identity_row_id": second[ + "identity_row_id" + ], + } + ) + + _rewrite_csv_and_manifest( + manifest, + "security_identity", + mutate, + ) + + +def _assert_error_keeps_reproduction_digest(packet, reason: str) -> None: + assert reason in _decision( + packet, + "identity_coverage", + ).reason_codes + assert len(packet.membership_digests) == 2 + assert all( + digest.member_count == 0 + and digest.sha256 == _sha256_members() + for digest in packet.membership_digests + ) + assert _decision(packet, "reproduction_ready").status == "passed" + assert packet.analysis_eligible_rows == () + + +@pytest.mark.parametrize( + "mode", + ( + "lineage_order_reversed", + "lineage_cross_scope_parent", + ), +) +def test_outgoing_descendant_error_does_not_revoke_incoming_correction( + tmp_path, + mode, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _append_outgoing_descendant_error(manifest, mode) + + packet = validate_point_in_time_universe(manifest, registry) + + _assert_error_keeps_reproduction_digest(packet, mode) + + +def test_unavailable_descendant_does_not_revoke_visible_correction( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _append_outgoing_descendant_error(manifest, "unavailable") + + packet = validate_point_in_time_universe(manifest, registry) + + assert len(packet.membership_digests) == 2 + assert all( + digest.member_count == 1 + and digest.sha256 == _sha256_members("sec-1") + for digest in packet.membership_digests + ) + assert _decision(packet, "reproduction_ready").status == "passed" + + +def test_overlapping_descendant_does_not_revoke_incoming_correction( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _append_outgoing_descendant_error( + manifest, + "identity_interval_overlap", + ) + + packet = validate_point_in_time_universe(manifest, registry) + + _assert_error_keeps_reproduction_digest( + packet, + "identity_interval_overlap", + ) + + +def test_multigeneration_corrections_survive_later_outgoing_error( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _append_multigeneration_corrections_with_outgoing_error(manifest) + + packet = validate_point_in_time_universe(manifest, registry) + + _assert_error_keeps_reproduction_digest( + packet, + "lineage_cross_scope_parent", + ) + + +@pytest.mark.parametrize( + "mode", + ("lineage_order_reversed", "lineage_cycle"), +) +def test_invalid_correction_edge_remains_non_authoritative(tmp_path, mode): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _append_invalid_same_effective_correction(manifest, mode) + + packet = validate_point_in_time_universe(manifest, registry) + + assert packet.membership_digests == () + assert _decision(packet, "reproduction_ready").reason_codes == ( + "reproduction_digest_missing", + ) + + +def _append_invalid_correction_sibling( + manifest, + mode: str, +) -> None: + _append_same_effective_issuer_correction(manifest) + + def mutate(rows): + erroneous = next( + row + for row in rows + if row["identity_row_id"] == "id-1" + ) + sibling = { + **erroneous, + "identity_row_id": f"id-invalid-sibling-{mode}", + "issuer_id": "issuer-1", + "source_ref": f"fixture://identity/invalid-sibling-{mode}", + "source_published_at": "2020-08-01T00:00:00Z", + "retrieved_at": "2020-08-02T00:00:00Z", + "supersedes_identity_row_id": erroneous[ + "identity_row_id" + ], + } + if mode == "lineage_cross_scope_parent": + sibling["security_id"] = "sec-cross-scope-sibling" + elif mode == "wrong_security": + sibling["security_id"] = "sec-wrong-security-sibling" + elif mode == "wrong_start": + sibling["valid_from"] = "2020-06-01T00:00:00Z" + elif mode == "lineage_cycle": + erroneous["supersedes_identity_row_id"] = sibling[ + "identity_row_id" + ] + elif mode == "unavailable": + sibling.update( + source_published_at="2022-01-01T00:00:00Z", + retrieved_at="2022-01-02T00:00:00Z", + ) + else: + raise AssertionError(f"unsupported mode: {mode}") + rows.append(sibling) + + _rewrite_csv_and_manifest( + manifest, + "security_identity", + mutate, + ) + + +def _append_second_valid_correction_candidate(manifest) -> None: + _append_same_effective_issuer_correction(manifest) + + def mutate(rows): + correction = next( + row + for row in rows + if row["identity_row_id"] == "id-issuer-correction" + ) + rows.append( + { + **correction, + "identity_row_id": "id-second-valid-correction-candidate", + "source_ref": ( + "fixture://identity/second-valid-correction-candidate" + ), + "source_published_at": "2020-08-01T00:00:00Z", + "retrieved_at": "2020-08-02T00:00:00Z", + } + ) + + _rewrite_csv_and_manifest( + manifest, + "security_identity", + mutate, + ) + + +@pytest.mark.parametrize("reverse", (False, True)) +@pytest.mark.parametrize( + ("mode", "expected_reason"), + ( + ( + "lineage_cross_scope_parent", + "lineage_cross_scope_parent", + ), + ("wrong_security", "lineage_cross_scope_parent"), + ("wrong_start", "lineage_fork"), + ("lineage_cycle", "lineage_cycle"), + ), +) +def test_invalid_sibling_does_not_compete_with_valid_correction( + tmp_path, + mode, + expected_reason, + reverse, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _append_invalid_correction_sibling(manifest, mode) + if reverse: + _reverse_contract_rows(manifest) + + packet = validate_point_in_time_universe(manifest, registry) + + _assert_error_keeps_reproduction_digest( + packet, + expected_reason, + ) + + +def test_unavailable_sibling_does_not_compete_at_cutoff(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _append_invalid_correction_sibling(manifest, "unavailable") + + packet = validate_point_in_time_universe(manifest, registry) + + assert len(packet.membership_digests) == 2 + assert all( + digest.member_count == 1 + and digest.sha256 == _sha256_members("sec-1") + for digest in packet.membership_digests + ) + assert _decision(packet, "reproduction_ready").status == "passed" + + +def test_two_valid_correction_candidates_remain_non_authoritative( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _append_second_valid_correction_candidate(manifest) + + packet = validate_point_in_time_universe(manifest, registry) + + assert "lineage_fork" in _decision( + packet, + "identity_coverage", + ).reason_codes + assert packet.membership_digests == () + assert _decision(packet, "reproduction_ready").reason_codes == ( + "reproduction_digest_missing", + ) + assert packet.analysis_eligible_rows == () + + +def test_outgoing_descendant_remains_separate_from_incoming_candidates( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _append_outgoing_descendant_error( + manifest, + "lineage_cross_scope_parent", + ) + + packet = validate_point_in_time_universe(manifest, registry) + + _assert_error_keeps_reproduction_digest( + packet, + "lineage_cross_scope_parent", + ) + + +def _append_duplicate_correction_child( + manifest, + mode: str, +) -> None: + _append_same_effective_issuer_correction(manifest) + + def mutate(rows): + correction = next( + row + for row in rows + if row["identity_row_id"] == "id-issuer-correction" + ) + duplicate = { + **correction, + "source_ref": f"fixture://identity/duplicate-child-{mode}", + "source_published_at": "2020-08-01T00:00:00Z", + "retrieved_at": "2020-08-02T00:00:00Z", + } + if mode == "cross_scope": + duplicate["security_id"] = "sec-duplicate-child-other" + elif mode == "wrong_start": + duplicate["valid_from"] = "2020-06-01T00:00:00Z" + elif mode == "order_reversed": + duplicate.update( + source_published_at="2020-01-01T00:00:00Z", + retrieved_at="2020-01-01T00:00:00Z", + ) + elif mode == "unavailable": + duplicate.update( + source_published_at="2022-01-01T00:00:00Z", + retrieved_at="2022-01-02T00:00:00Z", + ) + else: + raise AssertionError(f"unsupported mode: {mode}") + rows.append(duplicate) + + _rewrite_csv_and_manifest( + manifest, + "security_identity", + mutate, + ) + + +def _append_duplicate_correction_parent( + manifest, + mode: str, +) -> None: + _append_same_effective_issuer_correction(manifest) + + def mutate(rows): + parent = next( + row + for row in rows + if row["identity_row_id"] == "id-1" + ) + duplicate = { + **parent, + "source_ref": f"fixture://identity/duplicate-parent-{mode}", + "source_published_at": "2020-09-01T00:00:00Z", + "retrieved_at": "2020-09-02T00:00:00Z", + } + if mode == "cross_scope": + duplicate["security_id"] = "sec-duplicate-parent-other" + elif mode == "same_scope": + pass + elif mode == "unavailable": + duplicate.update( + source_published_at="2022-01-01T00:00:00Z", + retrieved_at="2022-01-02T00:00:00Z", + ) + else: + raise AssertionError(f"unsupported mode: {mode}") + rows.append(duplicate) + + _rewrite_csv_and_manifest( + manifest, + "security_identity", + mutate, + ) + + +def _assert_duplicate_endpoint_is_non_authoritative(packet) -> None: + assert "lineage_duplicate_id" in _decision( + packet, + "identity_coverage", + ).reason_codes + assert packet.membership_digests == () + assert _decision(packet, "reproduction_ready").reason_codes == ( + "reproduction_digest_missing", + ) + assert packet.analysis_eligible_rows == () + + +@pytest.mark.parametrize("reverse", (False, True)) +@pytest.mark.parametrize( + "mode", + ("cross_scope", "wrong_start", "order_reversed"), +) +def test_duplicate_child_id_cannot_authorize_valid_looking_correction( + tmp_path, + mode, + reverse, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _append_duplicate_correction_child(manifest, mode) + if reverse: + _reverse_contract_rows(manifest) + + packet = validate_point_in_time_universe(manifest, registry) + + _assert_duplicate_endpoint_is_non_authoritative(packet) + + +@pytest.mark.parametrize("reverse", (False, True)) +@pytest.mark.parametrize("mode", ("same_scope", "cross_scope")) +def test_duplicate_parent_id_cannot_authorize_correction( + tmp_path, + mode, + reverse, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _append_duplicate_correction_parent(manifest, mode) + if reverse: + _reverse_contract_rows(manifest) + + packet = validate_point_in_time_universe(manifest, registry) + + _assert_duplicate_endpoint_is_non_authoritative(packet) + + +@pytest.mark.parametrize("endpoint", ("child", "parent")) +def test_post_cutoff_duplicate_endpoint_does_not_revoke_correction( + tmp_path, + endpoint, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + if endpoint == "child": + _append_duplicate_correction_child(manifest, "unavailable") + else: + _append_duplicate_correction_parent(manifest, "unavailable") + + packet = validate_point_in_time_universe(manifest, registry) + + assert len(packet.membership_digests) == 2 + assert all( + digest.member_count == 1 + and digest.sha256 == _sha256_members("sec-1") + for digest in packet.membership_digests + ) + assert _decision(packet, "reproduction_ready").status == "passed" + + +def _build_duplicate_target_chain( + manifest, + *, + duplicate_available: bool = True, + include_duplicate: bool = True, + reverse: bool = False, +) -> None: + def mutate_identities(rows): + grandparent = rows[0] + grandparent.update( + identity_row_id="id-duplicated-grandparent", + issuer_id="issuer-erroneous", + valid_to="2020-06-01T00:00:00Z", + source_ref="fixture://identity/grandparent-sec-1", + ) + duplicate = { + **grandparent, + "security_id": "sec-duplicate-scope", + "issuer_id": "issuer-duplicate-scope", + "source_ref": "fixture://identity/grandparent-duplicate", + "source_published_at": ( + "2020-01-01T00:00:00Z" + if duplicate_available + else "2022-01-01T00:00:00Z" + ), + "retrieved_at": ( + "2020-01-02T00:00:00Z" + if duplicate_available + else "2022-01-02T00:00:00Z" + ), + } + later = { + **grandparent, + "identity_row_id": "id-later-interval", + "valid_from": "2020-06-01T00:00:00Z", + "valid_to": "", + "source_ref": "fixture://identity/later-interval", + "source_published_at": "2020-06-01T00:00:00Z", + "retrieved_at": "2020-06-02T00:00:00Z", + "supersedes_identity_row_id": ( + grandparent["identity_row_id"] + ), + } + correction = { + **later, + "identity_row_id": "id-later-interval-correction", + "issuer_id": "issuer-1", + "source_ref": "fixture://identity/later-correction", + "source_published_at": "2020-07-01T00:00:00Z", + "retrieved_at": "2020-07-02T00:00:00Z", + "supersedes_identity_row_id": later["identity_row_id"], + } + unrelated = { + **grandparent, + "identity_row_id": "id-unrelated", + "security_id": "sec-unrelated", + "issuer_id": "issuer-unrelated", + "valid_to": "", + "source_ref": "fixture://identity/unrelated", + "supersedes_identity_row_id": "", + } + replacement = [grandparent] + if include_duplicate: + replacement.append(duplicate) + replacement.extend((later, correction, unrelated)) + if reverse: + replacement.reverse() + rows[:] = replacement + + _rewrite_csv_and_manifest( + manifest, + "security_identity", + mutate_identities, + ) + + def consume_unrelated_scope(rows): + research = next( + row + for row in rows + if row["universe_id"] == "research-1" + ) + research.update( + security_id="sec-unrelated", + source_ref="fixture://membership/research-unrelated", + ) + + _rewrite_csv_and_manifest( + manifest, + "membership", + consume_unrelated_scope, + ) + + +def _decision_signature(packet): + return tuple( + (area, item.status, item.reason_codes) + for area, item in packet.decisions.items() + ) + + +def _assert_affected_scope_fails_closed(packet) -> None: + assert _decision(packet, "identity_coverage").status == "blocked" + assert _decision(packet, "identity_coverage").reason_codes == ( + "lineage_duplicate_id", + ) + assert len(packet.membership_digests) == 1 + research = packet.membership_digests[0] + assert ( + research.universe_id, + research.member_count, + research.sha256, + ) == ( + "research-1", + 1, + _sha256_members("sec-unrelated"), + ) + assert _decision(packet, "reproduction_ready").reason_codes == ( + "reproduction_digest_missing", + ) + assert packet.analysis_eligible_rows == () + + +def test_duplicate_supersedes_target_is_input_order_independent(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + forward_root = tmp_path / "forward" + forward_root.mkdir() + forward_manifest, forward_registry = build_valid_package( + forward_root + ) + _build_duplicate_target_chain(forward_manifest) + + reverse_root = tmp_path / "reverse" + reverse_root.mkdir() + reverse_manifest, reverse_registry = build_valid_package( + reverse_root + ) + _build_duplicate_target_chain(reverse_manifest, reverse=True) + + forward = validate_point_in_time_universe( + forward_manifest, + forward_registry, + ) + reverse = validate_point_in_time_universe( + reverse_manifest, + reverse_registry, + ) + + assert _decision_signature(forward) == _decision_signature(reverse) + assert ( + forward.exclusion_reason_counts + == reverse.exclusion_reason_counts + ) + _assert_affected_scope_fails_closed(forward) + _assert_affected_scope_fails_closed(reverse) + + +def test_ambiguous_parent_cannot_authorize_fork_inference(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _build_duplicate_target_chain(manifest) + + def append_second_child(rows): + first_child = next( + row + for row in rows + if row["identity_row_id"] == "id-later-interval" + ) + rows.append( + { + **first_child, + "identity_row_id": "id-later-sibling", + "source_ref": "fixture://identity/later-sibling", + } + ) + + _rewrite_csv_and_manifest( + manifest, + "security_identity", + append_second_child, + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "identity_coverage").reason_codes == ( + "lineage_duplicate_id", + ) + assert "lineage_fork" not in packet.exclusion_reason_counts + _assert_affected_scope_fails_closed(packet) + + +def test_post_cutoff_duplicate_target_matches_unique_target(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + unique_root = tmp_path / "unique" + unique_root.mkdir() + unique_manifest, unique_registry = build_valid_package(unique_root) + _build_duplicate_target_chain( + unique_manifest, + include_duplicate=False, + ) + + unavailable_root = tmp_path / "unavailable" + unavailable_root.mkdir() + unavailable_manifest, unavailable_registry = build_valid_package( + unavailable_root + ) + _build_duplicate_target_chain( + unavailable_manifest, + duplicate_available=False, + ) + + unique = validate_point_in_time_universe( + unique_manifest, + unique_registry, + ) + unavailable = validate_point_in_time_universe( + unavailable_manifest, + unavailable_registry, + ) + + assert _decision_signature(unique) == _decision_signature( + unavailable + ) + assert unique.membership_digests == unavailable.membership_digests + assert "lineage_duplicate_id" not in _decision( + unavailable, + "identity_coverage", + ).reason_codes + + +def _append_identity_revision( + manifest, + *, + ticker: str = "AAA", + exchange: str = "XNYS", + same_valid_from: bool = False, +) -> None: + def mutate(rows): + prior = rows[0] + if not same_valid_from: + prior["valid_to"] = "2020-06-01T00:00:00Z" + rows.append( + { + **prior, + "identity_row_id": "id-2", + "ticker": ticker, + "exchange": exchange, + "valid_from": ( + prior["valid_from"] + if same_valid_from + else "2020-06-01T00:00:00Z" + ), + "valid_to": "", + "source_ref": "fixture://identity/id-2", + "source_published_at": "2020-06-01T00:00:00Z", + "retrieved_at": "2020-06-02T00:00:00Z", + "supersedes_identity_row_id": prior["identity_row_id"], + } + ) + + _rewrite_csv_and_manifest( + manifest, + "security_identity", + mutate, + ) + + +def _assert_action_reconciliation_blocked(packet) -> None: + assert _decision(packet, "technical_validity").status == "passed" + assert _decision(packet, "identity_coverage").status == "passed" + assert _decision(packet, "corporate_action_coverage").status == "blocked" + assert "corporate_action_evidence_missing" in _decision( + packet, + "corporate_action_coverage", + ).reason_codes + assert packet.analysis_eligible is False + + +def test_ticker_transition_requires_event_even_when_policy_is_not_applicable( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _append_identity_revision(manifest, ticker="BBB") + + packet = validate_point_in_time_universe(manifest, registry) + + _assert_action_reconciliation_blocked(packet) + assert packet.display_tickers == {"sec-1": "BBB"} + + +@pytest.mark.parametrize( + ("mutation", "event_options"), + [ + ("missing", None), + ("wrong_security", {"security_id": "sec-other"}), + ( + "wrong_time", + {"effective_at": "2020-07-01T00:00:00Z"}, + ), + ("post_cutoff", {"visible_at_cutoff": False}), + ], +) +def test_required_ticker_event_must_match_identity_transition( + tmp_path, + mutation, + event_options, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _append_identity_revision(manifest, ticker="BBB") + _rewrite_manifest( + manifest, + lambda raw: raw["corporate_action_policy"].update( + {"ticker_change": "required"} + ), + ) + if event_options is not None: + append_action_event( + manifest, + "ticker_change", + **event_options, + ) + + packet = validate_point_in_time_universe(manifest, registry) + + _assert_action_reconciliation_blocked(packet) + assert packet.display_tickers == {"sec-1": "BBB"}, mutation + + +@pytest.mark.parametrize( + ("field", "event_type", "revision"), + [ + ("ticker", "ticker_change", {"ticker": "BBB"}), + ("exchange", "exchange_change", {"exchange": "XNAS"}), + ], +) +def test_exact_identity_transition_event_reconciles_action( + tmp_path, + field, + event_type, + revision, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _append_identity_revision(manifest, **revision) + append_action_event(manifest, event_type) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "corporate_action_coverage").status == "passed" + assert _decision(packet, "corporate_action_coverage").reason_codes == () + assert { + ( + digest.universe_id, + digest.member_count, + digest.sha256, + ) + for digest in packet.membership_digests + } == { + ("bench-1", 1, STABLE_MEMBER_DIGEST), + ("research-1", 1, STABLE_MEMBER_DIGEST), + } + assert packet.analysis_eligible is True, field + + +@pytest.mark.parametrize( + ("present_event_types", "expected_status"), + [ + (("ticker_change",), "blocked"), + (("exchange_change",), "blocked"), + (("ticker_change", "exchange_change"), "passed"), + ], +) +def test_simultaneous_ticker_and_exchange_transition_requires_both_events( + tmp_path, + present_event_types, + expected_status, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _append_identity_revision( + manifest, + ticker="BBB", + exchange="XNAS", + ) + _rewrite_manifest( + manifest, + lambda raw: raw["corporate_action_policy"].update( + { + "ticker_change": "required", + "exchange_change": "required", + } + ), + ) + for event_type in present_event_types: + append_action_event(manifest, event_type) + + packet = validate_point_in_time_universe(manifest, registry) + + assert ( + _decision(packet, "corporate_action_coverage").status + == expected_status + ) + assert packet.analysis_eligible is (expected_status == "passed") + + +def test_same_ticker_on_different_security_does_not_create_action_requirement( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + def append_other_security(rows): + rows.append( + { + **rows[0], + "identity_row_id": "id-other-security", + "security_id": "sec-other", + "issuer_id": "issuer-other", + "source_ref": "fixture://identity/other-security", + "supersedes_identity_row_id": "", + } + ) + + _rewrite_csv_and_manifest( + manifest, + "security_identity", + append_other_security, + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "corporate_action_coverage").status == "passed" + assert packet.analysis_eligible is True + + +def test_same_valid_from_identity_correction_is_not_a_market_action( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _append_identity_revision( + manifest, + ticker="BBB", + exchange="XNAS", + same_valid_from=True, + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "identity_coverage").status == "passed" + assert _decision(packet, "corporate_action_coverage").status == "passed" + assert packet.display_tickers == {"sec-1": "BBB"} + assert packet.analysis_eligible is True + + +def _append_identity_transition( + manifest, + *, + ticker: str = "AAA", + exchange: str = "XNYS", +) -> None: + def mutate(rows): + prior = rows[0] + prior["valid_to"] = "2020-06-01T00:00:00Z" + rows.append( + { + **prior, + "identity_row_id": "id-new-identity", + "ticker": ticker, + "exchange": exchange, + "valid_from": "2020-06-01T00:00:00Z", + "valid_to": "", + "source_ref": "fixture://identity/new-identity", + "source_published_at": "2020-06-01T00:00:00Z", + "retrieved_at": "2020-06-02T00:00:00Z", + "supersedes_identity_row_id": prior["identity_row_id"], + } + ) + + _rewrite_csv_and_manifest( + manifest, + "security_identity", + mutate, + ) + + +@pytest.mark.parametrize( + "revision", + [ + {"ticker": "BBB"}, + {"exchange": "XNAS"}, + {"ticker": "BBB", "exchange": "XNAS"}, + ], +) +def test_identity_transition_groups_action_requirement_by_stable_security( + tmp_path, + revision, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _append_identity_transition(manifest, **revision) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "identity_coverage").status == "passed" + assert _decision(packet, "corporate_action_coverage").status == "blocked" + assert "corporate_action_evidence_missing" in _decision( + packet, + "corporate_action_coverage", + ).reason_codes + assert packet.analysis_eligible is False + + +@pytest.mark.parametrize( + ("event_type", "revision"), + [ + ("ticker_change", {"ticker": "BBB"}), + ("exchange_change", {"exchange": "XNAS"}), + ], +) +def test_exact_event_reconciles_identity_transition( + tmp_path, + event_type, + revision, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _append_identity_transition(manifest, **revision) + append_action_event(manifest, event_type) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "identity_coverage").status == "passed" + assert _decision(packet, "corporate_action_coverage").status == "passed" + assert packet.analysis_eligible is True + + +@pytest.mark.parametrize( + ("present_event_types", "expected_status"), + [ + (("ticker_change",), "blocked"), + (("exchange_change",), "blocked"), + (("ticker_change", "exchange_change"), "passed"), + ], +) +def test_identity_transition_changing_both_fields_requires_both_events( + tmp_path, + present_event_types, + expected_status, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _append_identity_transition( + manifest, + ticker="BBB", + exchange="XNAS", + ) + for event_type in present_event_types: + append_action_event(manifest, event_type) + + packet = validate_point_in_time_universe(manifest, registry) + + assert ( + _decision(packet, "corporate_action_coverage").status + == expected_status + ) + assert packet.analysis_eligible is (expected_status == "passed") + + +def _append_known_future_child(manifest) -> None: + def mutate(rows): + prior = rows[0] + prior["valid_to"] = "2022-01-01T00:00:00Z" + rows.append( + { + **prior, + "identity_row_id": "id-future-child", + "ticker": "BBB", + "valid_from": "2022-01-01T00:00:00Z", + "valid_to": "", + "source_ref": "fixture://identity/future-child", + "source_published_at": "2020-06-01T00:00:00Z", + "retrieved_at": "2020-06-02T00:00:00Z", + "supersedes_identity_row_id": prior["identity_row_id"], + } + ) + + _rewrite_csv_and_manifest( + manifest, + "security_identity", + mutate, + ) + + + + +def test_known_future_child_does_not_erase_active_ancestor_at_early_cutoff( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _append_known_future_child(manifest) + + packet = validate_point_in_time_universe(manifest, registry) + eligible_identity_ids = { + row.row_id + for row in packet.analysis_eligible_rows + if row.contract == "security_identity" + } + + assert _decision(packet, "identity_coverage").status == "passed" + assert _decision(packet, "corporate_action_coverage").status == "passed" + assert packet.display_tickers == {"sec-1": "AAA"} + assert eligible_identity_ids == {"id-1"} + assert { + digest.sha256 + for digest in packet.membership_digests + } == {STABLE_MEMBER_DIGEST} + assert packet.analysis_eligible is True + + +@pytest.mark.parametrize( + ("with_event", "expected_status"), + [ + (False, "blocked"), + (True, "passed"), + ], +) +def test_future_identity_transition_is_reconciled_when_it_becomes_effective( + tmp_path, + with_event, + expected_status, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _append_known_future_child(manifest) + _move_evaluations_to_2023(manifest) + _rewrite_manifest( + manifest, + lambda raw: raw["corporate_action_policy"].update( + {"ticker_change": "required"} + ), + ) + if with_event: + append_action_event( + manifest, + "ticker_change", + effective_at="2022-01-01T00:00:00Z", + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "identity_coverage").status == "passed" + assert ( + _decision(packet, "corporate_action_coverage").status + == expected_status + ) + assert packet.display_tickers == {"sec-1": "BBB"} + assert packet.analysis_eligible is (expected_status == "passed") + + +def test_adjacent_interval_on_different_security_is_independent(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + def append_other_security(rows): + rows.append( + { + **rows[0], + "identity_row_id": "id-other-security", + "security_id": "sec-other", + "issuer_id": "issuer-other", + "ticker": "BBB", + "valid_from": "2020-06-01T00:00:00Z", + "source_ref": "fixture://identity/other-security", + "supersedes_identity_row_id": "", + } + ) + + _rewrite_csv_and_manifest( + manifest, + "security_identity", + append_other_security, + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "corporate_action_coverage").status == "passed" + assert packet.analysis_eligible is True + + +REASON = "schema_identity_issuer_matches_security" + + +def _collapse_identity_identifiers(manifest) -> None: + _rewrite_csv_and_manifest( + manifest, + "security_identity", + lambda rows: rows[0].update( + issuer_id=rows[0]["security_id"], + ), + ) + + +def test_issuer_identifier_cannot_equal_security_identifier(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _collapse_identity_identifiers(manifest) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "technical_validity").status == "blocked" + assert _decision(packet, "technical_validity").reason_codes == (REASON,) + assert any( + row.contract == "security_identity" + and row.row_id == "id-1" + and row.reason_codes == (REASON,) + for row in packet.excluded + ) + assert packet.analysis_eligible is False + assert packet.analysis_eligible_rows == () + + +@pytest.mark.parametrize("command", ("status", "preview")) +def test_equal_identity_identifiers_are_cli_readable_and_write_free( + tmp_path, + command, +): + manifest, registry = build_valid_package(tmp_path) + _collapse_identity_identifiers(manifest) + before = _snapshot(tmp_path) + + result = _run_cli( + command, + "--manifest", + str(manifest), + "--registry", + str(registry), + ) + + assert result.returncode == 0 + assert "technical_validity: blocked" in result.stdout + assert REASON in result.stdout + assert "Traceback" not in result.stderr + assert _snapshot(tmp_path) == before + + +def test_distinct_unicode_security_and_issuer_identifiers_remain_valid( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + security_id = "證券-β-🚀" + issuer_id = "發行人-β-🚀" + _rewrite_csv_and_manifest( + manifest, + "security_identity", + lambda rows: rows[0].update( + security_id=security_id, + issuer_id=issuer_id, + ), + ) + for contract in ("membership", "events"): + _rewrite_csv_and_manifest( + manifest, + contract, + lambda rows: [ + row.update(security_id=security_id) + for row in rows + ], + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "technical_validity").status == "passed" + assert _decision(packet, "identity_coverage").status == "passed" + assert packet.analysis_eligible is True diff --git a/tests/test_point_in_time_universe_lineage.py b/tests/test_point_in_time_universe_lineage.py new file mode 100644 index 00000000..8e75ee03 --- /dev/null +++ b/tests/test_point_in_time_universe_lineage.py @@ -0,0 +1,89 @@ +from dataclasses import dataclass +from datetime import datetime, timezone + +import pytest + +from src.point_in_time_universe_lineage import resolve_lineage + + +@dataclass(frozen=True) +class Row: + row_id: str + parent_id: str + scope: str + available_at: datetime + + +T0 = datetime(2020, 1, 1, tzinfo=timezone.utc) +T1 = datetime(2021, 1, 1, tzinfo=timezone.utc) + + +def _resolve(rows, cutoff=T1): + return resolve_lineage( + rows, + row_id=lambda row: row.row_id, + parent_id=lambda row: row.parent_id, + scope=lambda row: row.scope, + available_at=lambda row: row.available_at, + cutoff=cutoff, + ) + + +def test_selects_latest_unambiguous_leaf_available_by_cutoff(): + rows = (Row("a", "", "scope", T0), Row("b", "a", "scope", T1)) + + result = _resolve(rows) + + assert result.leaves == (rows[1],) + assert result.reason_codes == () + + +def test_fork_blocks_scope_instead_of_picking_a_leaf(): + rows = ( + Row("a", "", "scope", T0), + Row("b", "a", "scope", T1), + Row("c", "a", "scope", T1), + ) + + result = _resolve(rows) + + assert result.leaves == () + assert "lineage_fork" in result.reason_codes + + +@pytest.mark.parametrize( + "rows,reason", + [ + ( + (Row("a", "", "s", T0), Row("a", "", "s", T1)), + "lineage_duplicate_id", + ), + ((Row("b", "missing", "s", T1),), "lineage_missing_parent"), + ( + (Row("a", "", "s1", T0), Row("b", "a", "s2", T1)), + "lineage_cross_scope_parent", + ), + ( + (Row("a", "", "s", T0), Row("b", "", "s", T1)), + "lineage_multiple_roots", + ), + ( + (Row("a", "b", "s", T0), Row("b", "a", "s", T1)), + "lineage_cycle", + ), + ( + (Row("a", "", "s", T1), Row("b", "a", "s", T0)), + "lineage_order_reversed", + ), + ], +) +def test_invalid_lineage_is_blocked(rows, reason): + assert reason in _resolve(rows).reason_codes + + +def test_post_cutoff_revision_is_not_selected(): + rows = (Row("a", "", "scope", T0), Row("b", "a", "scope", T1)) + + result = _resolve(rows, cutoff=T0) + + assert tuple(row.row_id for row in result.leaves) == ("a",) diff --git a/tests/test_point_in_time_universe_manifest.py b/tests/test_point_in_time_universe_manifest.py new file mode 100644 index 00000000..b84a5e60 --- /dev/null +++ b/tests/test_point_in_time_universe_manifest.py @@ -0,0 +1,697 @@ +from pathlib import Path +import json +import os + +import pytest + +from tests.point_in_time_universe_fixture import build_valid_package + + +def _file_bytes(root: Path) -> dict[str, bytes]: + return {p.relative_to(root).as_posix(): p.read_bytes() for p in root.rglob("*") if p.is_file()} + + +def _deeply_nested_sequence(depth: int = 2_000) -> bytes: + return ("[" * depth + "0" + "]" * depth).encode("utf-8") + + +def test_loads_hash_bound_manifest_without_writing(tmp_path): + from src.point_in_time_universe_manifest import load_universe_package + + manifest, registry = build_valid_package(tmp_path) + before = _file_bytes(tmp_path) + loaded = load_universe_package(manifest, registry) + assert loaded.manifest.schema_version == "point_in_time_universe_v1" + assert set(loaded.files) == {"security_identity", "membership", "events", "evaluations"} + assert _file_bytes(tmp_path) == before + + +def test_deeply_nested_manifest_is_stably_unreadable_without_writing(tmp_path): + from src.point_in_time_universe_manifest import load_universe_package + + manifest, registry = build_valid_package(tmp_path) + manifest.write_bytes(_deeply_nested_sequence()) + before = _file_bytes(tmp_path) + + with pytest.raises(ValueError, match="^manifest_unreadable$"): + load_universe_package(manifest, registry) + + assert _file_bytes(tmp_path) == before + + +def test_internal_snapshot_reader_recursion_propagates_unchanged( + tmp_path, + monkeypatch, +): + import src.point_in_time_universe_manifest as loader + + manifest, registry = build_valid_package(tmp_path) + failure = RecursionError("internal-reader-recursion") + before = _file_bytes(tmp_path) + + def fail_snapshot(*args, **kwargs): + raise failure + + monkeypatch.setattr(loader, "_bounded_snapshot", fail_snapshot) + + with pytest.raises(RecursionError) as caught: + loader.load_universe_package(manifest, registry) + + assert caught.value is failure + assert _file_bytes(tmp_path) == before + + +def test_deeply_nested_rights_registry_is_stably_unreadable(): + from src.commercial_source_rights import parse_source_rights_registry + + with pytest.raises( + ValueError, + match="^source_rights_registry_unreadable$", + ): + parse_source_rights_registry(_deeply_nested_sequence()) + + +def test_valid_rights_registry_snapshot_still_parses(tmp_path): + from src.commercial_source_rights import parse_source_rights_registry + + _, registry = build_valid_package(tmp_path) + + parsed = parse_source_rights_registry(registry.read_bytes()) + + assert "fixture_source" in parsed + + +def test_hash_and_row_count_use_the_same_immutable_file_snapshot( + tmp_path, + monkeypatch, +): + import src.point_in_time_universe_manifest as loader + + manifest, registry = build_valid_package(tmp_path) + identity_path = manifest.parent / "identity.csv" + verified_bytes = identity_path.read_bytes() + original_sha256 = loader._sha256 + raced = False + + def hash_then_append_row(snapshot): + nonlocal raced + digest = original_sha256(snapshot) + if snapshot == verified_bytes and not raced: + raced = True + original_row = snapshot.splitlines(keepends=True)[1] + path.write_bytes(snapshot + original_row) + return digest + + path = identity_path + monkeypatch.setattr(loader, "_sha256", hash_then_append_row) + + loaded = loader.load_universe_package(manifest, registry) + + assert raced is True + assert identity_path.read_bytes() != verified_bytes + assert loaded.contract_snapshots["security_identity"] == verified_bytes + + +def test_loaded_snapshots_are_immutable(tmp_path): + from src.point_in_time_universe_manifest import load_universe_package + + manifest, registry = build_valid_package(tmp_path) + loaded = load_universe_package(manifest, registry) + + with pytest.raises(TypeError): + loaded.contract_snapshots["security_identity"] = b"changed" + with pytest.raises(TypeError): + loaded.contract_snapshots["security_identity"][0] = 0 + with pytest.raises(TypeError): + loaded.registry_snapshot[0] = 0 + + +def test_bounded_reader_accepts_exact_limit_and_rejects_plus_one(tmp_path): + from src.point_in_time_universe_manifest import _bounded_snapshot + + path = tmp_path / "snapshot.bin" + path.write_bytes(b"abcd") + assert _bounded_snapshot( + path, + maximum_bytes=4, + size_error="too_large", + unreadable_error="unreadable", + ) == b"abcd" + + path.write_bytes(b"abcde") + with pytest.raises(ValueError, match="too_large"): + _bounded_snapshot( + path, + maximum_bytes=4, + size_error="too_large", + unreadable_error="unreadable", + ) + + +def test_bounded_reader_rejects_non_regular_input(tmp_path): + from src.point_in_time_universe_manifest import _bounded_snapshot + + with pytest.raises(ValueError, match="unreadable"): + _bounded_snapshot( + tmp_path, + maximum_bytes=4, + size_error="too_large", + unreadable_error="unreadable", + ) + + +def test_bounded_reader_never_requests_or_retains_more_than_limit_plus_one( + tmp_path, + monkeypatch, +): + import src.point_in_time_universe_manifest as loader + + path = tmp_path / "snapshot.bin" + path.write_bytes(b"abcd") + calls = [] + original_read = os.read + + def tracked_read(fd, amount): + chunk = original_read(fd, amount) + calls.append((amount, len(chunk))) + return chunk + + monkeypatch.setattr(loader.os, "read", tracked_read) + assert loader._bounded_snapshot( + path, + maximum_bytes=4, + size_error="too_large", + unreadable_error="unreadable", + ) == b"abcd" + + assert calls + remaining = 5 + for requested, returned in calls: + assert requested <= remaining + remaining -= returned + assert remaining >= 1 + + +def test_bounded_reader_completes_legal_short_reads_within_one_budget( + tmp_path, + monkeypatch, +): + import src.point_in_time_universe_manifest as loader + + path = tmp_path / "snapshot.bin" + path.write_bytes(b"abcdefgh") + original_read = os.read + calls = [] + + def short_read(descriptor, amount): + chunk = original_read(descriptor, min(amount, 2)) + calls.append((amount, len(chunk))) + return chunk + + monkeypatch.setattr(loader.os, "read", short_read) + + assert loader._bounded_snapshot( + path, + maximum_bytes=8, + size_error="too_large", + unreadable_error="unreadable", + ) == b"abcdefgh" + remaining = 9 + for requested, returned in calls: + assert requested <= remaining + remaining -= returned + assert remaining == 1 + + +def test_contract_prefix_digest_cannot_authorize_larger_stable_file( + tmp_path, + monkeypatch, +): + import src.point_in_time_universe_manifest as loader + + manifest, registry = build_valid_package(tmp_path) + identity = manifest.parent / "identity.csv" + authorized_prefix = identity.read_bytes() + identity.write_bytes( + authorized_prefix + + authorized_prefix.splitlines(keepends=True)[1] + ) + identity_inode = identity.stat().st_ino + original_read = os.read + shortened = False + + def prefix_first_read(descriptor, amount): + nonlocal shortened + if os.fstat(descriptor).st_ino == identity_inode and not shortened: + shortened = True + return original_read(descriptor, len(authorized_prefix)) + return original_read(descriptor, amount) + + monkeypatch.setattr(loader.os, "read", prefix_first_read) + + with pytest.raises(ValueError, match="manifest_hash_mismatch"): + loader.load_universe_package(manifest, registry) + assert shortened is True + + +@pytest.mark.parametrize("mutation,match", [ + ("hash", "manifest_hash_mismatch"), + ("row_count", "manifest_row_count_mismatch"), + ("schema", "manifest_schema_unsupported"), + ("registry", "manifest_registry_digest_mismatch"), +]) +def test_manifest_integrity_fails_closed(tmp_path, mutation, match): + from src.point_in_time_universe_manifest import load_universe_package + + manifest, registry = build_valid_package(tmp_path) + raw = json.loads(manifest.read_text()) + if mutation == "hash": + raw["files"][0]["sha256"] = "0" * 64 + elif mutation == "row_count": + raw["files"][0]["row_count"] += 1 + elif mutation == "schema": + raw["schema_version"] = "unknown" + else: + registry.write_text(registry.read_text() + "\n", encoding="utf-8") + manifest.write_text(json.dumps(raw), encoding="utf-8") + with pytest.raises(ValueError, match=match): + load_universe_package(manifest, registry) + + +def test_manifest_rejects_parent_traversal(tmp_path): + from src.point_in_time_universe_manifest import load_universe_package + + manifest, registry = build_valid_package(tmp_path) + raw = json.loads(manifest.read_text()) + raw["files"][0]["path"] = "../outside.csv" + manifest.write_text(json.dumps(raw), encoding="utf-8") + with pytest.raises(ValueError, match="manifest_path_unsafe"): + load_universe_package(manifest, registry) + + +def test_manifest_rejects_symlink_escape(tmp_path): + from src.point_in_time_universe_manifest import load_universe_package + + manifest, registry = build_valid_package(tmp_path) + outside = tmp_path / "outside.csv" + outside.write_text("identity_row_id\nid-1\n", encoding="utf-8") + link = manifest.parent / "escape.csv" + link.symlink_to(outside) + raw = json.loads(manifest.read_text()) + raw["files"][0].update(path="escape.csv", sha256=__import__("hashlib").sha256(outside.read_bytes()).hexdigest(), row_count=1) + manifest.write_text(json.dumps(raw), encoding="utf-8") + with pytest.raises(ValueError, match="manifest_path_unsafe"): + load_universe_package(manifest, registry) + + +@pytest.mark.parametrize("unlisted_kind", ["file", "symlink"]) +def test_manifest_rejects_unlisted_package_entry(tmp_path, unlisted_kind): + from src.point_in_time_universe_manifest import load_universe_package + + manifest, registry = build_valid_package(tmp_path) + extra = manifest.parent / "unlisted.csv" + if unlisted_kind == "file": + extra.write_text("unexpected\n", encoding="utf-8") + else: + extra.symlink_to(manifest.parent / "identity.csv") + with pytest.raises(ValueError, match="manifest_unlisted_file"): + load_universe_package(manifest, registry) + + +def test_manifest_rejects_missing_listed_file_with_stable_reason(tmp_path): + from src.point_in_time_universe_manifest import load_universe_package + + manifest, registry = build_valid_package(tmp_path) + raw = json.loads(manifest.read_text()) + (manifest.parent / raw["files"][0]["path"]).unlink() + with pytest.raises(ValueError, match="manifest_file_unreadable"): + load_universe_package(manifest, registry) + + +@pytest.mark.parametrize("mutation,match", [ + ("coverage", "manifest_coverage_semantics_invalid"), + ("declared_empty", "manifest_declared_universes_invalid"), + ("declared_blank_id", "manifest_declared_universes_invalid"), + ("declared_invalid_kind", "manifest_declared_universes_invalid"), + ("sources", "manifest_allowed_source_ids_invalid"), + ("walk_forward", "manifest_evaluation_policy_invalid"), + ("partition_boundaries", "manifest_evaluation_policy_invalid"), + ("corporate_actions", "manifest_corporate_action_policy_invalid"), + ("delisting", "manifest_delisting_policy_invalid"), + ("survivorship", "manifest_survivorship_policy_invalid"), + ("reproduction", "manifest_reproduction_contract_invalid"), +]) +def test_manifest_rejects_invalid_immutable_policy_semantics(tmp_path, mutation, match): + from src.point_in_time_universe_manifest import load_universe_package + + manifest, registry = build_valid_package(tmp_path) + raw = json.loads(manifest.read_text()) + if mutation == "coverage": + raw["coverage_semantics"] = "current_snapshot" + elif mutation == "declared_empty": + raw["declared_universes"] = [] + elif mutation == "declared_blank_id": + raw["declared_universes"][0]["universe_id"] = "" + elif mutation == "declared_invalid_kind": + raw["declared_universes"][0]["universe_kind"] = "current_universe" + elif mutation == "sources": + raw["allowed_source_ids"] = [] + elif mutation == "walk_forward": + raw["evaluation_policy"] = { + "kind": "walk_forward", + "minimum_history_count": 0, + } + elif mutation == "partition_boundaries": + raw["evaluation_policy"] = { + "kind": "train_validation_test", + "train_end_at": "2021-01-03T00:00:00Z", + "validation_start_at": "2021-01-02T00:00:00Z", + "validation_end_at": "2021-01-04T00:00:00Z", + "test_start_at": "2021-01-05T00:00:00Z", + } + elif mutation == "corporate_actions": + raw["corporate_action_policy"].pop("delisting") + elif mutation == "delisting": + raw["delisting_policy"]["missing_evidence"] = "ignore" + elif mutation == "survivorship": + raw["survivorship_policy"]["filter_by_current_listing_state"] = True + else: + raw["reproduction_contract"] = "membership_count_v0" + manifest.write_text(json.dumps(raw), encoding="utf-8") + with pytest.raises(ValueError, match=match): + load_universe_package(manifest, registry) + + +@pytest.mark.parametrize("mutation,match", [ + ("dataset_id", "manifest_dataset_id_invalid"), + ("manifest_id", "manifest_id_invalid"), + ("created_at", "manifest_created_at_invalid"), + ("created_at_without_timezone", "manifest_created_at_invalid"), + ("created_at_date_only_z", "manifest_created_at_invalid"), + ("cutoff_at", "manifest_observation_cutoff_at_invalid"), + ("cutoff_at_without_timezone", "manifest_observation_cutoff_at_invalid"), + ("cutoff_at_date_only_z", "manifest_observation_cutoff_at_invalid"), +]) +def test_manifest_rejects_invalid_identity_and_cutoff_metadata(tmp_path, mutation, match): + from src.point_in_time_universe_manifest import load_universe_package + + manifest, registry = build_valid_package(tmp_path) + raw = json.loads(manifest.read_text()) + if mutation == "dataset_id": + raw["dataset_id"] = "" + elif mutation == "manifest_id": + raw["manifest_id"] = None + elif mutation == "created_at": + raw["manifest_created_at"] = "not-a-timestamp" + elif mutation == "created_at_without_timezone": + raw["manifest_created_at"] = "2021-01-02T00:00:00" + elif mutation == "created_at_date_only_z": + raw["manifest_created_at"] = "2021-01-02Z" + elif mutation == "cutoff_at": + raw["observation_cutoff_at"] = "" + elif mutation == "cutoff_at_date_only_z": + raw["observation_cutoff_at"] = "2021-01-01Z" + else: + raw["observation_cutoff_at"] = "2021-01-01T00:00:00" + manifest.write_text(json.dumps(raw), encoding="utf-8") + with pytest.raises(ValueError, match=match): + load_universe_package(manifest, registry) + + +@pytest.mark.parametrize("mutation", ["path", "contract", "uppercase_sha256", "short_sha256", "negative_row_count", "bool_row_count"]) +def test_manifest_rejects_invalid_file_record_before_construction(tmp_path, mutation): + from src.point_in_time_universe_manifest import load_universe_package + + manifest, registry = build_valid_package(tmp_path) + raw = json.loads(manifest.read_text()) + file_record = raw["files"][0] + if mutation == "path": + file_record["path"] = "" + elif mutation == "contract": + file_record["contract"] = "" + elif mutation == "uppercase_sha256": + file_record["sha256"] = file_record["sha256"].upper() + elif mutation == "short_sha256": + file_record["sha256"] = "a" * 63 + elif mutation == "negative_row_count": + file_record["row_count"] = -1 + else: + file_record["row_count"] = True + manifest.write_text(json.dumps(raw), encoding="utf-8") + with pytest.raises(ValueError, match="manifest_file_record_invalid"): + load_universe_package(manifest, registry) + + +def test_manifest_size_limit_accepts_boundary_and_rejects_boundary_plus_one( + tmp_path, + monkeypatch, +): + import src.point_in_time_universe_manifest as loader + + manifest, registry = build_valid_package(tmp_path) + size = manifest.stat().st_size + monkeypatch.setattr(loader, "MAX_MANIFEST_BYTES", size) + loader.load_universe_package(manifest, registry) + + monkeypatch.setattr(loader, "MAX_MANIFEST_BYTES", size - 1) + with pytest.raises(ValueError, match="manifest_size_limit_exceeded"): + loader.load_universe_package(manifest, registry) + + +def test_contract_size_limits_accept_boundaries_and_reject_plus_one_before_read( + tmp_path, + monkeypatch, +): + import src.point_in_time_universe_manifest as loader + + manifest, registry = build_valid_package(tmp_path) + contract_paths = tuple( + path for path in manifest.parent.glob("*.csv") + ) + largest = max(path.stat().st_size for path in contract_paths) + original_read_bytes = Path.read_bytes + + monkeypatch.setattr(loader, "MAX_CONTRACT_SNAPSHOT_BYTES", largest) + loader.load_universe_package(manifest, registry) + + read_contracts: list[Path] = [] + + def track_reads(path): + if path.suffix == ".csv": + read_contracts.append(path) + return original_read_bytes(path) + + monkeypatch.setattr(Path, "read_bytes", track_reads) + monkeypatch.setattr(loader, "MAX_CONTRACT_SNAPSHOT_BYTES", largest - 1) + with pytest.raises(ValueError, match="manifest_file_size_limit_exceeded"): + loader.load_universe_package(manifest, registry) + assert not any( + path.stat().st_size == largest + for path in read_contracts + ) + + +def test_contract_post_read_growth_past_limit_is_rejected(tmp_path, monkeypatch): + import src.point_in_time_universe_manifest as loader + + manifest, registry = build_valid_package(tmp_path) + identity = manifest.parent / "identity.csv" + original_read = os.read + identity_inode = identity.stat().st_ino + limit = identity.stat().st_size + raced = False + + def read_then_grow(descriptor, amount): + nonlocal raced + snapshot = original_read(descriptor, amount) + if os.fstat(descriptor).st_ino == identity_inode and not raced: + raced = True + path.write_bytes(snapshot + b"x") + return snapshot + + path = identity + monkeypatch.setattr(loader, "MAX_CONTRACT_SNAPSHOT_BYTES", limit) + monkeypatch.setattr(loader.os, "read", read_then_grow) + with pytest.raises(ValueError, match="manifest_file_size_limit_exceeded"): + loader.load_universe_package(manifest, registry) + assert raced is True + + +def test_combined_contract_limit_accepts_boundary_and_rejects_plus_one( + tmp_path, + monkeypatch, +): + import src.point_in_time_universe_manifest as loader + + manifest, registry = build_valid_package(tmp_path) + total = sum(path.stat().st_size for path in manifest.parent.glob("*.csv")) + monkeypatch.setattr(loader, "MAX_TOTAL_CONTRACT_SNAPSHOT_BYTES", total) + loader.load_universe_package(manifest, registry) + + monkeypatch.setattr(loader, "MAX_TOTAL_CONTRACT_SNAPSHOT_BYTES", total - 1) + with pytest.raises( + ValueError, + match="manifest_total_snapshot_size_limit_exceeded", + ): + loader.load_universe_package(manifest, registry) + + +def test_combined_budget_bounds_each_descriptor_read_by_remaining_allowance( + tmp_path, + monkeypatch, +): + import src.point_in_time_universe_manifest as loader + + manifest, registry = build_valid_package(tmp_path) + raw = json.loads(manifest.read_text(encoding="utf-8")) + contract_paths = [ + manifest.parent / item["path"] + for item in raw["files"] + ] + exact_total = sum(path.stat().st_size for path in contract_paths) + monkeypatch.setattr( + loader, + "MAX_TOTAL_CONTRACT_SNAPSHOT_BYTES", + exact_total, + ) + loader.load_universe_package(manifest, registry) + + prefix_bytes = sum( + path.stat().st_size + for path in contract_paths[:-1] + ) + combined_limit = prefix_bytes + 1 + inode_to_path = { + path.stat().st_ino: path + for path in contract_paths + } + original_read = os.read + requests: list[tuple[Path, int, int]] = [] + + def track_contract_reads(descriptor, amount): + chunk = original_read(descriptor, amount) + path = inode_to_path.get(os.fstat(descriptor).st_ino) + if path is not None: + requests.append((path, amount, len(chunk))) + return chunk + + monkeypatch.setattr(loader.os, "read", track_contract_reads) + monkeypatch.setattr( + loader, + "MAX_TOTAL_CONTRACT_SNAPSHOT_BYTES", + combined_limit, + ) + + with pytest.raises( + ValueError, + match="manifest_total_snapshot_size_limit_exceeded", + ): + loader.load_universe_package(manifest, registry) + + bytes_read = sum(returned for _, _, returned in requests) + assert bytes_read <= combined_limit + 1 + final_path = contract_paths[-1] + final_requests = [ + (requested, returned) + for path, requested, returned in requests + if path == final_path + ] + assert sum(returned for _, returned in final_requests) <= 2 + assert all(requested <= 2 for requested, _ in final_requests) + + +def test_registry_size_limit_accepts_boundary_and_rejects_plus_one_before_read( + tmp_path, + monkeypatch, +): + import src.point_in_time_universe_manifest as loader + + manifest, registry = build_valid_package(tmp_path) + size = registry.stat().st_size + monkeypatch.setattr(loader, "MAX_RIGHTS_REGISTRY_BYTES", size) + loader.load_universe_package(manifest, registry) + + original_read_bytes = Path.read_bytes + registry_reads = 0 + + def track_reads(path): + nonlocal registry_reads + if path.resolve() == registry.resolve(): + registry_reads += 1 + return original_read_bytes(path) + + monkeypatch.setattr(Path, "read_bytes", track_reads) + monkeypatch.setattr(loader, "MAX_RIGHTS_REGISTRY_BYTES", size - 1) + with pytest.raises(ValueError, match="manifest_registry_size_limit_exceeded"): + loader.load_universe_package(manifest, registry) + assert registry_reads == 0 + + +def test_registry_post_read_growth_past_limit_is_rejected(tmp_path, monkeypatch): + import src.point_in_time_universe_manifest as loader + + manifest, registry = build_valid_package(tmp_path) + original_read = os.read + registry_inode = registry.stat().st_ino + limit = registry.stat().st_size + raced = False + + def read_then_grow(descriptor, amount): + nonlocal raced + snapshot = original_read(descriptor, amount) + if os.fstat(descriptor).st_ino == registry_inode and not raced: + raced = True + path.write_bytes(snapshot + b"x") + return snapshot + + path = registry + monkeypatch.setattr(loader, "MAX_RIGHTS_REGISTRY_BYTES", limit) + monkeypatch.setattr(loader.os, "read", read_then_grow) + with pytest.raises(ValueError, match="manifest_registry_size_limit_exceeded"): + loader.load_universe_package(manifest, registry) + assert raced is True + + +def test_declared_row_limit_accepts_boundary_and_rejects_plus_one_before_contract_read( + tmp_path, + monkeypatch, +): + import src.point_in_time_universe_manifest as loader + + manifest, registry = build_valid_package(tmp_path) + monkeypatch.setattr(loader, "MAX_DECLARED_ROWS_PER_CONTRACT", 2) + loader.load_universe_package(manifest, registry) + + original_read_bytes = Path.read_bytes + contract_reads: list[Path] = [] + + def track_reads(path): + if path.suffix == ".csv": + contract_reads.append(path) + return original_read_bytes(path) + + monkeypatch.setattr(Path, "read_bytes", track_reads) + monkeypatch.setattr(loader, "MAX_DECLARED_ROWS_PER_CONTRACT", 1) + with pytest.raises(ValueError, match="manifest_row_count_limit_exceeded"): + loader.load_universe_package(manifest, registry) + assert contract_reads == [] + + +def test_package_traversal_limit_accepts_boundary_and_stops_at_plus_one( + tmp_path, + monkeypatch, +): + import src.point_in_time_universe_manifest as loader + + manifest, registry = build_valid_package(tmp_path) + entry_count = sum(1 for _ in manifest.parent.iterdir()) + monkeypatch.setattr(loader, "MAX_PACKAGE_TRAVERSAL_ENTRIES", entry_count) + loader.load_universe_package(manifest, registry) + + monkeypatch.setattr( + loader, + "MAX_PACKAGE_TRAVERSAL_ENTRIES", + entry_count - 1, + ) + with pytest.raises(ValueError, match="manifest_package_entry_limit_exceeded"): + loader.load_universe_package(manifest, registry) diff --git a/tests/test_point_in_time_universe_manifest_safety.py b/tests/test_point_in_time_universe_manifest_safety.py new file mode 100644 index 00000000..039c6f2c --- /dev/null +++ b/tests/test_point_in_time_universe_manifest_safety.py @@ -0,0 +1,1120 @@ +from __future__ import annotations + +import os +from pathlib import Path +import shutil +import socket +import subprocess +import sys +import tempfile +import pytest +from tests.point_in_time_universe_fixture import build_valid_package +from tests.test_point_in_time_universe import ( + _decision, + _rewrite_manifest, +) +from tests.test_point_in_time_universe_cli import ( + _run_cli, + _run_make, + _snapshot, +) +from dataclasses import FrozenInstanceError, replace +import json + + +def _set_manifest_enum_value( + manifest, + location: str, + value, +) -> None: + def mutate(raw): + if location == "coverage_semantics": + raw["coverage_semantics"] = value + elif location == "universe_kind": + raw["declared_universes"][0]["universe_kind"] = value + else: + raw["corporate_action_policy"]["listing"] = value + + _rewrite_manifest(manifest, mutate) + + +@pytest.mark.parametrize( + ("location", "value", "error"), + [ + ( + "coverage_semantics", + ["complete_snapshot"], + "manifest_coverage_semantics_invalid", + ), + ( + "coverage_semantics", + {"state": "complete_snapshot"}, + "manifest_coverage_semantics_invalid", + ), + ( + "universe_kind", + ["benchmark"], + "manifest_declared_universes_invalid", + ), + ( + "universe_kind", + {"kind": "benchmark"}, + "manifest_declared_universes_invalid", + ), + ( + "corporate_action_policy", + ["required"], + "manifest_corporate_action_policy_invalid", + ), + ( + "corporate_action_policy", + {"state": "required"}, + "manifest_corporate_action_policy_invalid", + ), + ], +) +def test_non_scalar_manifest_enum_has_stable_validator_failure( + tmp_path, + location, + value, + error, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _set_manifest_enum_value(manifest, location, value) + + with pytest.raises(ValueError, match=f"^{error}$"): + validate_point_in_time_universe(manifest, registry) + + +def test_non_scalar_manifest_enum_is_readable_and_write_free_everywhere( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _set_manifest_enum_value( + manifest, + "corporate_action_policy", + {"state": "required"}, + ) + before = _snapshot(tmp_path) + error = "manifest_corporate_action_policy_invalid" + + with pytest.raises(ValueError, match=f"^{error}$"): + validate_point_in_time_universe(manifest, registry) + + cli = _run_cli( + "status", + "--manifest", + str(manifest), + "--registry", + str(registry), + ) + make = _run_make( + "point-in-time-universe-status", + f"MANIFEST={manifest}", + f"REGISTRY={registry}", + ) + + for result in (cli, make): + assert result.returncode == 2 + assert error in result.stderr + assert "Traceback" not in result.stderr + assert _snapshot(tmp_path) == before + + +def test_scalar_manifest_enum_values_remain_valid(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "manifest_integrity").status == "passed" + assert packet.analysis_eligible is True + + +def _set_contract_path(manifest, contract: str, path: str) -> None: + def mutate(raw): + entry = next( + item + for item in raw["files"] + if item["contract"] == contract + ) + entry["path"] = path + + _rewrite_manifest(manifest, mutate) + + +def test_final_contract_symlink_swap_cannot_escape_package( + tmp_path, + monkeypatch, +): + import src.point_in_time_universe_manifest as loader + + manifest, registry = build_valid_package(tmp_path) + identity = manifest.parent / "identity.csv" + outside = tmp_path / "outside-identity.csv" + outside.write_bytes(identity.read_bytes()) + original_open = loader.os.open + swapped = False + + def swap_before_open(path, flags, mode=0o777, *, dir_fd=None): + nonlocal swapped + if not swapped and Path(path).name == "identity.csv": + swapped = True + identity.unlink() + identity.symlink_to(outside) + return original_open(path, flags, mode, dir_fd=dir_fd) + + monkeypatch.setattr(loader.os, "open", swap_before_open) + + with pytest.raises(ValueError, match="^manifest_file_unreadable$"): + loader.load_universe_package(manifest, registry) + assert swapped is True + + +def test_intermediate_contract_symlink_swap_cannot_escape_package( + tmp_path, + monkeypatch, +): + import src.point_in_time_universe_manifest as loader + + manifest, registry = build_valid_package(tmp_path) + nested = manifest.parent / "nested" + nested.mkdir() + identity = manifest.parent / "identity.csv" + nested_identity = nested / "identity.csv" + identity.rename(nested_identity) + _set_contract_path( + manifest, + "security_identity", + "nested/identity.csv", + ) + outside = tmp_path / "outside-package" + outside.mkdir() + (outside / "identity.csv").write_bytes( + nested_identity.read_bytes() + ) + saved = tmp_path / "saved-nested" + original_inventory = loader._reject_unlisted_files + + def inventory_then_swap(*args, **kwargs): + result = original_inventory(*args, **kwargs) + nested.rename(saved) + nested.symlink_to(outside, target_is_directory=True) + return result + + monkeypatch.setattr( + loader, + "_reject_unlisted_files", + inventory_then_swap, + ) + + with pytest.raises(ValueError, match="^manifest_file_unreadable$"): + loader.load_universe_package(manifest, registry) + + +def test_package_directory_descriptor_survives_path_rename( + tmp_path, + monkeypatch, +): + import src.point_in_time_universe_manifest as loader + + manifest, registry = build_valid_package(tmp_path) + package = manifest.parent + moved = tmp_path / "moved-package" + expected = (package / "identity.csv").read_bytes() + original_inventory = loader._reject_unlisted_files + + def rename_then_inventory(*args, **kwargs): + package.rename(moved) + return original_inventory(*args, **kwargs) + + monkeypatch.setattr( + loader, + "_reject_unlisted_files", + rename_then_inventory, + ) + + loaded = loader.load_universe_package(manifest, registry) + + assert loaded.contract_snapshots["security_identity"] == expected + + +def test_valid_nested_contract_path_loads_from_verified_snapshot(tmp_path): + from src.point_in_time_universe_manifest import load_universe_package + + manifest, registry = build_valid_package(tmp_path) + nested = manifest.parent / "nested" + nested.mkdir() + identity = manifest.parent / "identity.csv" + expected = identity.read_bytes() + identity.rename(nested / "identity.csv") + _set_contract_path( + manifest, + "security_identity", + "nested/identity.csv", + ) + + loaded = load_universe_package(manifest, registry) + + assert loaded.contract_snapshots["security_identity"] == expected + + +@pytest.mark.parametrize("kind", ("fifo", "directory")) +def test_contract_special_file_is_stably_unreadable(tmp_path, kind): + from src.point_in_time_universe_manifest import load_universe_package + + manifest, registry = build_valid_package(tmp_path) + identity = manifest.parent / "identity.csv" + identity.unlink() + if kind == "fifo": + os.mkfifo(identity) + else: + identity.mkdir() + + with pytest.raises(ValueError, match="^manifest_file_unreadable$"): + load_universe_package(manifest, registry) + + +def test_standalone_rights_rejects_final_symlink(tmp_path): + from src.commercial_source_rights import load_source_rights_registry + + _, registry = build_valid_package(tmp_path) + outside = tmp_path / "outside-rights.yml" + outside.write_bytes(registry.read_bytes()) + link = tmp_path / "rights-link.yml" + link.symlink_to(outside) + + with pytest.raises( + ValueError, + match="^source_rights_registry_unreadable$", + ): + load_source_rights_registry(link) + + +def test_standalone_rights_rejects_directory(tmp_path): + from src.commercial_source_rights import load_source_rights_registry + + directory = tmp_path / "rights-directory" + directory.mkdir() + + with pytest.raises( + ValueError, + match="^source_rights_registry_unreadable$", + ): + load_source_rights_registry(directory) + + +def test_standalone_rights_fifo_is_nonblocking_and_unreadable(tmp_path): + fifo = tmp_path / "rights.fifo" + os.mkfifo(fifo) + script = ( + "import sys\n" + "from src.commercial_source_rights import " + "load_source_rights_registry\n" + "try:\n" + f" load_source_rights_registry({str(fifo)!r})\n" + "except ValueError as exc:\n" + " print(exc, file=sys.stderr)\n" + " raise SystemExit(2)\n" + ) + + result = subprocess.run( + [sys.executable, "-c", script], + cwd=Path(__file__).resolve().parent.parent, + check=False, + capture_output=True, + text=True, + timeout=2, + ) + + assert result.returncode == 2 + assert "source_rights_registry_unreadable" in result.stderr + assert "Traceback" not in result.stderr + + +def test_standalone_rights_rejects_size_limit_plus_one( + tmp_path, + monkeypatch, +): + import src.commercial_source_rights as rights + + _, registry = build_valid_package(tmp_path) + size = registry.stat().st_size + monkeypatch.setattr( + rights, + "MAX_SOURCE_RIGHTS_REGISTRY_BYTES", + size, + raising=False, + ) + rights.load_source_rights_registry(registry) + + registry.write_bytes(registry.read_bytes() + b" ") + with pytest.raises( + ValueError, + match="^source_rights_registry_size_limit_exceeded$", + ): + rights.load_source_rights_registry(registry) + + +def test_standalone_rights_completes_short_reads( + tmp_path, + monkeypatch, +): + import src.point_in_time_universe_manifest as loader + import src.commercial_source_rights as rights + + _, registry = build_valid_package(tmp_path) + original_read = loader.os.read + calls = 0 + + def short_read(descriptor, amount): + nonlocal calls + calls += 1 + return original_read(descriptor, min(amount, 7)) + + monkeypatch.setattr(loader.os, "read", short_read) + + parsed = rights.load_source_rights_registry(registry) + + assert "fixture_source" in parsed + assert calls > 1 + + +def test_standalone_rights_rejects_post_read_growth( + tmp_path, + monkeypatch, +): + import src.point_in_time_universe_manifest as loader + import src.commercial_source_rights as rights + + _, registry = build_valid_package(tmp_path) + original_read = loader.os.read + inode = registry.stat().st_ino + size = registry.stat().st_size + grew = False + + def read_then_grow(descriptor, amount): + nonlocal grew + chunk = original_read(descriptor, amount) + if os.fstat(descriptor).st_ino == inode and not grew: + grew = True + registry.write_bytes(chunk + b" ") + return chunk + + monkeypatch.setattr( + rights, + "MAX_SOURCE_RIGHTS_REGISTRY_BYTES", + size, + raising=False, + ) + monkeypatch.setattr(loader.os, "read", read_then_grow) + + with pytest.raises( + ValueError, + match="^source_rights_registry_size_limit_exceeded$", + ): + rights.load_source_rights_registry(registry) + assert grew is True + + +def test_default_standalone_rights_registry_remains_valid(): + from src.commercial_source_rights import load_source_rights_registry + + registry = load_source_rights_registry() + + assert set(registry) == {"sec_companyfacts", "yfinance"} + + +def test_package_view_is_bound_before_manifest_read( + tmp_path, + monkeypatch, +): + import src.point_in_time_universe_manifest as loader + + manifest, registry = build_valid_package(tmp_path) + package = manifest.parent + replacement = tmp_path / "replacement-staged" + shutil.copytree(package, replacement) + (replacement / "unlisted-after-swap.txt").write_text( + "replacement marker", + encoding="utf-8", + ) + original_package = tmp_path / "original-package" + expected = (package / "identity.csv").read_bytes() + original_open = loader.os.open + swapped = False + + def swap_before_manifest(path, flags, mode=0o777, *, dir_fd=None): + nonlocal swapped + if not swapped and Path(path).name == "manifest.json": + swapped = True + package.rename(original_package) + replacement.rename(package) + return original_open(path, flags, mode, dir_fd=dir_fd) + + monkeypatch.setattr(loader.os, "open", swap_before_manifest) + + loaded = loader.load_universe_package(manifest, registry) + + assert swapped is True + assert loaded.contract_snapshots["security_identity"] == expected + + +@pytest.mark.parametrize("flag", ("O_NOFOLLOW", "O_DIRECTORY")) +def test_package_open_capability_gap_fails_closed( + tmp_path, + monkeypatch, + flag, +): + import src.point_in_time_universe_manifest as loader + + manifest, registry = build_valid_package(tmp_path) + monkeypatch.delattr(loader.os, flag) + + with pytest.raises( + ValueError, + match="^manifest_package_unreadable$", + ): + loader.load_universe_package(manifest, registry) + + +def test_relative_open_not_implemented_is_stably_unreadable( + tmp_path, + monkeypatch, +): + import src.point_in_time_universe_manifest as loader + + manifest, registry = build_valid_package(tmp_path) + original_open = loader.os.open + + def reject_relative_open(path, flags, mode=0o777, *, dir_fd=None): + if dir_fd is not None: + raise NotImplementedError("relative open unsupported") + return original_open(path, flags, mode, dir_fd=dir_fd) + + monkeypatch.setattr(loader.os, "open", reject_relative_open) + + with pytest.raises(ValueError, match="^manifest_unreadable$"): + loader.load_universe_package(manifest, registry) + + +def test_standalone_rights_symlink_fails_closed_without_nofollow( + tmp_path, + monkeypatch, +): + import src.point_in_time_universe_manifest as loader + from src.commercial_source_rights import load_source_rights_registry + + _, registry = build_valid_package(tmp_path) + link = tmp_path / "rights-link-without-nofollow.yml" + link.symlink_to(registry) + monkeypatch.delattr(loader.os, "O_NOFOLLOW") + + with pytest.raises( + ValueError, + match="^source_rights_registry_unreadable$", + ): + load_source_rights_registry(link) + + +@pytest.mark.parametrize("with_unlisted_file", (False, True)) +def test_nested_contract_read_reuses_inventoried_directory_view( + tmp_path, + monkeypatch, + with_unlisted_file, +): + import src.point_in_time_universe_manifest as loader + + manifest, registry = build_valid_package(tmp_path) + nested = manifest.parent / "nested" + nested.mkdir() + identity = manifest.parent / "identity.csv" + expected = identity.read_bytes() + identity.rename(nested / "identity.csv") + _set_contract_path( + manifest, + "security_identity", + "nested/identity.csv", + ) + replacement = tmp_path / "replacement-nested" + shutil.copytree(nested, replacement) + if with_unlisted_file: + (replacement / "unlisted.txt").write_text( + "replacement marker", + encoding="utf-8", + ) + original_nested_inode = nested.stat().st_ino + saved = tmp_path / "saved-nested" + original_inventory = loader._reject_unlisted_files + swapped = False + + def inventory_then_swap(*args, **kwargs): + nonlocal swapped + result = original_inventory(*args, **kwargs) + nested.rename(saved) + replacement.rename(nested) + swapped = True + return result + + monkeypatch.setattr( + loader, + "_reject_unlisted_files", + inventory_then_swap, + ) + + with pytest.raises( + ValueError, + match="^manifest_file_unreadable$", + ): + loader.load_universe_package(manifest, registry) + assert swapped is True + assert (saved / "identity.csv").read_bytes() == expected + assert saved.stat().st_ino == original_nested_inode + + +def test_valid_multi_level_nested_contract_uses_verified_snapshot( + tmp_path, +): + from src.point_in_time_universe_manifest import load_universe_package + + manifest, registry = build_valid_package(tmp_path) + nested = manifest.parent / "one" / "two" + nested.mkdir(parents=True) + identity = manifest.parent / "identity.csv" + expected = identity.read_bytes() + identity.rename(nested / "identity.csv") + _set_contract_path( + manifest, + "security_identity", + "one/two/identity.csv", + ) + + loaded = load_universe_package(manifest, registry) + + assert loaded.contract_snapshots["security_identity"] == expected + + +def test_inventoried_multi_level_view_rejects_rename_before_read( + tmp_path, + monkeypatch, +): + import src.point_in_time_universe_manifest as loader + + manifest, registry = build_valid_package(tmp_path) + parent = manifest.parent / "one" + nested = parent / "two" + nested.mkdir(parents=True) + identity = manifest.parent / "identity.csv" + expected = identity.read_bytes() + identity.rename(nested / "identity.csv") + _set_contract_path( + manifest, + "security_identity", + "one/two/identity.csv", + ) + moved = tmp_path / "moved-one" + original_inventory = loader._reject_unlisted_files + + def inventory_then_rename(*args, **kwargs): + result = original_inventory(*args, **kwargs) + parent.rename(moved) + return result + + monkeypatch.setattr( + loader, + "_reject_unlisted_files", + inventory_then_rename, + ) + + with pytest.raises( + ValueError, + match="^manifest_file_unreadable$", + ): + loader.load_universe_package(manifest, registry) + assert (moved / "two" / "identity.csv").read_bytes() == expected + + +def test_unlisted_file_added_to_inventoried_view_before_read_is_rejected( + tmp_path, + monkeypatch, +): + import src.point_in_time_universe_manifest as loader + + manifest, registry = build_valid_package(tmp_path) + nested = manifest.parent / "nested" + nested.mkdir() + identity = manifest.parent / "identity.csv" + identity.rename(nested / "identity.csv") + _set_contract_path( + manifest, + "security_identity", + "nested/identity.csv", + ) + original_inventory = loader._reject_unlisted_files + + def inventory_then_add_unlisted(*args, **kwargs): + result = original_inventory(*args, **kwargs) + (nested / "unlisted.txt").write_text( + "added after inventory", + encoding="utf-8", + ) + return result + + monkeypatch.setattr( + loader, + "_reject_unlisted_files", + inventory_then_add_unlisted, + ) + + with pytest.raises( + ValueError, + match="^manifest_unlisted_file$", + ): + loader.load_universe_package(manifest, registry) + + +def test_nested_contract_directory_replacement_after_snapshot_is_rejected( + tmp_path, + monkeypatch, +): + import src.point_in_time_universe_manifest as loader + + manifest, registry = build_valid_package(tmp_path) + nested = manifest.parent / "nested" + nested.mkdir() + identity = manifest.parent / "identity.csv" + identity.rename(nested / "identity.csv") + _set_contract_path( + manifest, + "security_identity", + "nested/identity.csv", + ) + replacement = tmp_path / "replacement-nested-after-read" + shutil.copytree(nested, replacement) + saved = tmp_path / "saved-nested-after-read" + original_snapshot = loader._bounded_snapshot + swapped = False + + def snapshot_then_replace(path, **kwargs): + nonlocal swapped + snapshot = original_snapshot(path, **kwargs) + if not swapped and Path(path).name == "identity.csv": + nested.rename(saved) + replacement.rename(nested) + swapped = True + return snapshot + + monkeypatch.setattr( + loader, + "_bounded_snapshot", + snapshot_then_replace, + ) + + with pytest.raises( + ValueError, + match="^manifest_file_unreadable$", + ): + loader.load_universe_package(manifest, registry) + assert swapped is True + + +def test_contract_free_directory_replacement_is_rejected( + tmp_path, + monkeypatch, +): + import src.point_in_time_universe_manifest as loader + + manifest, registry = build_valid_package(tmp_path) + auxiliary = manifest.parent / "auxiliary" + auxiliary.mkdir() + replacement = tmp_path / "replacement-auxiliary" + replacement.mkdir() + saved = tmp_path / "saved-auxiliary" + original_inventory = loader._reject_unlisted_files + + def inventory_then_replace(*args, **kwargs): + result = original_inventory(*args, **kwargs) + auxiliary.rename(saved) + replacement.rename(auxiliary) + return result + + monkeypatch.setattr( + loader, + "_reject_unlisted_files", + inventory_then_replace, + ) + + with pytest.raises( + ValueError, + match="^manifest_package_unreadable$", + ): + loader.load_universe_package(manifest, registry) + + +@pytest.mark.parametrize( + ("mutation", "error"), + [ + ("add", "manifest_unlisted_file"), + ("remove", "manifest_file_unreadable"), + ], +) +def test_final_inventory_validation_rejects_late_file_set_change( + tmp_path, + monkeypatch, + mutation, + error, +): + import src.point_in_time_universe_manifest as loader + + manifest, registry = build_valid_package(tmp_path) + identity = manifest.parent / "identity.csv" + original_validate = loader._validate_open_package_inventory + validation_calls = 0 + + def mutate_after_last_post_validation(*args, **kwargs): + nonlocal validation_calls + result = original_validate(*args, **kwargs) + validation_calls += 1 + if validation_calls == 8: + if mutation == "add": + (manifest.parent / "late-unlisted.txt").write_text( + "late addition", + encoding="utf-8", + ) + else: + identity.unlink() + return result + + monkeypatch.setattr( + loader, + "_validate_open_package_inventory", + mutate_after_last_post_validation, + ) + + with pytest.raises(ValueError, match=f"^{error}$"): + loader.load_universe_package(manifest, registry) + assert validation_calls >= 8 + + +@pytest.mark.parametrize("kind", ("fifo", "socket")) +def test_unlisted_special_entry_is_rejected( + monkeypatch, + kind, +): + from src.point_in_time_universe_manifest import load_universe_package + + if kind == "socket" and not hasattr(socket, "AF_UNIX"): + pytest.skip("local sockets are unavailable") + short_tmp_path = Path(tempfile.mkdtemp(prefix="pit-", dir="/tmp")) + manifest, registry = build_valid_package(short_tmp_path) + special = manifest.parent / f"unlisted-{kind}" + local_socket = None + if kind == "fifo": + os.mkfifo(special) + else: + monkeypatch.chdir(manifest.parent) + local_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + local_socket.bind(special.name) + except OSError: + local_socket.close() + shutil.rmtree(short_tmp_path) + pytest.skip("local socket paths are unavailable") + + try: + with pytest.raises( + ValueError, + match="^manifest_unlisted_file$", + ): + load_universe_package(manifest, registry) + finally: + if local_socket is not None: + local_socket.close() + shutil.rmtree(short_tmp_path) + + +@pytest.mark.parametrize( + ("api", "exception_type", "error"), + [ + ("dup", TypeError, "manifest_package_unreadable"), + ("dup", NotImplementedError, "manifest_package_unreadable"), + ("dup", OSError, "manifest_package_unreadable"), + ("listdir", TypeError, "manifest_package_unreadable"), + ("listdir", NotImplementedError, "manifest_package_unreadable"), + ("listdir", OSError, "manifest_package_unreadable"), + ("stat", TypeError, "manifest_package_unreadable"), + ("stat", NotImplementedError, "manifest_package_unreadable"), + ("stat", OSError, "manifest_package_unreadable"), + ("open", TypeError, "manifest_unreadable"), + ("open", NotImplementedError, "manifest_unreadable"), + ("open", OSError, "manifest_unreadable"), + ], +) +def test_descriptor_api_failures_are_stably_unreadable( + tmp_path, + monkeypatch, + api, + exception_type, + error, +): + import src.point_in_time_universe_manifest as loader + + manifest, registry = build_valid_package(tmp_path) + original = getattr(loader.os, api) + + if api == "dup": + def fail_api(*args, **kwargs): + raise exception_type("simulated descriptor API failure") + elif api == "listdir": + def fail_api(path): + if isinstance(path, int): + raise exception_type("simulated descriptor API failure") + return original(path) + elif api == "stat": + def fail_api(path, *args, dir_fd=None, **kwargs): + if dir_fd is not None: + raise exception_type("simulated descriptor API failure") + return original(path, *args, dir_fd=dir_fd, **kwargs) + else: + def fail_api(path, flags, mode=0o777, *, dir_fd=None): + if dir_fd is not None: + raise exception_type("simulated descriptor API failure") + return original(path, flags, mode, dir_fd=dir_fd) + + monkeypatch.setattr(loader.os, api, fail_api) + + with pytest.raises(ValueError, match=f"^{error}$"): + loader.load_universe_package(manifest, registry) + + +def test_package_directory_descriptors_are_closed_after_load( + tmp_path, + monkeypatch, +): + import src.point_in_time_universe_manifest as loader + + manifest, registry = build_valid_package(tmp_path) + nested = manifest.parent / "one" / "two" + nested.mkdir(parents=True) + identity = manifest.parent / "identity.csv" + identity.rename(nested / "identity.csv") + _set_contract_path( + manifest, + "security_identity", + "one/two/identity.csv", + ) + original_open = loader.os.open + original_dup = loader.os.dup + original_close = loader.os.close + active_tokens = {} + close_counts = {} + next_token = 0 + + def track_descriptor(descriptor): + nonlocal next_token + token = next_token + next_token += 1 + active_tokens.setdefault(descriptor, []).append(token) + close_counts[token] = 0 + + def capture_directory_open( + path, + flags, + mode=0o777, + *, + dir_fd=None, + ): + descriptor = original_open( + path, + flags, + mode, + dir_fd=dir_fd, + ) + if flags & loader.os.O_DIRECTORY: + track_descriptor(descriptor) + return descriptor + + def capture_directory_dup(descriptor): + duplicate = original_dup(descriptor) + track_descriptor(duplicate) + return duplicate + + def capture_directory_close(descriptor): + tokens = active_tokens.get(descriptor) + if tokens: + token = tokens.pop(0) + close_counts[token] += 1 + if not tokens: + del active_tokens[descriptor] + return original_close(descriptor) + + monkeypatch.setattr(loader.os, "open", capture_directory_open) + monkeypatch.setattr(loader.os, "dup", capture_directory_dup) + monkeypatch.setattr(loader.os, "close", capture_directory_close) + + loader.load_universe_package(manifest, registry) + + assert len(close_counts) >= 4 + assert set(close_counts.values()) == {1} + assert active_tokens == {} + + +def _add_nested_extensions(manifest) -> None: + raw = json.loads(manifest.read_text(encoding="utf-8")) + raw["declared_universes"][0]["metadata"] = { + "labels": ["stable"], + } + raw["evaluation_policy"]["metadata"] = { + "windows": ["strict"], + } + raw["delisting_policy"]["metadata"] = { + "reviewed": True, + } + raw["survivorship_policy"]["metadata"] = { + "notes": ["point-in-time"], + } + manifest.write_text( + json.dumps(raw, sort_keys=True), + encoding="utf-8", + ) + + +def test_loaded_manifest_rejects_top_level_and_nested_mutation(tmp_path): + from src.point_in_time_universe_manifest import load_universe_package + + manifest, registry = build_valid_package(tmp_path) + _add_nested_extensions(manifest) + loaded = load_universe_package(manifest, registry).manifest + + with pytest.raises(FrozenInstanceError): + loaded.dataset_id = "changed" + with pytest.raises(TypeError): + loaded.evaluation_policy["kind"] = "walk_forward" + with pytest.raises(TypeError): + loaded.declared_universes[0]["metadata"]["labels"][0] = "changed" + with pytest.raises(TypeError): + loaded.evaluation_policy["metadata"]["windows"][0] = "changed" + with pytest.raises(TypeError): + loaded.delisting_policy["metadata"]["reviewed"] = False + with pytest.raises(TypeError): + loaded.survivorship_policy["metadata"]["notes"][0] = "changed" + + assert loaded.declared_universes[0]["metadata"]["labels"] == ( + "stable", + ) + assert loaded.evaluation_policy["metadata"]["windows"] == ("strict",) + assert loaded.delisting_policy["metadata"]["reviewed"] is True + assert loaded.survivorship_policy["metadata"]["notes"] == ( + "point-in-time", + ) + + +def test_manifest_constructor_defensively_copies_nested_inputs(tmp_path): + from src.point_in_time_universe_manifest import load_universe_package + + manifest, registry = build_valid_package(tmp_path) + loaded = load_universe_package(manifest, registry).manifest + declared = [ + { + "universe_id": "bench-1", + "universe_kind": "benchmark", + "metadata": {"labels": ["stable"]}, + }, + ] + policy = { + "kind": "walk_forward", + "minimum_history_count": 1, + "metadata": {"windows": ["strict"]}, + } + + copied = replace( + loaded, + declared_universes=declared, + evaluation_policy=policy, + ) + declared[0]["universe_id"] = "mutated" + declared[0]["metadata"]["labels"][0] = "mutated" + policy["kind"] = "mutated" + policy["metadata"]["windows"][0] = "mutated" + + assert copied.declared_universes[0]["universe_id"] == "bench-1" + assert copied.declared_universes[0]["metadata"]["labels"] == ( + "stable", + ) + assert copied.evaluation_policy["kind"] == "walk_forward" + assert copied.evaluation_policy["metadata"]["windows"] == ("strict",) + + +def test_independently_loaded_nested_manifests_remain_equal(tmp_path): + from src.point_in_time_universe_manifest import load_universe_package + + manifest, registry = build_valid_package(tmp_path) + _add_nested_extensions(manifest) + + first = load_universe_package(manifest, registry).manifest + second = load_universe_package(manifest, registry).manifest + + assert first == second + + +@pytest.mark.parametrize("command", ("status", "preview")) +def test_nested_manifest_extensions_remain_cli_compatible_and_write_free( + tmp_path, + command, +): + manifest, registry = build_valid_package(tmp_path) + _add_nested_extensions(manifest) + before = _snapshot(tmp_path) + + result = _run_cli( + command, + "--manifest", + str(manifest), + "--registry", + str(registry), + ) + + assert result.returncode == 0 + assert "technical_validity: passed" in result.stdout + assert "Traceback" not in result.stderr + assert _snapshot(tmp_path) == before + + +def test_excessively_deep_manifest_extension_fails_closed_and_write_free( + tmp_path, +): + from src.point_in_time_universe_manifest import load_universe_package + + manifest, registry = build_valid_package(tmp_path) + raw = json.loads(manifest.read_text(encoding="utf-8")) + nested = {"leaf": "value"} + for _ in range(80): + nested = {"next": nested} + raw["evaluation_policy"]["metadata"] = nested + manifest.write_text( + json.dumps(raw, sort_keys=True), + encoding="utf-8", + ) + before = _snapshot(tmp_path) + error = "manifest_nesting_limit_exceeded" + + with pytest.raises(ValueError, match=f"^{error}$"): + load_universe_package(manifest, registry) + + result = _run_cli( + "status", + "--manifest", + str(manifest), + "--registry", + str(registry), + ) + + assert result.returncode == 2 + assert error in result.stderr + assert "Traceback" not in result.stderr + assert _snapshot(tmp_path) == before diff --git a/tests/test_point_in_time_universe_membership_snapshots.py b/tests/test_point_in_time_universe_membership_snapshots.py new file mode 100644 index 00000000..5497572c --- /dev/null +++ b/tests/test_point_in_time_universe_membership_snapshots.py @@ -0,0 +1,392 @@ +from __future__ import annotations + +import pytest + +from tests.point_in_time_universe_remediation_fixtures import ( + add_second_security_evidence, +) +from tests.point_in_time_universe_fixture import build_valid_package +from tests.test_point_in_time_universe import ( + _decision, + _read_contract_rows, + _replace_contract_rows, + _sha256_members, +) +from tests.test_point_in_time_universe_cli import ( + _run_cli, + _snapshot, +) +from tests.test_point_in_time_universe_contracts import ( + _rewrite_csv_and_manifest, +) + + +OMISSION_REASON = "membership_snapshot_omission_unexplained" +LATEST_SNAPSHOT_AT = "2020-06-01T00:00:00Z" + + +def _membership_row( + rows, + *, + security_id: str, + state: str, + row_id: str, + observation_at: str = LATEST_SNAPSHOT_AT, + source_published_at: str = LATEST_SNAPSHOT_AT, + retrieved_at: str = "2020-06-02T00:00:00Z", + supersedes: str = "", +): + base = next(row for row in rows if row["universe_id"] == "bench-1") + return { + **base, + "membership_row_id": row_id, + "security_id": security_id, + "membership_state": state, + "effective_from": observation_at, + "observation_at": observation_at, + "source_ref": f"fixture://membership/{row_id}", + "source_published_at": source_published_at, + "retrieved_at": retrieved_at, + "supersedes_membership_row_id": supersedes, + } + + +def _configure_later_benchmark_snapshot( + manifest, + *, + restatement: str | None = None, + invalid_exclusion: str | None = None, +) -> None: + add_second_security_evidence(manifest) + + def mutate(rows): + rows.append( + _membership_row( + rows, + security_id="sec-2", + state="included", + row_id="member-bench-sec-2-latest", + ) + ) + if restatement is not None: + rows.append( + _membership_row( + rows, + security_id="sec-1", + state=restatement, + row_id="member-bench-sec-1-latest", + supersedes="member-bench-1", + ) + ) + elif invalid_exclusion == "wrong_timestamp": + rows.append( + _membership_row( + rows, + security_id="sec-1", + state="excluded", + row_id="member-bench-sec-1-wrong-time", + observation_at="2020-05-01T00:00:00Z", + source_published_at="2020-05-01T00:00:00Z", + retrieved_at="2020-05-02T00:00:00Z", + supersedes="member-bench-1", + ) + ) + elif invalid_exclusion == "future": + rows.append( + _membership_row( + rows, + security_id="sec-1", + state="excluded", + row_id="member-bench-sec-1-future", + observation_at="2022-01-01T00:00:00Z", + source_published_at="2022-01-01T00:00:00Z", + retrieved_at="2022-01-02T00:00:00Z", + supersedes="member-bench-1", + ) + ) + elif invalid_exclusion == "unavailable": + rows.append( + _membership_row( + rows, + security_id="sec-1", + state="excluded", + row_id="member-bench-sec-1-unavailable", + source_published_at="2020-06-01T00:00:00Z", + retrieved_at="2022-01-01T00:00:00Z", + supersedes="member-bench-1", + ) + ) + elif invalid_exclusion == "superseded": + rows.append( + _membership_row( + rows, + security_id="sec-1", + state="excluded", + row_id="member-bench-sec-1-superseded", + supersedes="member-bench-1", + ) + ) + rows.append( + _membership_row( + rows, + security_id="sec-1", + state="excluded", + row_id="member-bench-sec-1-correction", + observation_at="2020-05-01T00:00:00Z", + source_published_at="2020-07-01T00:00:00Z", + retrieved_at="2020-07-02T00:00:00Z", + supersedes="member-bench-sec-1-superseded", + ) + ) + elif invalid_exclusion == "ambiguous": + rows.append( + _membership_row( + rows, + security_id="sec-1", + state="excluded", + row_id="member-bench-sec-1-ambiguous", + ) + ) + + _rewrite_csv_and_manifest(manifest, "membership", mutate) + + +def _omission_exclusions(packet): + return tuple( + row + for row in packet.excluded + if OMISSION_REASON in row.reason_codes + ) + + +def test_latest_complete_snapshot_blocks_unexplained_prior_member_omission( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _configure_later_benchmark_snapshot(manifest) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "membership_coverage").status == "blocked" + assert OMISSION_REASON in _decision( + packet, + "membership_coverage", + ).reason_codes + assert _decision(packet, "reproduction_ready").status == "blocked" + assert _decision(packet, "reproduction_ready").reason_codes == ( + "reproduction_digest_missing", + ) + assert { + (digest.universe_id, digest.evaluation_at) + for digest in packet.membership_digests + } == {("research-1", "2021-01-01T00:00:00Z")} + assert _omission_exclusions(packet)[0].row_id == "member-bench-1" + assert _omission_exclusions(packet)[0].reason_codes == (OMISSION_REASON,) + assert packet.analysis_eligible is False + assert packet.analysis_eligible_rows == () + + +def test_latest_snapshot_explicit_exclusion_closes_omission_gate(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _configure_later_benchmark_snapshot( + manifest, + restatement="excluded", + ) + + packet = validate_point_in_time_universe(manifest, registry) + digests = { + digest.universe_id: digest + for digest in packet.membership_digests + } + + assert _decision(packet, "membership_coverage").status == "passed" + assert _decision(packet, "reproduction_ready").status == "passed" + assert digests["bench-1"].member_count == 1 + assert digests["bench-1"].sha256 == _sha256_members("sec-2") + assert _omission_exclusions(packet) == () + assert packet.analysis_eligible is True + + +def test_latest_snapshot_repeated_inclusion_closes_omission_gate(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _configure_later_benchmark_snapshot( + manifest, + restatement="included", + ) + + packet = validate_point_in_time_universe(manifest, registry) + bench = next( + digest + for digest in packet.membership_digests + if digest.universe_id == "bench-1" + ) + + assert _decision(packet, "membership_coverage").status == "passed" + assert _decision(packet, "reproduction_ready").status == "passed" + assert bench.member_count == 2 + assert bench.sha256 == _sha256_members("sec-1", "sec-2") + assert _omission_exclusions(packet) == () + assert packet.analysis_eligible is True + + +@pytest.mark.parametrize( + "invalid_exclusion", + ( + "wrong_timestamp", + "future", + "unavailable", + "superseded", + "ambiguous", + ), +) +def test_non_authoritative_exclusion_does_not_close_latest_snapshot_gate( + tmp_path, + invalid_exclusion, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _configure_later_benchmark_snapshot( + manifest, + invalid_exclusion=invalid_exclusion, + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "membership_coverage").status == "blocked" + assert OMISSION_REASON in _decision( + packet, + "membership_coverage", + ).reason_codes + assert all( + digest.universe_id != "bench-1" + for digest in packet.membership_digests + ) + assert _omission_exclusions(packet)[0].row_id == "member-bench-1" + assert packet.analysis_eligible is False + assert packet.analysis_eligible_rows == () + + +def test_snapshot_omission_is_input_order_deterministic(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + packets = [] + for index, reverse in enumerate((False, True)): + root = tmp_path / str(index) + root.mkdir() + manifest, registry = build_valid_package(root) + _configure_later_benchmark_snapshot(manifest) + if reverse: + _replace_contract_rows( + manifest, + "membership", + list(reversed(_read_contract_rows(manifest, "membership"))), + ) + packets.append( + validate_point_in_time_universe(manifest, registry) + ) + + first, second = packets + assert first.decisions == second.decisions + assert first.membership_digests == second.membership_digests + assert { + (row.row_id, row.reason_codes) + for row in first.excluded + } == { + (row.row_id, row.reason_codes) + for row in second.excluded + } + assert first.analysis_eligible_rows == second.analysis_eligible_rows == () + + +def test_snapshot_omission_is_universe_and_evaluation_scoped(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _configure_later_benchmark_snapshot(manifest) + + def add_earlier_benchmark_evaluation(rows): + base = next(row for row in rows if row["universe_id"] == "bench-1") + rows.append( + { + **base, + "evaluation_row_id": "eval-bench-earlier", + "evaluation_at": "2020-03-01T00:00:00Z", + "available_at": "2020-03-01T00:00:00Z", + "source_ref": "fixture://evaluation/bench-earlier", + } + ) + + _rewrite_csv_and_manifest( + manifest, + "evaluations", + add_earlier_benchmark_evaluation, + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert { + ( + digest.universe_id, + digest.evaluation_at, + digest.member_count, + digest.sha256, + ) + for digest in packet.membership_digests + } == { + ( + "bench-1", + "2020-03-01T00:00:00Z", + 1, + _sha256_members("sec-1"), + ), + ( + "research-1", + "2021-01-01T00:00:00Z", + 1, + _sha256_members("sec-1"), + ), + } + assert { + row.row_id + for row in _omission_exclusions(packet) + } == {"member-bench-1"} + assert all( + row.row_id != "member-research-1" + for row in _omission_exclusions(packet) + ) + assert _decision(packet, "reproduction_ready").reason_codes == ( + "reproduction_digest_missing", + ) + + +@pytest.mark.parametrize("command", ("status", "preview")) +def test_snapshot_omission_is_cli_readable_and_write_free( + tmp_path, + command, +): + manifest, registry = build_valid_package(tmp_path) + _configure_later_benchmark_snapshot(manifest) + before = _snapshot(tmp_path) + + result = _run_cli( + command, + "--manifest", + str(manifest), + "--registry", + str(registry), + ) + + assert result.returncode == 0 + assert "membership_coverage: blocked" in result.stdout + assert OMISSION_REASON in result.stdout + assert "reproduction_ready: blocked" in result.stdout + assert "reproduction_digest_missing" in result.stdout + assert "Traceback" not in result.stderr + assert _snapshot(tmp_path) == before diff --git a/tests/test_point_in_time_universe_remediation6.py b/tests/test_point_in_time_universe_remediation6.py new file mode 100644 index 00000000..1aa3599b --- /dev/null +++ b/tests/test_point_in_time_universe_remediation6.py @@ -0,0 +1,500 @@ +from __future__ import annotations + +import json + +import pytest + +from tests.point_in_time_universe_remediation_fixtures import ( + walk_forward_rows, +) +from tests.point_in_time_universe_fixture import build_valid_package +from tests.test_point_in_time_universe import ( + _append_registry_source, + _decision, + _read_contract_rows, + _refresh_contract_digest, + _refresh_registry_digest, + _replace_contract_rows, + _rewrite_csv_and_manifest, + _rewrite_manifest, +) + + +@pytest.mark.parametrize("event_type", ("not-a-real-event", "")) +def test_unclassifiable_event_type_cannot_inherit_corporate_action_rights( + tmp_path, + event_type, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + source_id = "corporate_only_source" + _rewrite_csv_and_manifest( + manifest, + "events", + lambda rows: rows[0].update( + event_type=event_type, + source_id=source_id, + ), + ) + _append_registry_source( + registry, + source_id, + ("corporate_actions",), + ) + _rewrite_manifest( + manifest, + lambda raw: raw["allowed_source_ids"].append(source_id), + ) + _refresh_registry_digest(manifest, registry) + + packet = validate_point_in_time_universe(manifest, registry) + exact = next( + row + for row in packet.excluded + if row.contract == "events" and row.source_row == 2 + ) + + assert _decision(packet, "technical_validity").status == "blocked" + assert _decision( + packet, + "source_rights_eligibility", + ).reason_codes == ("source_rights_event_scope_unreadable",) + assert "source_rights_event_scope_unreadable" in exact.reason_codes + + +def test_internal_missing_event_type_sentinel_has_no_inferred_rights_scope(): + from types import MappingProxyType + + from src.point_in_time_universe import _raw_required_rights_scope + from src.point_in_time_universe_contracts import ( + RAW_MISSING_CELL, + RawEvidenceRow, + ) + + row = RawEvidenceRow( + "events", + "events.csv", + 2, + MappingProxyType({"event_type": RAW_MISSING_CELL}), + ) + + assert _raw_required_rights_scope(row) is None + + +@pytest.mark.parametrize( + ("contract", "malformed_field"), + [ + ("security_identity", "valid_from"), + ("membership", "effective_from"), + ("events", "effective_at"), + ], +) +def test_malformed_evidence_still_blocks_unknown_source_rights_on_exact_row( + tmp_path, + contract, + malformed_field, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + + def mutate(rows): + rows[0][malformed_field] = "not-a-timestamp" + rows[0]["source_id"] = "unknown_source" + + _rewrite_csv_and_manifest(manifest, contract, mutate) + _rewrite_manifest( + manifest, + lambda raw: raw["allowed_source_ids"].append("unknown_source"), + ) + + packet = validate_point_in_time_universe(manifest, registry) + exact = next( + row + for row in packet.excluded + if row.contract == contract and row.source_row == 2 + ) + + assert _decision(packet, "technical_validity").status == "blocked" + assert _decision(packet, "source_rights_eligibility").status == "blocked" + assert _decision( + packet, + "source_rights_eligibility", + ).reason_codes == ( + "source_rights_field_scope_missing", + "source_rights_unknown_source", + ) + assert "schema_timestamp_invalid" in exact.reason_codes + assert "source_rights_unknown_source" in exact.reason_codes + assert "source_rights_field_scope_missing" in exact.reason_codes + + +def test_missing_source_identity_blocks_rights_independently_on_exact_row( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _rewrite_csv_and_manifest( + manifest, + "security_identity", + lambda rows: rows[0].update(source_id=""), + ) + + packet = validate_point_in_time_universe(manifest, registry) + exact = next( + row + for row in packet.excluded + if row.contract == "security_identity" and row.source_row == 2 + ) + + assert _decision(packet, "technical_validity").status == "blocked" + assert _decision( + packet, + "source_rights_eligibility", + ).reason_codes == ("source_rights_source_missing",) + assert exact.reason_codes == ( + "schema_required_field_missing", + "source_rights_source_missing", + ) + + +def test_surplus_column_does_not_hide_populated_source_rights(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + rows = _read_contract_rows(manifest, "security_identity") + rows[0]["source_id"] = "unknown_source" + _replace_contract_rows(manifest, "security_identity", rows) + raw = json.loads(manifest.read_text()) + entry = next( + item + for item in raw["files"] + if item["contract"] == "security_identity" + ) + path = manifest.parent / entry["path"] + lines = path.read_text(encoding="utf-8").splitlines() + lines[1] += ",surplus" + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + _refresh_contract_digest(manifest, "security_identity") + _rewrite_manifest( + manifest, + lambda payload: payload["allowed_source_ids"].append( + "unknown_source" + ), + ) + + packet = validate_point_in_time_universe(manifest, registry) + exact = next( + row + for row in packet.excluded + if row.contract == "security_identity" and row.source_row == 2 + ) + + assert "schema_columns_invalid" in exact.reason_codes + assert "source_rights_unknown_source" in exact.reason_codes + assert _decision(packet, "source_rights_eligibility").status == "blocked" + + +@pytest.mark.parametrize( + ("rights_case", "expected_reason"), + [ + ("disallowed", "source_rights_source_not_allowed"), + ("unreviewed", "source_rights_commercial_rights_unverified"), + ("scope_mismatch", "source_rights_field_scope_missing"), + ], +) +def test_populated_source_rights_failures_have_exact_row_lineage( + tmp_path, + rights_case, + expected_reason, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + source_id = "review_case_source" + _rewrite_csv_and_manifest( + manifest, + "security_identity", + lambda rows: rows[0].update(source_id=source_id), + ) + if rights_case != "disallowed": + scopes = ( + ("universe_membership",) + if rights_case == "scope_mismatch" + else ("security_identity",) + ) + _append_registry_source( + registry, + source_id, + scopes, + commercial_use=( + "unverified" + if rights_case == "unreviewed" + else "approved" + ), + ) + _rewrite_manifest( + manifest, + lambda raw: raw["allowed_source_ids"].append(source_id), + ) + _refresh_registry_digest(manifest, registry) + + packet = validate_point_in_time_universe(manifest, registry) + exact = next( + row + for row in packet.excluded + if row.contract == "security_identity" and row.source_row == 2 + ) + + assert expected_reason in _decision( + packet, + "source_rights_eligibility", + ).reason_codes + assert expected_reason in exact.reason_codes + + +def test_walk_forward_history_is_strictly_prior_and_input_order_independent( + tmp_path, +): + from src.point_in_time_universe import _classify_evaluations + from src.point_in_time_universe_contracts import parse_universe_evidence + from src.point_in_time_universe_manifest import load_universe_package + + results = [] + for index, rows in enumerate( + (walk_forward_rows(), list(reversed(walk_forward_rows()))) + ): + root = tmp_path / str(index) + root.mkdir() + manifest, registry = build_valid_package(root) + _replace_contract_rows(manifest, "evaluations", rows) + _rewrite_manifest( + manifest, + lambda raw: raw.update( + observation_cutoff_at="2023-01-01T00:00:00Z", + evaluation_policy={ + "kind": "walk_forward", + "minimum_history_count": 2, + }, + ), + ) + package = load_universe_package(manifest, registry) + parsed = parse_universe_evidence(package) + valid, reasons, global_reasons = _classify_evaluations( + package.manifest, + parsed.evaluations, + ) + results.append( + ( + tuple(sorted(row.evaluation_row_id for row in valid)), + dict(reasons), + global_reasons, + ) + ) + + assert results[0] == results[1] + valid_ids, reasons, global_reasons = results[0] + assert valid_ids == ("eval-bench-1-2023", "eval-research-1-2023") + assert global_reasons == () + for universe in ("bench-1", "research-1"): + assert reasons[f"eval-{universe}-2021"] == ( + "partition_minimum_history_unmet", + ) + assert reasons[f"eval-{universe}-2022"] == ( + "partition_minimum_history_unmet", + ) + assert reasons[f"eval-{universe}-2023"] == () + + +def test_public_validator_excludes_bootstrap_and_unlocks_later_cutoff( + tmp_path, +): + from src.point_in_time_universe import validate_point_in_time_universe + + semantic_results = [] + for index, rows in enumerate( + (walk_forward_rows(), list(reversed(walk_forward_rows()))) + ): + root = tmp_path / str(index) + root.mkdir() + manifest, registry = build_valid_package(root) + _replace_contract_rows(manifest, "evaluations", rows) + _rewrite_manifest( + manifest, + lambda raw: raw.update( + observation_cutoff_at="2023-01-01T00:00:00Z", + evaluation_policy={ + "kind": "walk_forward", + "minimum_history_count": 2, + }, + ), + ) + + packet = validate_point_in_time_universe(manifest, registry) + semantic_results.append( + ( + packet.decisions, + packet.membership_digests, + { + row.row_id: row.reason_codes + for row in packet.excluded + if row.contract == "evaluations" + }, + { + row.row_id + for row in packet.raw_rows + if row.contract == "evaluations" + }, + ) + ) + + assert semantic_results[0] == semantic_results[1] + decisions, digests, excluded, observed_evaluations = semantic_results[0] + assert decisions["leakage_safe"].status == "passed" + assert decisions["leakage_safe"].reason_codes == () + assert { + (item.universe_id, item.evaluation_at) + for item in digests + } == { + ("bench-1", "2023-01-01T00:00:00Z"), + ("research-1", "2023-01-01T00:00:00Z"), + } + assert observed_evaluations == { + f"eval-{universe}-{year}" + for universe in ("bench-1", "research-1") + for year in (2021, 2022, 2023) + } + assert set(excluded) == { + "eval-bench-1-2021", + "eval-bench-1-2022", + "eval-research-1-2021", + "eval-research-1-2022", + } + assert all( + reasons == ("partition_minimum_history_unmet",) + for reasons in excluded.values() + ) + + +def test_invalid_unavailable_and_same_time_evaluations_do_not_count_as_history( + tmp_path, +): + from src.point_in_time_universe import _classify_evaluations + from src.point_in_time_universe_contracts import parse_universe_evidence + from src.point_in_time_universe_manifest import load_universe_package + + manifest, registry = build_valid_package(tmp_path) + rows = [ + { + **walk_forward_rows()[0], + "evaluation_row_id": "eval-unavailable", + "evaluation_at": "2021-01-01T00:00:00Z", + "available_at": "2021-01-02T00:00:00Z", + }, + { + **walk_forward_rows()[0], + "evaluation_row_id": "eval-same-a", + "evaluation_at": "2022-01-01T00:00:00Z", + "available_at": "2022-01-01T00:00:00Z", + }, + { + **walk_forward_rows()[0], + "evaluation_row_id": "eval-same-b", + "evaluation_at": "2022-01-01T00:00:00Z", + "available_at": "2022-01-01T00:00:00Z", + }, + ] + _replace_contract_rows(manifest, "evaluations", rows) + _rewrite_manifest( + manifest, + lambda raw: raw.update( + observation_cutoff_at="2022-01-01T00:00:00Z", + evaluation_policy={ + "kind": "walk_forward", + "minimum_history_count": 1, + }, + ), + ) + package = load_universe_package(manifest, registry) + parsed = parse_universe_evidence(package) + valid, reasons, _ = _classify_evaluations( + package.manifest, + parsed.evaluations, + ) + + assert valid == () + assert reasons["eval-unavailable"] == ( + "cutoff_evaluation_unavailable", + ) + assert reasons["eval-same-a"] == ( + "partition_minimum_history_unmet", + ) + assert reasons["eval-same-b"] == ( + "partition_minimum_history_unmet", + ) + + +@pytest.mark.parametrize( + "contract", + ("security_identity", "membership", "events"), +) +def test_retrieval_before_publication_blocks_exact_contract_row( + tmp_path, + contract, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _rewrite_csv_and_manifest( + manifest, + contract, + lambda rows: rows[0].update( + source_published_at="2020-01-03T00:00:00Z", + retrieved_at="2020-01-02T00:00:00Z", + ), + ) + + packet = validate_point_in_time_universe(manifest, registry) + exact = next( + row + for row in packet.excluded + if row.contract == contract and row.source_row == 2 + ) + + assert _decision(packet, "technical_validity").status == "blocked" + assert "schema_retrieved_before_publication" in exact.reason_codes + assert packet.analysis_eligible is False + + +@pytest.mark.parametrize( + ("published", "retrieved"), + [ + ("2020-01-01T00:00:00Z", "2020-01-01T00:00:00Z"), + ("2020-01-01T00:00:00Z", "2020-01-02T00:00:00Z"), + ], +) +def test_publication_chronology_accepts_equality_and_valid_order( + tmp_path, + published, + retrieved, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + for contract in ("security_identity", "membership", "events"): + _rewrite_csv_and_manifest( + manifest, + contract, + lambda rows: rows[0].update( + source_published_at=published, + retrieved_at=retrieved, + ), + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert _decision(packet, "technical_validity").status == "passed" + assert _decision(packet, "temporal_validity").status == "passed" + assert packet.analysis_eligible is True diff --git a/tests/test_point_in_time_universe_remediation7.py b/tests/test_point_in_time_universe_remediation7.py new file mode 100644 index 00000000..547f36d6 --- /dev/null +++ b/tests/test_point_in_time_universe_remediation7.py @@ -0,0 +1,742 @@ +from __future__ import annotations + +from dataclasses import replace +import csv +import hashlib +import json + +import pytest + +from tests.point_in_time_universe_fixture import build_valid_package +from tests.test_point_in_time_universe_cli import ( + _run_cli, + _run_make, + _snapshot, +) + + +def _manifest_payload(manifest): + return json.loads(manifest.read_text(encoding="utf-8")) + + +def _write_manifest(manifest, payload) -> None: + manifest.write_text(json.dumps(payload, sort_keys=True), encoding="utf-8") + + +def _rewrite_contract(manifest, contract, mutation) -> None: + payload = _manifest_payload(manifest) + entry = next(item for item in payload["files"] if item["contract"] == contract) + path = manifest.parent / entry["path"] + with path.open(encoding="utf-8", newline="") as handle: + reader = csv.DictReader(handle) + fieldnames = tuple(reader.fieldnames or ()) + rows = list(reader) + mutation(rows) + with path.open("w", encoding="utf-8", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=fieldnames) + writer.writeheader() + writer.writerows(rows) + entry["sha256"] = hashlib.sha256(path.read_bytes()).hexdigest() + entry["row_count"] = len(rows) + _write_manifest(manifest, payload) + + +@pytest.mark.parametrize( + "field,mutation,reason", + [ + ( + "dataset_id", + lambda raw: raw.update(dataset_id="fixture\nanalysis_eligible: true"), + "manifest_dataset_id_invalid", + ), + ( + "manifest_id", + lambda raw: raw.update(manifest_id="fixture\x1b[2J"), + "manifest_id_invalid", + ), + ( + "universe_id", + lambda raw: raw["declared_universes"][0].update( + universe_id="bench-1\x85forged", + ), + "manifest_declared_universes_invalid", + ), + ( + "source_id", + lambda raw: raw.update( + allowed_source_ids=["fixture_source\rforged"], + ), + "manifest_allowed_source_ids_invalid", + ), + ( + "dataset_line_separator", + lambda raw: raw.update( + dataset_id="fixture\u2028analysis_eligible: true", + ), + "manifest_dataset_id_invalid", + ), + ( + "manifest_paragraph_separator", + lambda raw: raw.update( + manifest_id="fixture\u2029analysis_eligible: true", + ), + "manifest_id_invalid", + ), + ( + "dataset_high_surrogate", + lambda raw: raw.update(dataset_id="fixture\ud800forged"), + "manifest_dataset_id_invalid", + ), + ( + "manifest_low_surrogate", + lambda raw: raw.update(manifest_id="fixture\udfffforged"), + "manifest_id_invalid", + ), + ], +) +def test_manifest_structural_identifiers_reject_c0_c1_controls( + tmp_path, + field, + mutation, + reason, +): + from src.point_in_time_universe_manifest import load_universe_package + + manifest, registry = build_valid_package(tmp_path) + raw = _manifest_payload(manifest) + mutation(raw) + _write_manifest(manifest, raw) + + with pytest.raises(ValueError, match=f"^{reason}$"): + load_universe_package(manifest, registry) + + +@pytest.mark.parametrize( + "contract,field,value", + [ + ("security_identity", "identity_row_id", "id-1\nforged"), + ("security_identity", "security_id", "sec-1\x00forged"), + ("security_identity", "issuer_id", "issuer-1\x7fforged"), + ("security_identity", "source_id", "fixture_source\x85forged"), + ("security_identity", "source_ref", "fixture://id\rforged"), + ("membership", "membership_row_id", "member-bench\nforged"), + ("membership", "universe_id", "bench-1\x1bforged"), + ("membership", "security_id", "sec-1\x9fforged"), + ("events", "event_row_id", "event-1\nforged"), + ("events", "successor_security_id", "sec-2\rforged"), + ("evaluations", "evaluation_row_id", "eval-bench\nforged"), + ("evaluations", "universe_id", "bench-1\x1bforged"), + ("evaluations", "source_ref", "fixture://evaluation\x85forged"), + ("security_identity", "security_id", "sec-1\u2028forged"), + ("membership", "universe_id", "bench-1\u2029forged"), + ("events", "listing_state_after", "active\nforged"), + ("events", "listing_state_after", "active\u2028forged"), + ("events", "listing_state_after", "active\u2029forged"), + ], +) +def test_contract_structural_identifiers_reject_controls_on_exact_rows( + tmp_path, + contract, + field, + value, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _rewrite_contract( + manifest, + contract, + lambda rows: rows[0].update({field: value}), + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert packet.analysis_eligible is False + assert packet.decisions["technical_validity"].reason_codes == ( + "schema_identifier_control_character", + ) + assert any( + item.contract == contract + and item.source_row == 2 + and "schema_identifier_control_character" in item.reason_codes + for item in packet.excluded + ) + + +@pytest.mark.parametrize("surrogate", ("\ud800", "\udfff")) +def test_contract_boundary_rejects_lone_surrogate_identifiers( + tmp_path, + surrogate, +): + from src.point_in_time_universe_contracts import _parse_event + + manifest, _ = build_valid_package(tmp_path) + with (manifest.parent / "events.csv").open( + encoding="utf-8", + newline="", + ) as handle: + row = next(csv.DictReader(handle)) + row["security_id"] = f"sec-1{surrogate}forged" + + with pytest.raises( + ValueError, + match="^schema_identifier_control_character$", + ): + _parse_event(row) + + +@pytest.mark.parametrize( + "surrogate,escaped", + (("\ud800", "\\ud800"), ("\udfff", "\\udfff")), +) +def test_shared_structural_predicate_rejects_and_renderer_escapes_non_scalars( + surrogate, + escaped, +): + from src.point_in_time_universe_identifiers import ( + escape_structural_token, + is_control_free, + require_control_free, + ) + + assert is_control_free(surrogate) is False + with pytest.raises( + ValueError, + match="^schema_identifier_control_character$", + ): + require_control_free( + surrogate, + "schema_identifier_control_character", + ) + assert escape_structural_token(surrogate) == escaped + assert escape_structural_token(surrogate).isascii() + + +def test_ordinary_unicode_opaque_ids_remain_deterministic_and_digest_safe(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + raw = _manifest_payload(manifest) + raw["dataset_id"] = "研究資料集-🚀" + raw["manifest_id"] = "清單-é-𐐷" + for universe in raw["declared_universes"]: + universe["universe_id"] = { + "bench-1": "基準-é-🚀", + "research-1": "研究-β-𐐷", + }[universe["universe_id"]] + _write_manifest(manifest, raw) + _rewrite_contract( + manifest, + "security_identity", + lambda rows: rows[0].update( + identity_row_id="身份-é", + security_id="證券-β-🚀", + issuer_id="發行人-東京", + ), + ) + _rewrite_contract( + manifest, + "membership", + lambda rows: [ + row.update( + membership_row_id=f"成員-{index}", + universe_id=( + "基準-é-🚀" + if index == 0 + else "研究-β-𐐷" + ), + security_id="證券-β-🚀", + ) + for index, row in enumerate(rows) + ], + ) + _rewrite_contract( + manifest, + "events", + lambda rows: rows[0].update( + event_row_id="事件-é", + security_id="證券-β-🚀", + ), + ) + _rewrite_contract( + manifest, + "evaluations", + lambda rows: [ + row.update( + evaluation_row_id=f"評估-{index}", + universe_id=( + "基準-é-🚀" + if index == 0 + else "研究-β-𐐷" + ), + ) + for index, row in enumerate(rows) + ], + ) + + first = validate_point_in_time_universe(manifest, registry) + second = validate_point_in_time_universe(manifest, registry) + expected = hashlib.sha256( + "證券-β-🚀".encode("utf-8"), + ).hexdigest() + + assert first.analysis_eligible is True + assert first.membership_digests == second.membership_digests + assert {item.sha256 for item in first.membership_digests} == {expected} + + +@pytest.mark.parametrize( + "security_ids", + (("a\nb", "c"), ("a", "b\nc")), +) +def test_v5_newline_digest_collision_sets_are_rejected_before_reproduction( + tmp_path, + security_ids, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _rewrite_contract( + manifest, + "security_identity", + lambda rows: rows.__setitem__( + slice(None), + [ + { + **rows[0], + "identity_row_id": f"id-{index}", + "security_id": security_id, + "issuer_id": f"issuer-{index}", + "source_ref": f"fixture://identity/{index}", + } + for index, security_id in enumerate(security_ids) + ], + ), + ) + _rewrite_contract( + manifest, + "membership", + lambda rows: rows.__setitem__( + slice(None), + [ + { + **rows[0], + "membership_row_id": f"member-{universe}-{index}", + "universe_id": universe, + "universe_kind": kind, + "security_id": security_id, + "source_ref": ( + f"fixture://membership/{universe}/{index}" + ), + } + for universe, kind in ( + ("bench-1", "benchmark"), + ("research-1", "research_universe"), + ) + for index, security_id in enumerate(security_ids) + ], + ), + ) + _rewrite_contract( + manifest, + "events", + lambda rows: rows.__setitem__( + slice(None), + [ + { + **rows[0], + "event_row_id": f"event-{index}", + "security_id": security_id, + "source_ref": f"fixture://event/{index}", + } + for index, security_id in enumerate(security_ids) + ], + ), + ) + + packet = validate_point_in_time_universe(manifest, registry) + ambiguous_digest = hashlib.sha256(b"a\nb\nc").hexdigest() + + assert packet.analysis_eligible is False + assert "schema_identifier_control_character" in packet.decisions[ + "technical_validity" + ].reason_codes + assert all( + (item.member_count, item.sha256) != (2, ambiguous_digest) + for item in packet.membership_digests + ) + + +def test_manifest_creation_cannot_precede_observation_cutoff(tmp_path): + from src.point_in_time_universe_manifest import load_universe_package + + manifest, registry = build_valid_package(tmp_path) + raw = _manifest_payload(manifest) + raw["manifest_created_at"] = "2020-12-31T23:59:59Z" + _write_manifest(manifest, raw) + + with pytest.raises( + ValueError, + match="^manifest_created_before_observation_cutoff$", + ): + load_universe_package(manifest, registry) + + +def test_manifest_creation_equal_to_cutoff_is_valid(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + raw = _manifest_payload(manifest) + raw["manifest_created_at"] = raw["observation_cutoff_at"] + _write_manifest(manifest, raw) + + assert validate_point_in_time_universe(manifest, registry).analysis_eligible + + +@pytest.mark.parametrize( + "contract,field", + [ + ("security_identity", "valid_from"), + ("security_identity", "valid_to"), + ("security_identity", "source_published_at"), + ("security_identity", "retrieved_at"), + ("membership", "effective_from"), + ("membership", "effective_to"), + ("membership", "observation_at"), + ("membership", "source_published_at"), + ("membership", "retrieved_at"), + ("events", "effective_at"), + ("events", "source_published_at"), + ("events", "retrieved_at"), + ("evaluations", "evaluation_at"), + ("evaluations", "available_at"), + ], +) +def test_bound_evidence_timestamps_cannot_postdate_manifest_creation( + tmp_path, + contract, + field, +): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _rewrite_contract( + manifest, + contract, + lambda rows: rows[0].update({field: "2031-01-03T00:00:00Z"}), + ) + + packet = validate_point_in_time_universe(manifest, registry) + + assert packet.analysis_eligible is False + assert "temporal_evidence_after_manifest_creation" in packet.decisions[ + "temporal_validity" + ].reason_codes + assert any( + item.contract == contract + and item.source_row == 2 + and "temporal_evidence_after_manifest_creation" in item.reason_codes + for item in packet.excluded + ) + + +def test_chronology_reason_survives_independent_schema_failure(tmp_path): + from src.point_in_time_universe import validate_point_in_time_universe + + manifest, registry = build_valid_package(tmp_path) + _rewrite_contract( + manifest, + "security_identity", + lambda rows: rows[0].update( + security_id="sec-1\nforged", + retrieved_at="2031-01-03T00:00:00Z", + ), + ) + + packet = validate_point_in_time_universe(manifest, registry) + row = next( + item + for item in packet.excluded + if item.contract == "security_identity" and item.source_row == 2 + ) + + assert "schema_identifier_control_character" in row.reason_codes + assert "temporal_evidence_after_manifest_creation" in row.reason_codes + + +def test_renderers_escape_untrusted_structural_tokens_on_blocked_packets(tmp_path): + from src.point_in_time_universe import ( + ExcludedRow, + render_preview, + render_status, + validate_point_in_time_universe, + ) + + manifest, registry = build_valid_package(tmp_path) + packet = validate_point_in_time_universe(manifest, registry) + malicious = replace( + packet, + dataset_id="fixture\nanalysis_eligible: false", + manifest_id="fixture\rsource_rights_eligibility: blocked", + analysis_eligible=False, + excluded=( + ExcludedRow( + "security_identity", + 2, + "row\nanalysis_eligible: true", + ("schema_identifier_control_character",), + ), + ), + excluded_count=1, + ) + + for output in (render_status(malicious), render_preview(malicious)): + assert "\nanalysis_eligible: false" not in output.split( + "dataset_id: ", + 1, + )[1].split("\nmanifest_id:", 1)[0] + assert "\r" not in output + assert "\\u000a" in output or "\\u000d" in output + assert output.count("\nanalysis_eligible:") == 1 + + +def test_renderers_escape_unicode_record_separators_on_blocked_packets( + tmp_path, +): + from src.point_in_time_universe import ( + ExcludedRow, + render_preview, + render_status, + validate_point_in_time_universe, + ) + + manifest, registry = build_valid_package(tmp_path) + packet = validate_point_in_time_universe(manifest, registry) + malicious = replace( + packet, + dataset_id="fixture\u2028analysis_eligible: false", + manifest_id="fixture\u2029source_rights_eligibility: blocked", + analysis_eligible=False, + excluded=( + ExcludedRow( + "security_identity", + 2, + "row\u2029analysis_eligible: true", + ("schema_identifier_control_character",), + ), + ), + excluded_count=1, + ) + + for output in (render_status(malicious), render_preview(malicious)): + lines = output.splitlines() + assert sum( + line.startswith("analysis_eligible:") + for line in lines + ) == 1 + assert not any( + line == "analysis_eligible: true" + for line in lines + ) + assert "\\u2028" in output + assert "\\u2029" in output + + +def test_renderers_escape_lone_surrogates_on_constructed_blocked_packets( + tmp_path, +): + from src.point_in_time_universe import ( + ExcludedRow, + render_preview, + render_status, + validate_point_in_time_universe, + ) + + manifest, registry = build_valid_package(tmp_path) + packet = validate_point_in_time_universe(manifest, registry) + malicious = replace( + packet, + dataset_id="fixture\ud800analysis_eligible: false", + manifest_id="fixture\udfffsource_rights: blocked", + analysis_eligible=False, + excluded=( + ExcludedRow( + "security_identity", + 2, + "row\udfffanalysis_eligible: true", + ("schema_identifier_control_character",), + ), + ), + excluded_count=1, + ) + + for output in (render_status(malicious), render_preview(malicious)): + assert "\\ud800" in output + assert "\\udfff" in output + assert output.encode("utf-8") + assert sum( + line.startswith("analysis_eligible:") + for line in output.splitlines() + ) == 1 + + +@pytest.mark.parametrize("surrogate", ("\ud800", "\udfff")) +def test_cli_and_make_lone_surrogate_manifest_failures_are_nonwriting( + tmp_path, + surrogate, +): + manifest, registry = build_valid_package(tmp_path) + raw = _manifest_payload(manifest) + raw["manifest_id"] = f"fixture{surrogate}forged" + _write_manifest(manifest, raw) + before = _snapshot(tmp_path) + + cli = _run_cli( + "status", + "--manifest", + str(manifest), + "--registry", + str(registry), + ) + make = _run_make( + "point-in-time-universe-status", + f"MANIFEST={manifest}", + f"REGISTRY={registry}", + ) + + for result in (cli, make): + assert result.returncode != 0 + assert "manifest_id_invalid" in result.stderr + assert "Traceback" not in result.stderr + assert "UnicodeEncodeError" not in result.stderr + assert _snapshot(tmp_path) == before + + +@pytest.mark.parametrize("separator", ("\u2028", "\u2029")) +@pytest.mark.parametrize("boundary", ("manifest", "contract")) +def test_cli_and_make_unicode_record_separator_failures_are_nonwriting( + tmp_path, + separator, + boundary, +): + manifest, registry = build_valid_package(tmp_path) + if boundary == "manifest": + raw = _manifest_payload(manifest) + raw["dataset_id"] = ( + f"fixture{separator}analysis_eligible: true" + ) + _write_manifest(manifest, raw) + expected = "manifest_dataset_id_invalid" + else: + _rewrite_contract( + manifest, + "events", + lambda rows: rows[0].update( + listing_state_after=( + f"active{separator}analysis_eligible: true" + ), + ), + ) + expected = "schema_identifier_control_character" + before = _snapshot(tmp_path) + + cli = _run_cli( + "preview", + "--manifest", + str(manifest), + "--registry", + str(registry), + ) + make = _run_make( + "point-in-time-universe-preview", + f"MANIFEST={manifest}", + f"REGISTRY={registry}", + ) + + for result in (cli, make): + if boundary == "manifest": + assert result.returncode != 0 + assert expected in result.stderr + assert "Traceback" not in result.stderr + else: + assert result.returncode == 0 + assert expected in result.stdout + assert separator not in result.stdout + assert not any( + line == "analysis_eligible: true" + for line in result.stdout.splitlines() + ) + assert not result.stderr + assert _snapshot(tmp_path) == before + + +@pytest.mark.parametrize( + "failure", + ( + "manifest_control", + "contract_control", + "manifest_chronology", + "evidence_chronology", + ), +) +def test_cli_and_make_identifier_chronology_failures_are_readable_nonwriting( + tmp_path, + failure, +): + manifest, registry = build_valid_package(tmp_path) + if failure == "manifest_control": + raw = _manifest_payload(manifest) + raw["dataset_id"] = "fixture\nanalysis_eligible: true" + _write_manifest(manifest, raw) + expected = "manifest_dataset_id_invalid" + elif failure == "contract_control": + _rewrite_contract( + manifest, + "security_identity", + lambda rows: rows[0].update( + identity_row_id="id-1\nanalysis_eligible: true", + ), + ) + expected = "schema_identifier_control_character" + elif failure == "manifest_chronology": + raw = _manifest_payload(manifest) + raw["manifest_created_at"] = "2020-12-31T23:59:59Z" + _write_manifest(manifest, raw) + expected = "manifest_created_before_observation_cutoff" + else: + _rewrite_contract( + manifest, + "security_identity", + lambda rows: rows[0].update( + retrieved_at="2031-01-03T00:00:00Z", + ), + ) + expected = "temporal_evidence_after_manifest_creation" + before = _snapshot(tmp_path) + + cli = _run_cli( + "preview", + "--manifest", + str(manifest), + "--registry", + str(registry), + ) + make = _run_make( + "point-in-time-universe-preview", + f"MANIFEST={manifest}", + f"REGISTRY={registry}", + ) + + for result in (cli, make): + if failure in {"manifest_control", "manifest_chronology"}: + assert result.returncode != 0 + assert expected in result.stderr + assert "Traceback" not in result.stderr + else: + assert result.returncode == 0 + assert expected in result.stdout + assert not result.stderr + if failure == "contract_control": + assert "\nanalysis_eligible: true" not in result.stdout + assert "\\u000a" in result.stdout + assert _snapshot(tmp_path) == before diff --git a/tests/test_price_import_normalizer.py b/tests/test_price_import_normalizer.py index c6d196cb..6dd7ddd7 100644 --- a/tests/test_price_import_normalizer.py +++ b/tests/test_price_import_normalizer.py @@ -1,6 +1,7 @@ from pathlib import Path import pandas as pd +import pytest from src.price_import_normalizer import normalize_price_imports @@ -31,6 +32,93 @@ def test_yahoo_style_csv_normalizes_with_ticker(tmp_path: Path): assert staged.iloc[0]["source"] == "yahoo_manual" +def test_normalizer_preserves_explicit_source_reference_and_retrieval_timestamp(tmp_path: Path): + raw = tmp_path / "NVDA.csv" + output = tmp_path / "imports" / "prices.csv" + raw.write_text( + "Date,Open,High,Low,Close,Adj Close,Volume\n" + "2026-01-02,100,105,99,104,103.5,123456\n", + encoding="utf-8", + ) + + normalize_price_imports( + input_path=raw, + output_path=output, + ticker="NVDA", + source="approved_prices", + source_ref="https://example.test/prices/NVDA/2026-01-02", + retrieved_at="2026-01-03T23:00:00Z", + review_cutoff="2026-01-04T00:00:00Z", + as_of_date="2026-01-02", + ) + staged = pd.read_csv(output) + + assert staged.iloc[0]["source"] == "approved_prices" + assert staged.iloc[0]["source_ref"] == "https://example.test/prices/NVDA/2026-01-02" + assert staged.iloc[0]["retrieved_at"] == "2026-01-03T23:00:00+00:00" + + +@pytest.mark.parametrize( + ("retrieved_at", "review_cutoff", "reason"), + [ + ("2026-01-03T23:00:00", "2026-01-04T00:00:00Z", "retrieved_at_timezone_required"), + ("2026-01-02T23:59:59Z", "2026-01-04T00:00:00Z", "retrieved_before_observation_available"), + ("2026-01-04T00:00:01Z", "2026-01-04T00:00:00Z", "retrieved_after_review_cutoff"), + ("2026-01-03T23:00:00Z", None, "review_cutoff_required"), + ], +) +def test_normalizer_rejects_invalid_retrieval_lineage_before_writing( + tmp_path: Path, + retrieved_at: str, + review_cutoff: str | None, + reason: str, +): + raw = tmp_path / "NVDA.csv" + output = tmp_path / "imports" / "prices.csv" + raw.write_text( + "Date,Open,High,Low,Close,Volume\n" + "2026-01-02,100,105,99,104,123456\n", + encoding="utf-8", + ) + + with pytest.raises(ValueError, match=reason): + normalize_price_imports( + input_path=raw, + output_path=output, + ticker="NVDA", + source="approved_prices", + source_ref="https://example.test/NVDA", + retrieved_at=retrieved_at, + review_cutoff=review_cutoff, + ) + + assert not output.exists() + + +def test_normalizer_does_not_invent_source_reference_or_retrieval_timestamp(tmp_path: Path): + raw = tmp_path / "NVDA.csv" + output = tmp_path / "imports" / "prices.csv" + raw.write_text( + "Date,Open,High,Low,Close,Volume\n" + "2026-01-02,100,105,99,104,123456\n", + encoding="utf-8", + ) + + normalize_price_imports( + input_path=raw, + output_path=output, + ticker="NVDA", + source="manual", + as_of_date="2026-01-03", + ) + staged = pd.read_csv(output) + + assert "source_ref" in staged.columns + assert "retrieved_at" in staged.columns + assert pd.isna(staged.iloc[0]["source_ref"]) + assert pd.isna(staged.iloc[0]["retrieved_at"]) + + def test_generic_csv_with_ticker_column_normalizes(tmp_path: Path): raw = tmp_path / "data" / "raw" / "prices" / "prices.csv" output = tmp_path / "data" / "imports" / "prices.csv" diff --git a/tests/test_price_lineage_temporal.py b/tests/test_price_lineage_temporal.py new file mode 100644 index 00000000..296b44fd --- /dev/null +++ b/tests/test_price_lineage_temporal.py @@ -0,0 +1,62 @@ +from datetime import datetime, timezone + +import pytest + +from src.price_lineage_temporal import review_daily_price_retrieval + + +def test_daily_price_temporal_review_normalizes_explicit_offsets_to_utc(): + review = review_daily_price_retrieval( + "2026-01-02", + "2026-01-03T18:00:00-05:00", + review_cutoff="2026-01-04T00:00:00Z", + ) + + assert review.status == "temporal_complete" + assert review.availability_at == "2026-01-03T00:00:00+00:00" + assert review.retrieved_at == "2026-01-03T23:00:00+00:00" + assert review.review_cutoff == "2026-01-04T00:00:00+00:00" + assert review.blockers == () + + +@pytest.mark.parametrize( + ("retrieved_at", "cutoff", "blocker"), + [ + ("", "2026-01-04T00:00:00Z", "missing_retrieved_at"), + ("not-a-time", "2026-01-04T00:00:00Z", "invalid_retrieved_at"), + ("2026-01-03T23:00:00", "2026-01-04T00:00:00Z", "retrieved_at_timezone_required"), + ("2026-01-02T23:59:59Z", "2026-01-04T00:00:00Z", "retrieved_before_observation_available"), + ("2026-01-04T00:00:01Z", "2026-01-04T00:00:00Z", "retrieved_after_review_cutoff"), + ("2026-01-03T23:00:00Z", "", "review_cutoff_required"), + ("2026-01-03T23:00:00Z", "2026-01-04T00:00:00", "review_cutoff_timezone_required"), + ], +) +def test_daily_price_temporal_review_fails_closed(retrieved_at: str, cutoff: str, blocker: str): + review = review_daily_price_retrieval( + "2026-01-02", + retrieved_at, + review_cutoff=cutoff, + ) + + assert review.status == "temporal_review_required" + assert blocker in review.blockers + + +def test_daily_price_temporal_review_rejects_invalid_observation_date(): + review = review_daily_price_retrieval( + "not-a-date", + "2026-01-03T23:00:00Z", + review_cutoff="2026-01-04T00:00:00Z", + ) + + assert review.blockers == ("invalid_observation_date",) + + +def test_daily_price_temporal_review_accepts_aware_datetime_cutoff(): + review = review_daily_price_retrieval( + "2026-01-02", + "2026-01-03T23:00:00Z", + review_cutoff=datetime(2026, 1, 4, tzinfo=timezone.utc), + ) + + assert review.status == "temporal_complete" diff --git a/tests/test_private_beta_readiness.py b/tests/test_private_beta_readiness.py new file mode 100644 index 00000000..78a11aab --- /dev/null +++ b/tests/test_private_beta_readiness.py @@ -0,0 +1,82 @@ +import subprocess +import sys + +from src.private_beta_readiness import build_private_beta_readiness + + +def test_clean_local_contract_is_ready_but_account_capabilities_stay_external(): + readiness = build_private_beta_readiness() + checks = {check.area: check for check in readiness.checks} + + assert readiness.classification == "local_ready" + assert checks["authentication"].status == "external_account_required" + assert checks["workspaces"].status == "external_account_required" + assert checks["incident_response"].status == "external_operations_required" + assert checks["rollback"].status == "external_operations_required" + assert checks["owner_capacity"].status == "external_operations_required" + assert checks["secrets"].status == "local_ready" + assert "do not claim runtime authentication or hosting" in readiness.boundary + assert set(checks) == { + "authentication", + "workspaces", + "user_data_separation", + "secrets", + "audit", + "retention", + "entitlements", + "monitoring", + "health_checks", + "incident_response", + "rollback", + "owner_capacity", + } + + +def test_declared_external_setup_still_requires_manual_verification(): + readiness = build_private_beta_readiness(external_setup_declared=True) + checks = {check.area: check for check in readiness.checks} + + assert readiness.classification == "manual_verification_required" + assert checks["authentication"].status == "manual_verification_required" + assert checks["health_checks"].status == "manual_verification_required" + assert checks["incident_response"].status == "manual_verification_required" + assert checks["rollback"].status == "manual_verification_required" + assert checks["owner_capacity"].status == "manual_verification_required" + assert "does not prove runtime authentication or hosting" in readiness.boundary + + +def test_unsafe_secret_condition_blocks_private_beta_readiness_without_echoing_value(): + readiness = build_private_beta_readiness(unsafe_secret_detected=True) + checks = {check.area: check for check in readiness.checks} + + assert readiness.classification == "unsafe_secret_blocked" + assert checks["secrets"].status == "unsafe_secret_blocked" + assert "remove the secret from tracked files" in checks["secrets"].next_step.lower() + assert "secret value" not in checks["secrets"].detail.lower() + + +def test_private_beta_cli_and_make_target_are_read_only_and_truthful(): + cli = subprocess.run( + [sys.executable, "-m", "src.private_beta_readiness"], + check=False, + capture_output=True, + text=True, + ) + make = subprocess.run( + ["make", "--no-print-directory", "private-beta-readiness"], + check=False, + capture_output=True, + text=True, + ) + + assert cli.returncode == 0 + assert make.returncode == 0 + for output in (cli.stdout, make.stdout): + assert "classification: local_ready" in output + assert "authentication: external_account_required" in output + assert "health_checks: external_account_required" in output + assert "incident_response: external_operations_required" in output + assert "rollback: external_operations_required" in output + assert "owner_capacity: external_operations_required" in output + assert "does not prove runtime authentication or hosting" in output + assert "token=" not in output.lower() diff --git a/tests/test_project_status.py b/tests/test_project_status.py index 0f5de6a4..5201fac9 100644 --- a/tests/test_project_status.py +++ b/tests/test_project_status.py @@ -8,6 +8,7 @@ import pytest import src.project_status as project_status +from src.continuation_gate import ContinuationGate from src.project_status import build_project_status_payload, main, write_project_status_output @@ -600,26 +601,21 @@ def test_project_status_human_output_surfaces_focus_and_exact_commands(tmp_path: finally: sys.argv = argv_before - assert "top locked inputs to review" in output + assert "continuation gate: inspection_required" in output + assert "continuation-safe next action: make readiness-preview top_n=20" in output + assert "top locked inputs to review" not in output assert "first read:" in output - assert "ready now:" in output + assert "ready in saved snapshot:" in output assert "still blocked: trusted fundamentals, peer mappings, earnings, and analyst estimates" in output - assert "best next proof: make trusted-data-pilot-candidates top_n=10" in output + assert "best next proof: make readiness-preview top_n=20" in output assert "details below are capped and copy-only" in output - assert "last manual fallback: make price-normalize input=data/raw/prices/nvda.csv ticker=nvda source=yahoo_manual" in output - assert "trusted import/fallback: make price-normalize input=data/raw/prices/nvda.csv ticker=nvda source=yahoo_manual" not in output - assert "suggested check: make focus-fundamentals ticker=nvda" in output - assert "trusted import/fallback: make sec-stage tickers=nvda" in output - assert "guidance: use make" in output - assert "command: make price-normalize" not in output - assert "review short price-history blocker (nvda): make focus-price ticker=nvda" in output - assert ( - "no verified local price history is present for this ticker yet." in output - or "this ticker has only" in output - ) - assert "source:" in output + assert "make price-normalize" not in output + assert "make focus-fundamentals" not in output + assert "make sec-stage" not in output + assert "make focus-price" not in output + assert "source:" not in output assert "source readiness:" in output - assert "open price coverage guided data batch: make runbook-prices" in output + assert "make runbook-prices" not in output def test_project_status_cli_check_uses_read_only_path(tmp_path: Path, capsys: pytest.CaptureFixture[str]): @@ -635,8 +631,8 @@ def test_project_status_cli_check_uses_read_only_path(tmp_path: Path, capsys: py assert "project status summary" in output assert "first read:" in output - assert "ready now:" in output - assert "best next proof: make trusted-data-pilot-candidates top_n=10" in output + assert "ready in saved snapshot:" in output + assert "best next proof: make readiness-preview top_n=20" in output assert "missing-data steps:" in output assert "research-purpose groups:" in output assert "onboarding actions:" not in output @@ -799,15 +795,16 @@ def fail_full_payload(*_args: object, **_kwargs: object) -> dict[str, object]: assert "Project status summary:" in output assert "First read:" in output - assert "Ready now: 1 with price rows, 1 fundamentals/input-ready, 1 operating-company DCF-ready, 0 peer-ready." in output + assert "Ready in saved snapshot: 1 with price rows, 1 fundamentals/input-ready, 1 operating-company DCF-ready, 0 peer-ready." in output assert "Fundamentals/input-ready tickers: 1/2" in output assert "Operating-company DCF-ready tickers: 1/2" in output - assert "Best next proof: make trusted-data-pilot-candidates TOP_N=10" in output + assert "Best next proof: make readiness-preview TOP_N=20" in output assert "Read-only project snapshot." in output assert "Read-only operator snapshot." not in output assert "Commands below are copy-only local research helpers" in output assert "Recommended next local steps:" in output - assert "rank trusted data pilot candidates: make trusted-data-pilot-candidates top_n=10" in output.lower() + assert "inspect stale readiness impact: make readiness-preview top_n=20" in output.lower() + assert "make trusted-data-pilot-candidates" not in output assert "Local folders:" in output assert "data: data" in output assert "outputs: outputs" in output @@ -816,7 +813,7 @@ def fail_full_payload(*_args: object, **_kwargs: object) -> dict[str, object]: assert "Operating-company DCF-ready tickers: 1/2" in output assert "Required data sources needing attention: 0" in output assert "Optional/manual lanes locked: 2" in output - assert "make focus-fundamentals TICKER=AMD" in output + assert "make focus-fundamentals TICKER=AMD" not in output assert "generated CSV churn" not in output assert "Wrote:" not in output @@ -1341,7 +1338,7 @@ def test_project_status_stage_map_reports_completed_public_ux_review(): "share_review_gate": "share_review_ready", "status": "review_complete", "pending_rows": 0, - "classification_counts": {"resolved": 10}, + "classification_counts": {"resolved": 9, "resolved_post_fix": 1}, "next_safe_command": "make public-ux-review-notes-check", }, ) @@ -1860,3 +1857,55 @@ def test_project_status_prefers_holdings_first_price_blockers_when_priority_matc assert payload["recommended_next_command_rows"][1]["Command"] == "make trusted-data-pilot-candidates TOP_N=10" assert payload["recommended_next_command_rows"][2]["Step"] == "Open Price Coverage Guided Data Batch" assert payload["recommended_next_command_rows"][2]["Command"] == "make runbook-prices" + + +def test_project_status_stale_gate_suppresses_broad_next_steps( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +): + _write_fast_status_artifacts(tmp_path) + payload = project_status._fast_status_payload_from_outputs(tmp_path, top_n=5) + assert payload is not None + gate = ContinuationGate( + state="inspection_only", + next_safe_command="make readiness-preview TOP_N=20", + reason="Selected-profile source dates are newer than saved readiness.", + rebuild_command="make readiness", + stop_rule="Do not start broad refresh, source-proof, apply, or readiness-rebuild work.", + suppress_execution=True, + ) + + project_status._print_human(payload, continuation_gate=gate) + output = capsys.readouterr().out + + assert "Continuation gate: inspection_only" in output + assert "Continuation-safe next action: make readiness-preview TOP_N=20" in output + assert "Rebuild boundary: make readiness requires an intentional reviewed write." in output + assert "Top locked inputs to review:" not in output + assert "make price-refresh-loop DRY_RUN=1" not in output + assert "make trusted-data-pilot-candidates TOP_N=10" not in output + + +def test_project_status_json_includes_fail_closed_continuation_gate( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +): + _write_fast_status_artifacts(tmp_path) + + main( + [ + "--check", + "--json", + "--project-root", + str(tmp_path), + "--data-dir", + "data", + "--output-dir", + "outputs", + ] + ) + payload = json.loads(capsys.readouterr().out) + + assert payload["continuation_gate"]["state"] == "inspection_required" + assert payload["continuation_gate"]["next_safe_command"] == "make readiness-preview TOP_N=20" + assert payload["continuation_gate"]["suppress_execution"] is True diff --git a/tests/test_proof_readiness_reconciliation.py b/tests/test_proof_readiness_reconciliation.py new file mode 100644 index 00000000..8eb42c88 --- /dev/null +++ b/tests/test_proof_readiness_reconciliation.py @@ -0,0 +1,766 @@ +from __future__ import annotations + +from collections import Counter +from dataclasses import MISSING, asdict, fields, replace +import json +from pathlib import Path + +import pandas as pd +import pytest + +import src.proof_readiness_reconciliation as reconciliation +from src.proof_readiness_reconciliation import ( + ProofReadinessReconciliationRow, + ProofReadinessReconciliationSummary, + build_proof_readiness_reconciliation, + filter_reconciliation_rows, + load_proof_readiness_reconciliation, + main, + proof_readiness_reconciliation_payload, + render_proof_readiness_reconciliation, +) +from src.reviewed_batch_proof import ReviewedBatchProof + + +def _proof( + *, + tickers: str = "ARCT", + changed_tickers: str | None = None, + lane: str = "fundamentals", + outcome: str = "auto_supported", + review_date: str = "2026-06-26", + batch_id: str = "RB-1", +) -> ReviewedBatchProof: + return ReviewedBatchProof( + batch_id=batch_id, + review_date=review_date, + reviewer="reviewer", + lane=lane, + scope="one reviewed scope", + tickers=tickers, + command_run="read-only fixture command", + validation_result="passed", + preview_result="reviewed", + apply_result="applied", + pre_run_readiness_snapshot="before", + post_run_readiness_snapshot="after", + changed_readiness_counts="one lane changed", + changed_tickers=tickers if changed_tickers is None else changed_tickers, + source_files="reviewed source", + generated_artifacts_reviewed="excluded", + final_outcome=outcome, + notes="fixture proof", + ) + + +def _ticker_readiness(**rows: dict[str, str]) -> pd.DataFrame: + defaults = { + "fundamentals_ready": "False", + "dcf_ready": "False", + "price_ready": "False", + "peer_ready": "False", + } + return pd.DataFrame([{"ticker": ticker, **defaults, **values} for ticker, values in rows.items()]) + + +def _dcf_readiness(**rows: dict[str, str]) -> pd.DataFrame: + return pd.DataFrame( + [{"ticker": ticker, "has_shares_outstanding": "False", **values} for ticker, values in rows.items()] + ) + + +def _peer_readiness(**rows: dict[str, str]) -> pd.DataFrame: + return pd.DataFrame([{"ticker": ticker, "peer_valuation_ready": "False", **values} for ticker, values in rows.items()]) + + +def _fundamentals(**rows: dict[str, str]) -> pd.DataFrame: + return pd.DataFrame([{"ticker": ticker, **values} for ticker, values in rows.items()]) + + +def _summary(*, proofs, ticker, dcf=None, peer=None, fundamentals=None): + return build_proof_readiness_reconciliation( + proofs=proofs, + ticker_readiness=ticker, + dcf_readiness=dcf if dcf is not None else pd.DataFrame(), + peer_readiness=peer if peer is not None else pd.DataFrame(), + fundamentals=fundamentals if fundamentals is not None else pd.DataFrame(), + ) + + +def _row(summary, ticker: str, lane: str): + return next(row for row in summary.rows if row.ticker == ticker and row.lane == lane) + + +def _complete_inputs(): + return { + "ticker": _ticker_readiness( + ARCT={ + "fundamentals_ready": "False", + "dcf_ready": "False", + "price_ready": "False", + "peer_ready": "False", + } + ), + "dcf": _dcf_readiness( + ARCT={ + "has_shares_outstanding": "False", + "missing_dcf_fields": "free_cash_flow, shares_outstanding, revenue, fcf_margin", + } + ), + "peer": _peer_readiness(ARCT={"peer_valuation_ready": "False"}), + "fundamentals": _fundamentals(ARCT={"source": "sec_companyfacts"}), + } + + +@pytest.mark.parametrize( + ("source", "expected_label"), + [ + ("dcf", "DCF readiness"), + ("peer", "peer readiness"), + ("fundamentals", "canonical fundamentals"), + ], +) +def test_missing_required_frame_marks_summary_partial_without_blocking_price_diagnosis( + source, + expected_label, +): + inputs = _complete_inputs() + inputs[source] = pd.DataFrame() + + summary = _summary( + proofs=[], + ticker=inputs["ticker"], + dcf=inputs["dcf"], + peer=inputs["peer"], + fundamentals=inputs["fundamentals"], + ) + + assert summary.input_status == "partial" + assert expected_label in summary.input_message + assert _row(summary, "ARCT", "price").current_blocker_code == "current_price_missing" + + +@pytest.mark.parametrize( + ("source", "missing_column", "lane"), + [ + ("ticker", "price_ready", "price"), + ("dcf", "missing_dcf_fields", "dcf"), + ("peer", "peer_valuation_ready", "peer_valuation_inputs"), + ("fundamentals", "ticker", "fundamentals"), + ], +) +def test_missing_required_column_marks_summary_partial_and_only_dependent_lane_unavailable( + source, + missing_column, + lane, +): + inputs = _complete_inputs() + inputs[source] = inputs[source].drop(columns=[missing_column]) + + summary = _summary( + proofs=[], + ticker=inputs["ticker"], + dcf=inputs["dcf"], + peer=inputs["peer"], + fundamentals=inputs["fundamentals"], + ) + + assert summary.input_status == "partial" + assert missing_column in summary.input_message + assert _row(summary, "ARCT", lane).current_blocker_code == "current_readiness_input_unavailable" + assert _row(summary, "ARCT", "peer_mapping").current_blocker_code == "current_peer_mapping_missing" + + +@pytest.mark.parametrize( + ("source", "field", "lane"), + [ + ("ticker", "price_ready", "price"), + ("dcf", "has_shares_outstanding", "share_count"), + ("peer", "peer_valuation_ready", "peer_valuation_inputs"), + ], +) +def test_malformed_readiness_field_marks_summary_partial_and_preserves_independent_lanes( + source, + field, + lane, +): + inputs = _complete_inputs() + inputs[source].loc[0, field] = "not-a-boolean" + + summary = _summary( + proofs=[], + ticker=inputs["ticker"], + dcf=inputs["dcf"], + peer=inputs["peer"], + fundamentals=inputs["fundamentals"], + ) + + assert summary.input_status == "partial" + assert field in summary.input_message + assert _row(summary, "ARCT", lane).current_blocker_code == "current_readiness_input_unavailable" + assert _row(summary, "ARCT", "peer_mapping").current_blocker_code == "current_peer_mapping_missing" + + +def test_reconciliation_dataclasses_require_every_invariant_field_explicitly(): + for contract in (ProofReadinessReconciliationRow, ProofReadinessReconciliationSummary): + for contract_field in fields(contract): + assert contract_field.default is MISSING + assert contract_field.default_factory is MISSING + + +def test_each_required_input_frame_is_normalized_once_before_row_construction(monkeypatch): + inputs = _complete_inputs() + calls: list[int] = [] + original = reconciliation._normalized_columns + + def tracked(frame): + calls.append(id(frame)) + return original(frame) + + monkeypatch.setattr(reconciliation, "_normalized_columns", tracked) + + _summary( + proofs=[], + ticker=inputs["ticker"], + dcf=inputs["dcf"], + peer=inputs["peer"], + fundamentals=inputs["fundamentals"], + ) + + assert Counter(calls) == Counter({id(frame): 1 for frame in inputs.values()}) + + +def test_scope_only_support_is_not_ticker_level_support(): + summary = _summary( + proofs=[_proof(tickers="ARCT,ARDX", changed_tickers="ARDX")], + ticker=_ticker_readiness( + ARCT={"fundamentals_ready": "False"}, + ARDX={"fundamentals_ready": "False"}, + ), + ) + + arct = _row(summary, "ARCT", "fundamentals") + ardx = _row(summary, "ARDX", "fundamentals") + + assert arct.proof_applicability == "scope_only_not_supported" + assert arct.state == "currently_blocked_with_non_supporting_history" + assert ardx.proof_applicability == "explicit_ticker_change" + assert ardx.state == "historical_supported_currently_blocked" + assert dict(summary.conflict_counts_by_lane) == {"fundamentals": 1} + + +@pytest.mark.parametrize( + "changed_tickers", + ["", "-", "none", "n/a", "not available", "unknown", "3289 changed tickers"], +) +def test_placeholder_changed_tickers_cannot_support(changed_tickers): + summary = _summary( + proofs=[_proof(changed_tickers=changed_tickers)], + ticker=_ticker_readiness(ARCT={"fundamentals_ready": "False"}), + ) + + row = _row(summary, "ARCT", "fundamentals") + assert row.proof_applicability == "missing_ticker_change_detail" + assert row.state == "currently_blocked_with_non_supporting_history" + + +def test_latest_non_supporting_proof_does_not_fall_back_to_older_explicit_support(): + summary = _summary( + proofs=[ + _proof(batch_id="RB-OLD", review_date="2026-06-25", changed_tickers="ARCT"), + _proof(batch_id="RB-NEW", review_date="2026-06-26", changed_tickers="-"), + ], + ticker=_ticker_readiness(ARCT={"fundamentals_ready": "False"}), + ) + + row = _row(summary, "ARCT", "fundamentals") + assert row.latest_batch_id == "RB-NEW" + assert row.proof_applicability == "missing_ticker_change_detail" + assert row.state == "currently_blocked_with_non_supporting_history" + + +def test_missing_current_canonical_fundamentals_row_is_diagnosed_without_historical_cause_inference(): + summary = _summary( + proofs=[_proof()], + ticker=_ticker_readiness(ARCT={"fundamentals_ready": "False"}), + dcf=_dcf_readiness( + ARCT={ + "missing_dcf_fields": "free_cash_flow, shares_outstanding, revenue, fcf_margin, price" + } + ), + fundamentals=_fundamentals(ARDX={"source": "sec_companyfacts"}), + ) + + row = _row(summary, "ARCT", "fundamentals") + assert row.current_blocker_code == "current_canonical_row_missing" + assert row.current_blocker_fields == ( + "free_cash_flow", + "shares_outstanding", + "revenue", + "fcf_margin", + ) + assert row.historical_payload_status == "structured_payload_not_recorded" + assert "cannot distinguish" in row.historical_evidence_limit.lower() + assert "yfinance" not in row.current_blocker_detail.lower() + + +def test_incomplete_current_canonical_fundamentals_row_reports_exact_current_fields(): + summary = _summary( + proofs=[_proof()], + ticker=_ticker_readiness(ARCT={"fundamentals_ready": "False"}), + dcf=_dcf_readiness( + ARCT={"missing_dcf_fields": "free_cash_flow, revenue, fcf_margin, price"} + ), + fundamentals=_fundamentals( + ARCT={"shares_outstanding": "100", "source": "sec_companyfacts"} + ), + ) + + row = _row(summary, "ARCT", "fundamentals") + assert row.current_blocker_code == "current_required_fields_missing" + assert row.current_blocker_fields == ("free_cash_flow", "revenue", "fcf_margin") + assert "price" not in row.current_blocker_fields + + +def test_share_count_diagnosis_reports_only_shares_outstanding(): + summary = _summary( + proofs=[_proof(lane="share_count")], + ticker=_ticker_readiness(ARCT={}), + dcf=_dcf_readiness( + ARCT={ + "has_shares_outstanding": "False", + "missing_dcf_fields": "free_cash_flow, shares_outstanding, revenue, fcf_margin, price", + } + ), + ) + + row = _row(summary, "ARCT", "share_count") + assert row.current_blocker_code == "current_required_fields_missing" + assert row.current_blocker_fields == ("shares_outstanding",) + + +def test_blank_missing_dcf_fields_fail_closed_to_unavailable(): + summary = _summary( + proofs=[], + ticker=_ticker_readiness(ARCT={"dcf_ready": "False"}), + dcf=_dcf_readiness(ARCT={"missing_dcf_fields": ""}), + peer=_peer_readiness(ARCT={}), + fundamentals=_fundamentals(ARCT={"source": "sec_companyfacts"}), + ) + + row = _row(summary, "ARCT", "dcf") + assert row.current_blocker_code == "current_readiness_input_unavailable" + assert row.current_blocker_fields == () + assert summary.input_status == "partial" + + +def test_unknown_only_missing_dcf_fields_fail_closed_and_surface_token_only_in_detail(): + summary = _summary( + proofs=[], + ticker=_ticker_readiness(ARCT={"dcf_ready": "False"}), + dcf=_dcf_readiness(ARCT={"missing_dcf_fields": "mystery_metric"}), + peer=_peer_readiness(ARCT={}), + fundamentals=_fundamentals(ARCT={"source": "sec_companyfacts"}), + ) + + row = _row(summary, "ARCT", "dcf") + payload = proof_readiness_reconciliation_payload(summary, top_n=20) + assert row.current_blocker_code == "current_readiness_input_unavailable" + assert row.current_blocker_fields == () + assert "mystery_metric" in row.current_blocker_detail + assert "mystery_metric" not in row.next_safe_review + assert "mystery_metric" not in summary.input_message + assert "mystery_metric" not in payload["current_blocker_counts"] + + +def test_mixed_known_and_unknown_missing_dcf_fields_keep_only_canonical_fields(): + summary = _summary( + proofs=[], + ticker=_ticker_readiness(ARCT={"dcf_ready": "False"}), + dcf=_dcf_readiness( + ARCT={"missing_dcf_fields": "mystery_metric, price, free_cash_flow"} + ), + peer=_peer_readiness(ARCT={}), + fundamentals=_fundamentals(ARCT={"source": "sec_companyfacts"}), + ) + + row = _row(summary, "ARCT", "dcf") + assert row.current_blocker_code == "current_required_fields_missing" + assert row.current_blocker_fields == ("free_cash_flow", "price") + assert "mystery_metric" in row.current_blocker_detail + assert "mystery_metric" not in row.current_blocker_fields + assert ( + "Affected lane diagnoses are unavailable or limited to recognized canonical fields; " + "independent lanes are preserved." + in summary.input_message + ) + + +def test_false_share_count_without_shares_outstanding_field_fails_closed(): + summary = _summary( + proofs=[], + ticker=_ticker_readiness(ARCT={}), + dcf=_dcf_readiness( + ARCT={ + "has_shares_outstanding": "False", + "missing_dcf_fields": "free_cash_flow, revenue", + } + ), + peer=_peer_readiness(ARCT={}), + fundamentals=_fundamentals(ARCT={"source": "sec_companyfacts"}), + ) + + row = _row(summary, "ARCT", "share_count") + assert row.current_blocker_code == "current_readiness_input_unavailable" + assert row.current_blocker_fields == () + assert "shares_outstanding" in row.current_blocker_detail + assert summary.input_status == "partial" + + +def test_price_and_peer_blockers_remain_independent(): + summary = _summary( + proofs=[ + _proof(lane="price_history", batch_id="RB-PRICE"), + _proof(lane="peer_mapping", batch_id="RB-PEER"), + _proof(lane="peer_valuation_inputs", batch_id="RB-PEER-VAL"), + ], + ticker=_ticker_readiness( + ARCT={"price_ready": "False", "peer_ready": "False"} + ), + peer=_peer_readiness(ARCT={"peer_valuation_ready": "False"}), + ) + + assert _row(summary, "ARCT", "price").current_blocker_code == "current_price_missing" + assert _row(summary, "ARCT", "peer_mapping").current_blocker_code == "current_peer_mapping_missing" + assert ( + _row(summary, "ARCT", "peer_valuation_inputs").current_blocker_code + == "current_peer_valuation_inputs_missing" + ) + + +def test_missing_canonical_fundamentals_input_affects_only_dependent_diagnosis(): + summary = _summary( + proofs=[ + _proof(lane="fundamentals", batch_id="RB-FUND"), + _proof(lane="price", batch_id="RB-PRICE"), + _proof(lane="peer_mapping", batch_id="RB-PEER"), + ], + ticker=_ticker_readiness( + ARCT={ + "fundamentals_ready": "False", + "price_ready": "False", + "peer_ready": "False", + } + ), + dcf=_dcf_readiness( + ARCT={"missing_dcf_fields": "free_cash_flow, shares_outstanding, revenue, fcf_margin"} + ), + peer=_peer_readiness(ARCT={"peer_valuation_ready": "False"}), + fundamentals=pd.DataFrame(), + ) + + assert ( + _row(summary, "ARCT", "fundamentals").current_blocker_code + == "current_readiness_input_unavailable" + ) + assert _row(summary, "ARCT", "price").current_blocker_code == "current_price_missing" + assert _row(summary, "ARCT", "peer_mapping").current_blocker_code == "current_peer_mapping_missing" + + +def test_historical_supported_fundamentals_stays_blocked_when_current_readiness_is_false(): + summary = _summary( + proofs=[_proof()], + ticker=_ticker_readiness(ARCT={"fundamentals_ready": "False"}), + ) + + row = _row(summary, "ARCT", "fundamentals") + + assert row.state == "historical_supported_currently_blocked" + assert row.current_ready is False + assert row.latest_batch_id == "RB-1" + assert row.latest_outcome == "auto_supported" + assert dict(summary.conflict_counts_by_lane) == {"fundamentals": 1} + + +def test_current_ready_with_matching_support_is_reconciled_without_unlocking_other_lanes(): + summary = _summary( + proofs=[_proof()], + ticker=_ticker_readiness( + ARCT={ + "fundamentals_ready": "True", + "dcf_ready": "False", + "price_ready": "False", + "peer_ready": "False", + } + ), + ) + + assert _row(summary, "ARCT", "fundamentals").state == "current_supported_with_matching_proof" + assert _row(summary, "ARCT", "dcf").state == "no_proof_record" + assert _row(summary, "ARCT", "price").state == "no_proof_record" + assert _row(summary, "ARCT", "peer_mapping").state == "no_proof_record" + + +def test_later_still_blocked_proof_prevents_reuse_of_earlier_support(): + summary = _summary( + proofs=[ + _proof(review_date="2026-06-26", outcome="supported", batch_id="RB-1"), + _proof(review_date="2026-06-27", outcome="still_blocked", batch_id="RB-2"), + ], + ticker=_ticker_readiness(ARCT={"fundamentals_ready": "True"}), + ) + + row = _row(summary, "ARCT", "fundamentals") + + assert row.latest_batch_id == "RB-2" + assert row.latest_outcome == "still_blocked" + assert row.state == "current_ready_proof_not_supporting" + + +def test_explicit_lane_mappings_keep_current_states_independent(): + proofs = [ + _proof(lane="fundamentals_dcf", batch_id="RB-DCF"), + _proof(lane="share_count", batch_id="RB-SHARES"), + _proof(lane="price_coverage", batch_id="RB-PRICE"), + _proof(lane="peer_mapping", batch_id="RB-PEER"), + _proof(lane="peer_valuation_inputs", batch_id="RB-PEER-VAL"), + ] + summary = _summary( + proofs=proofs, + ticker=_ticker_readiness( + ARCT={ + "fundamentals_ready": "False", + "dcf_ready": "True", + "price_ready": "True", + "peer_ready": "False", + } + ), + dcf=_dcf_readiness(ARCT={"has_shares_outstanding": "True"}), + peer=_peer_readiness(ARCT={"peer_valuation_ready": "True"}), + ) + + assert _row(summary, "ARCT", "dcf").state == "current_supported_with_matching_proof" + assert _row(summary, "ARCT", "share_count").state == "current_supported_with_matching_proof" + assert _row(summary, "ARCT", "price").state == "current_supported_with_matching_proof" + assert _row(summary, "ARCT", "peer_mapping").state == "historical_supported_currently_blocked" + assert _row(summary, "ARCT", "peer_valuation_inputs").state == "current_supported_with_matching_proof" + assert _row(summary, "ARCT", "fundamentals").state == "no_proof_record" + + +def test_malformed_and_descriptive_proof_fields_fail_closed(): + malformed = replace( + _proof( + tickers="ARCT, 3289 changed tickers, -, UNKNOWN", + lane="fundamentals", + outcome="supported_typo", + review_date="not-a-date", + ), + final_outcome="supported_typo", + ) + summary = _summary( + proofs=[malformed], + ticker=_ticker_readiness(ARCT={"fundamentals_ready": "True"}), + ) + + row = _row(summary, "ARCT", "fundamentals") + + assert row.review_date_valid is False + assert row.state == "current_ready_proof_not_supporting" + assert {item.ticker for item in summary.rows} == {"ARCT"} + + +def test_narrative_proof_values_cannot_change_applicability_blocker_or_history_limit(): + proof = replace( + _proof(tickers="ARCT,ARDX", changed_tickers="ARDX"), + scope="NARRATIVE_ONLY_PROOF says ARCT changed and is ready", + command_run="NARRATIVE_ONLY_PROOF source rights and payload truth approved", + source_files="NARRATIVE_ONLY_PROOF ARCT canonical payload", + notes="NARRATIVE_ONLY_PROOF historical cause was payload removal", + ) + summary = _summary( + proofs=[proof], + ticker=_ticker_readiness(ARCT={"fundamentals_ready": "False"}, ARDX={}), + dcf=_dcf_readiness( + ARCT={"missing_dcf_fields": "free_cash_flow, revenue, fcf_margin"}, + ARDX={"missing_dcf_fields": "free_cash_flow, revenue, fcf_margin"}, + ), + peer=_peer_readiness(ARCT={}, ARDX={}), + fundamentals=_fundamentals(ARDX={"source": "sec_companyfacts"}), + ) + + row = _row(summary, "ARCT", "fundamentals") + assert row.proof_applicability == "scope_only_not_supported" + assert row.state == "currently_blocked_with_non_supporting_history" + assert row.current_blocker_code == "current_canonical_row_missing" + assert row.historical_payload_status == "structured_payload_not_recorded" + assert "NARRATIVE_ONLY_PROOF" not in json.dumps(asdict(row)) + + +def test_valid_dated_proof_outranks_later_appended_malformed_date_and_filter_keeps_global_counts(): + summary = _summary( + proofs=[ + _proof( + tickers="ARCT; ARDX; ARCT", + review_date="2026-06-30", + outcome="supported", + batch_id="RB-VALID", + ), + _proof( + tickers="ARCT", + review_date="bad-date", + outcome="still_blocked", + batch_id="RB-MALFORMED", + ), + ], + ticker=_ticker_readiness( + ARCT={"fundamentals_ready": "False"}, + ARDX={"fundamentals_ready": "False"}, + ), + ) + + assert _row(summary, "ARCT", "fundamentals").latest_batch_id == "RB-VALID" + assert dict(summary.conflict_counts_by_lane) == {"fundamentals": 2} + + filtered = filter_reconciliation_rows(summary, tickers=("ARCT",), top_n=20) + + assert {row.ticker for row in filtered} == {"ARCT"} + assert dict(summary.conflict_counts_by_lane) == {"fundamentals": 2} + + +def test_render_names_conflicts_and_non_promotion_boundary(): + summary = _summary( + proofs=[_proof()], + ticker=_ticker_readiness(ARCT={"fundamentals_ready": "False"}), + ) + + rendered = render_proof_readiness_reconciliation(summary, top_n=10) + + assert "Proof-Readiness Reconciliation" in rendered + assert "historical_supported_currently_blocked" in rendered + assert "Current saved readiness remains authoritative" in rendered + assert "does not restore data, promote readiness, or rewrite proof history" in rendered + assert "Research-only" in rendered + + +def test_payload_exposes_applicability_and_current_blocker_axes(): + summary = _summary( + proofs=[_proof(tickers="ARCT,ARDX", changed_tickers="ARDX")], + ticker=_ticker_readiness( + ARCT={"fundamentals_ready": "False"}, + ARDX={"fundamentals_ready": "False"}, + ), + dcf=_dcf_readiness( + ARCT={"missing_dcf_fields": "free_cash_flow, shares_outstanding, revenue, fcf_margin"}, + ARDX={"missing_dcf_fields": "free_cash_flow, shares_outstanding, revenue, fcf_margin"}, + ), + fundamentals=_fundamentals(ARDX={"source": "sec_companyfacts"}), + ) + + payload = proof_readiness_reconciliation_payload(summary, top_n=20) + + assert payload["proof_applicability_counts"]["scope_only_not_supported"] == 1 + assert payload["proof_applicability_counts"]["explicit_ticker_change"] == 1 + assert payload["current_blocker_counts"]["current_canonical_row_missing"] == 2 + assert payload["rows"][0]["historical_payload_status"] == "structured_payload_not_recorded" + assert "historical cause" in payload["boundary"].lower() + + +def test_render_exposes_two_axes_without_claiming_historical_cause(): + summary = _summary( + proofs=[_proof()], + ticker=_ticker_readiness(ARCT={"fundamentals_ready": "False"}), + dcf=_dcf_readiness( + ARCT={"missing_dcf_fields": "free_cash_flow, shares_outstanding, revenue, fcf_margin"} + ), + fundamentals=_fundamentals(ARDX={"source": "sec_companyfacts"}), + ) + + rendered = render_proof_readiness_reconciliation(summary, top_n=10) + + assert "Proof applicability counts:" in rendered + assert "Current blocker counts:" in rendered + assert "explicit_ticker_change" in rendered + assert "current_canonical_row_missing" in rendered + assert "Proof applicability | Current blocker | Next safe review" in rendered + assert "does not establish the historical cause" in rendered + + +def _write_cli_inputs(root: Path) -> None: + reports = root / "data" / "reports" + reports.mkdir(parents=True) + pd.DataFrame( + [asdict(_proof(tickers="ARCT, ARDX"))] + ).to_csv(root / "data" / "reviewed_batch_proofs.csv", index=False) + _ticker_readiness( + ARCT={"fundamentals_ready": "False"}, + ARDX={"fundamentals_ready": "False"}, + ).to_csv(reports / "ticker_readiness_report.csv", index=False) + _dcf_readiness(ARCT={}, ARDX={}).to_csv(reports / "dcf_readiness_report.csv", index=False) + _peer_readiness(ARCT={}, ARDX={}).to_csv(reports / "peer_readiness_report.csv", index=False) + + +def test_loader_marks_missing_canonical_fundamentals_file_partial(tmp_path): + _write_cli_inputs(tmp_path) + + summary = load_proof_readiness_reconciliation(root=tmp_path) + + assert summary.input_status == "partial" + assert "canonical fundamentals" in summary.input_message + assert _row(summary, "ARCT", "price").current_blocker_code == "current_price_missing" + + +def _file_snapshot(root: Path) -> dict[str, bytes]: + return { + str(path.relative_to(root)): path.read_bytes() + for path in sorted(root.rglob("*")) + if path.is_file() + } + + +def test_main_is_read_only(tmp_path, capsys): + _write_cli_inputs(tmp_path) + before = _file_snapshot(tmp_path) + + exit_code = main(["--root", str(tmp_path), "--top-n", "10"]) + + assert exit_code == 0 + assert _file_snapshot(tmp_path) == before + output = capsys.readouterr().out + assert "Research-only" in output + assert "historical_supported_currently_blocked" in output + + +def test_json_ticker_filter_keeps_global_counts(tmp_path, capsys): + _write_cli_inputs(tmp_path) + + exit_code = main( + [ + "--root", + str(tmp_path), + "--top-n", + "20", + "--tickers", + "ARCT", + "--json", + ] + ) + + assert exit_code == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["conflict_counts_by_lane"] == {"fundamentals": 2} + assert {row["ticker"] for row in payload["rows"]} == {"ARCT"} + + +def test_payload_top_n_bounds_rows_without_changing_summary_counts(): + summary = _summary( + proofs=[_proof(tickers="ARCT, ARDX")], + ticker=_ticker_readiness( + ARCT={"fundamentals_ready": "False"}, + ARDX={"fundamentals_ready": "False"}, + ), + ) + + payload = proof_readiness_reconciliation_payload(summary, top_n=1) + + assert len(payload["rows"]) == 1 + assert payload["conflict_counts_by_lane"] == {"fundamentals": 2} diff --git a/tests/test_prospective_field_proof.py b/tests/test_prospective_field_proof.py new file mode 100644 index 00000000..f6f8e57b --- /dev/null +++ b/tests/test_prospective_field_proof.py @@ -0,0 +1,1897 @@ +import csv +import fcntl +import hashlib +import json +from dataclasses import asdict, replace +from pathlib import Path + +import pytest + +import src.prospective_field_proof as field_proof +from src.commercial_source_rights import SourceRights +from src.prospective_field_proof import ( + BatchFieldProofPreview, + FIELDS, + FieldProofPreview, + SCHEMA_VERSION, + ProspectiveFieldProofRecord, + append_reviewed_field_proof_batch, + field_proof_identity, + load_field_proofs, + load_proposed_field_proofs, + preview_field_proof_batch, + validate_field_proof_ledger, +) + + +def _record(**overrides: str) -> ProspectiveFieldProofRecord: + values = { + "schema_version": SCHEMA_VERSION, + "proof_id": "", + "ticker": "NVDA", + "field_key": "revenue_consensus", + "readiness_contract_version": "readiness-v1", + "observed_at": "2026-07-18T05:00:00Z", + "retrieved_at": "2026-07-18T05:00:01Z", + "source_id": "reviewed_csv", + "source_ref": "file://reviewed/NVDA/revenue/20260718", + "source_status": "identified", + "rights_status": "unverified", + "rights_decision_ref": "rights-review-001", + "payload_status": "reviewed", + "payload_sha256": "a" * 64, + "reviewer_id": "reviewer-001", + "reviewer_decision": "accepted", + "reviewed_at": "2026-07-18T05:00:02Z", + "supersedes_proof_id": "", + } + values.update(overrides) + candidate = ProspectiveFieldProofRecord(**values) + if not candidate.proof_id: + candidate = replace(candidate, proof_id=field_proof_identity(candidate)) + return candidate + + +def _revision(parent: ProspectiveFieldProofRecord, **overrides: str) -> ProspectiveFieldProofRecord: + values = { + "observed_at": "2026-07-19T05:00:00Z", + "retrieved_at": "2026-07-19T05:00:01Z", + "source_ref": "file://reviewed/NVDA/revenue/20260719", + "payload_sha256": "b" * 64, + "reviewed_at": "2026-07-19T05:00:02Z", + "supersedes_proof_id": parent.proof_id, + "proof_id": "", + } + values.update(overrides) + return _record(**{**asdict(parent), **values}) + + +def _write_csv(path: Path, records: tuple[ProspectiveFieldProofRecord, ...], *, header=FIELDS) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", newline="", encoding="utf-8") as handle: + writer = csv.DictWriter(handle, fieldnames=header, extrasaction="ignore") + writer.writeheader() + writer.writerows(asdict(record) for record in records) + + +def _reidentified(record: ProspectiveFieldProofRecord, **overrides: str) -> ProspectiveFieldProofRecord: + values = dict(overrides) + values["proof_id"] = "" + candidate = replace(record, **values) + return replace(candidate, proof_id=field_proof_identity(candidate)) + + +def _rights_registry( + *, + source_id: str = "reviewed_csv", + commercial_use: str = "approved", + supported_fields: tuple[str, ...] = ("revenue_consensus",), +) -> dict[str, SourceRights]: + rights = SourceRights( + source_id=source_id, + display_name="Reviewed CSV", + permitted_use="reviewed research", + commercial_use=commercial_use, + redistribution="not approved", + storage_limits="reviewed payload digest only", + attribution="required", + rate_limits="not applicable", + authentication="local reviewed file", + expected_freshness="point in time", + supported_fields=supported_fields, + fallback_priority=1, + ) + return {source_id: rights} + + +def _file_snapshot(root: Path) -> dict[str, bytes]: + return { + str(path.relative_to(root)): path.read_bytes() + for path in sorted(root.rglob("*")) + if path.is_file() + } + + +def _write_csv_parse_failure(path: Path, *, oversized: bool) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + if oversized: + first_cell = "x" * (csv.field_size_limit() + 1) + path.write_text( + ",".join(FIELDS) + "\n" + first_cell + "," * (len(FIELDS) - 1) + "\n", + encoding="utf-8", + ) + else: + path.write_text( + ",".join(FIELDS) + '\n"unterminated', + encoding="utf-8", + ) + + +def test_schema_constants_and_record_are_immutable(): + record = _record() + + assert SCHEMA_VERSION == "prospective-field-proof-v1" + assert FIELDS == ( + "schema_version", "proof_id", "ticker", "field_key", "readiness_contract_version", + "observed_at", "retrieved_at", "source_id", "source_ref", "source_status", + "rights_status", "rights_decision_ref", "payload_status", "payload_sha256", + "reviewer_id", "reviewer_decision", "reviewed_at", "supersedes_proof_id", + ) + with pytest.raises(Exception): + record.ticker = "AMD" + + +@pytest.mark.parametrize("header", [FIELDS[:-1], FIELDS + ("unexpected",), tuple(reversed(FIELDS))]) +def test_loaders_require_the_exact_ordered_header(tmp_path: Path, header: tuple[str, ...]): + ledger = tmp_path / "proofs.csv" + _write_csv(ledger, (_record(),), header=header) + + with pytest.raises(ValueError, match="header"): + load_field_proofs(ledger) + + +def test_missing_ledger_is_empty_but_present_empty_ledger_fails_closed(tmp_path: Path): + missing = tmp_path / "missing.csv" + empty = tmp_path / "empty.csv" + empty.touch() + + assert load_field_proofs(missing) == () + with pytest.raises(ValueError, match="header"): + load_field_proofs(empty) + + +def test_present_exact_header_zero_row_ledger_fails_closed(tmp_path: Path): + ledger = tmp_path / "proofs.csv" + _write_csv(ledger, ()) + + with pytest.raises(ValueError, match="at least one data row"): + load_field_proofs(ledger) + + +def test_non_regular_ledger_path_fails_closed_instead_of_looking_missing(tmp_path: Path): + ledger = tmp_path / "proofs.csv" + ledger.mkdir() + + with pytest.raises(ValueError, match="not a regular file"): + load_field_proofs(ledger) + + +@pytest.mark.parametrize("loader", [load_field_proofs, load_proposed_field_proofs]) +def test_loaders_reject_surplus_cells_beyond_the_exact_header( + tmp_path: Path, loader +): + ledger = tmp_path / "proofs.csv" + record = _record() + with ledger.open("w", newline="", encoding="utf-8") as handle: + writer = csv.writer(handle) + writer.writerow(FIELDS) + writer.writerow([getattr(record, field) for field in FIELDS] + ["surplus"]) + + with pytest.raises(ValueError, match="row 2: contains surplus cells"): + loader(ledger) + + +def test_loaders_normalize_scope_for_semantic_proof_identity(tmp_path: Path): + canonical = _record() + variant = _reidentified(canonical, ticker=" nvda ", field_key=" Revenue_Consensus ") + ledger = tmp_path / "proofs.csv" + _write_csv(ledger, (variant,)) + + assert field_proof_identity(variant) == field_proof_identity(canonical) + assert load_field_proofs(ledger) == (replace(variant, ticker="nvda", field_key="Revenue_Consensus"),) + + +@pytest.mark.parametrize( + "field", + [ + "schema_version", + "proof_id", + "ticker", + "field_key", + "readiness_contract_version", + "observed_at", + "retrieved_at", + "source_status", + "rights_status", + "payload_status", + "reviewer_decision", + "reviewed_at", + ], +) +def test_base_identity_scope_status_and_timestamps_remain_required( + tmp_path: Path, field: str +): + ledger = tmp_path / "proofs.csv" + record = ( + replace(_record(), proof_id="") + if field == "proof_id" + else _reidentified(_record(), **{field: ""}) + ) + _write_csv(ledger, (record,)) + + with pytest.raises(ValueError, match=rf"ledger row 2: .*{field}"): + load_field_proofs(ledger) + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("source_status", "pending"), + ("rights_status", "pending"), + ("payload_status", "pending"), + ("reviewer_decision", "pending"), + ], +) +def test_controlled_enums_fail_closed(tmp_path: Path, field: str, value: str): + ledger = tmp_path / "proofs.csv" + _write_csv(ledger, (_reidentified(_record(), **{field: value}),)) + + with pytest.raises(ValueError, match=rf"ledger row 2: .*{field}"): + load_field_proofs(ledger) + + +@pytest.mark.parametrize("digest", ["A" * 64, "a" * 63, "g" * 64]) +def test_payload_digest_must_be_lowercase_sha256(tmp_path: Path, digest: str): + ledger = tmp_path / "proofs.csv" + _write_csv(ledger, (_reidentified(_record(), payload_sha256=digest),)) + + with pytest.raises(ValueError, match="ledger row 2: .*payload_sha256"): + load_field_proofs(ledger) + + +def test_proof_id_must_equal_semantic_identity_and_identity_is_canonical(): + record = _record() + payload = { + field: getattr(record, field) + for field in FIELDS + if field not in {"proof_id", "supersedes_proof_id"} + } + assert field_proof_identity(record) == hashlib.sha256( + json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + + with pytest.raises(ValueError, match="proof_id must equal semantic identity"): + validate_field_proof_ledger((replace(record, proof_id="f" * 64),)) + + +@pytest.mark.parametrize( + ("field", "value", "message"), + [ + ("observed_at", "2026-07-18T05:00:00", "timezone-aware"), + ("retrieved_at", "invalid", "ISO-8601"), + ("retrieved_at", "2026-07-18T04:59:59Z", "observed_at cannot be after retrieved_at"), + ("reviewed_at", "2026-07-18T05:00:00Z", "retrieved_at cannot be after reviewed_at"), + ], +) +def test_timestamps_must_be_utc_aware_and_ordered_without_a_cutoff( + tmp_path: Path, field: str, value: str, message: str +): + ledger = tmp_path / "proofs.csv" + _write_csv(ledger, (_reidentified(_record(), **{field: value}),)) + + with pytest.raises(ValueError, match=rf"ledger row 2: .*{message}"): + load_field_proofs(ledger) + + +@pytest.mark.parametrize( + ("loader", "row_label"), + [ + (load_field_proofs, "ledger row 2"), + (load_proposed_field_proofs, "input row 2"), + ], +) +@pytest.mark.parametrize( + ("field", "value"), + [ + ("observed_at", "2026-07-18T06:00:00+01:00"), + ("retrieved_at", "2026-07-18T01:00:01-04:00"), + ("reviewed_at", "2026-07-18T10:30:02+05:30"), + ], +) +def test_ledger_and_input_rows_reject_nonzero_timezone_offsets( + tmp_path: Path, loader, row_label: str, field: str, value: str +): + path = tmp_path / "proofs.csv" + _write_csv(path, (_reidentified(_record(), **{field: value}),)) + + with pytest.raises(ValueError, match=rf"{row_label}: {field} must use UTC"): + loader(path) + + +@pytest.mark.parametrize("suffix", ["Z", "+00:00"]) +def test_timestamp_utc_zero_offset_forms_remain_valid(suffix: str): + record = _reidentified( + _record(), + observed_at=f"2026-07-18T05:00:00{suffix}", + retrieved_at=f"2026-07-18T05:00:01{suffix}", + reviewed_at=f"2026-07-18T05:00:02{suffix}", + ) + + assert validate_field_proof_ledger((record,)) is None + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("observed_at", "2026-07-18T06:00:00+01:00"), + ("retrieved_at", "2026-07-18T01:00:01-04:00"), + ("reviewed_at", "2026-07-18T10:30:02+05:30"), + ], +) +def test_preview_rejects_nonzero_offset_without_identity_or_ledger_write( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + field: str, + value: str, +): + ledger = tmp_path / "proofs.csv" + proposed = _reidentified(_record(), **{field: value}) + identity_calls = 0 + original_identity = field_proof_identity + + def identity_spy(record: ProspectiveFieldProofRecord) -> str: + nonlocal identity_calls + identity_calls += 1 + return original_identity(record) + + monkeypatch.setattr(field_proof, "field_proof_identity", identity_spy) + + preview = preview_field_proof_batch( + (), + (proposed,), + as_of="2026-07-20T00:00:00Z", + commercial_mode=False, + rights_registry=_rights_registry(), + ) + + assert preview.technical_write_eligible is False + assert preview.input_digest == _records_digest((proposed,)) + assert preview.preview_receipt + assert f"{field} must use UTC" in " ".join(preview.technical_blockers) + assert preview.rows[0].proof_identity == "" + assert identity_calls == 0 + assert preview.rows[0].commercial_blockers == ( + "record_rights_status:unverified", + ) + with pytest.raises(ValueError, match=rf"rejected_batch: .*{field} must use UTC"): + append_reviewed_field_proof_batch( + ledger, + (proposed,), + confirm_reviewed=True, + commercial_mode=False, + rights_registry=_rights_registry(), + review_cutoff=preview.review_cutoff, + preview_receipt=preview.preview_receipt, + ) + assert identity_calls == 0 + assert not ledger.exists() + + +def test_input_errors_are_row_numbered_and_loads_validate_proposed_rows(tmp_path: Path): + input_path = tmp_path / "input.csv" + _write_csv(input_path, (_record(), _reidentified(_record(), reviewer_decision="pending"))) + + with pytest.raises(ValueError, match="input row 3: reviewer_decision"): + load_proposed_field_proofs(input_path) + + +def test_accepted_record_requires_identified_source_reviewed_payload_and_non_placeholder_reviewer(): + accepted = _record() + for field, value in ( + ("source_status", "unavailable"), + ("source_id", ""), + ("source_ref", ""), + ("payload_status", "unavailable"), + ("payload_sha256", ""), + ("reviewer_id", ""), + ): + invalid = _reidentified(accepted, **{field: value}) + with pytest.raises(ValueError, match=field): + validate_field_proof_ledger((invalid,)) + + assert validate_field_proof_ledger((accepted,)) is None + + +@pytest.mark.parametrize( + ("reviewer_decision", "source_status", "payload_status"), + [ + ("rejected", "unavailable", "rejected"), + ("needs_follow_up", "disputed", "unavailable"), + ], +) +def test_unresolved_records_preserve_blank_optional_evidence_without_collapsing_states( + reviewer_decision: str, + source_status: str, + payload_status: str, +): + proposed = _reidentified( + _record(), + source_id="", + source_ref="", + source_status=source_status, + rights_status="unverified", + rights_decision_ref="", + payload_status=payload_status, + payload_sha256="", + reviewer_id="", + reviewer_decision=reviewer_decision, + ) + + preview = preview_field_proof_batch( + (), + (proposed,), + as_of="2026-07-20T00:00:00Z", + commercial_mode=False, + rights_registry=_rights_registry(), + ) + + assert preview.technical_write_eligible is True + assert preview.rows[0].state == "reviewable_new" + assert preview.commercial_evidence_eligible is False + assert preview.rows[0].commercial_evidence_eligible is False + assert f"row_1:reviewer_decision:{reviewer_decision}" in preview.commercial_blockers + assert f"row_1:source_status:{source_status}" in preview.commercial_blockers + assert f"row_1:payload_status:{payload_status}" in preview.commercial_blockers + assert "row_1:record_rights_status:unverified" in preview.commercial_blockers + assert "row_1:rights_decision_ref_required" in preview.commercial_blockers + + +def test_identified_source_and_reviewed_payload_require_their_own_evidence_identity(): + follow_up = _reidentified( + _record(), + reviewer_decision="needs_follow_up", + reviewer_id="", + ) + + for field in ("source_id", "source_ref", "payload_sha256"): + invalid = _reidentified(follow_up, **{field: ""}) + with pytest.raises(ValueError, match=field): + validate_field_proof_ledger((invalid,)) + + +def test_blank_rights_decision_reference_is_technical_evidence_but_not_commercial_evidence(): + proposed = _reidentified( + _record(), + rights_status="approved", + rights_decision_ref="", + ) + + preview = preview_field_proof_batch( + (), + (proposed,), + as_of="2026-07-20T00:00:00Z", + commercial_mode=False, + rights_registry=_rights_registry(), + ) + + assert preview.technical_write_eligible is True + assert preview.commercial_evidence_eligible is False + assert preview.commercial_blockers == ("row_1:rights_decision_ref_required",) + + +def test_ledger_rejects_duplicate_ids_and_semantic_identities(): + root = _record() + duplicate_identity = replace(root, proof_id=root.proof_id) + + with pytest.raises(ValueError, match="ledger row 3: duplicate proof_id"): + validate_field_proof_ledger((root, duplicate_identity)) + + +def test_ledger_accepts_one_linear_chain_per_independent_normalized_scope(): + root = _record() + child = _revision(root) + independent = _reidentified( + _record(ticker="amd", field_key="EPS_CONSENSUS", payload_sha256="c" * 64), + ticker=" amd ", field_key=" eps_consensus ", payload_sha256="c" * 64, + ) + + assert validate_field_proof_ledger((root, child, independent)) is None + + +def test_ledger_rejects_missing_and_cross_scope_parents(): + root = _record() + missing = _revision(root, supersedes_proof_id="f" * 64) + cross_scope = _revision(root, field_key="eps_consensus") + + with pytest.raises(ValueError, match="ledger row 3: missing parent proof"): + validate_field_proof_ledger((root, missing)) + with pytest.raises(ValueError, match="ledger row 3: revision parent must preserve normalized scope"): + validate_field_proof_ledger((root, cross_scope)) + + +def test_ledger_rejects_parent_after_child_and_non_monotonic_review_time(): + root = _record() + child = _revision(root) + parent_after_child = replace(root, proof_id="") + parent_after_child = replace(parent_after_child, proof_id=field_proof_identity(parent_after_child)) + child_for_future_parent = _revision(parent_after_child) + stale_time = _revision( + root, + observed_at=root.observed_at, + retrieved_at=root.retrieved_at, + reviewed_at=root.reviewed_at, + ) + + with pytest.raises(ValueError, match="ledger row 2: revision parent must appear earlier"): + validate_field_proof_ledger((child_for_future_parent, parent_after_child)) + with pytest.raises(ValueError, match="ledger row 3: reviewed_at must be strictly later"): + validate_field_proof_ledger((root, stale_time)) + + +def test_ledger_rejects_duplicate_root_fork_and_stale_leaf_revision(): + root = _record() + child = _revision(root) + duplicate_root = _reidentified(root, payload_sha256="c" * 64, supersedes_proof_id="") + fork = _revision(root, payload_sha256="d" * 64) + stale_leaf = _revision(root, payload_sha256="e" * 64, reviewed_at="2026-07-20T05:00:02Z") + + with pytest.raises(ValueError, match="exactly one root"): + validate_field_proof_ledger((root, duplicate_root)) + with pytest.raises(ValueError, match="revision fork"): + validate_field_proof_ledger((root, child, fork)) + with pytest.raises(ValueError, match="current leaf"): + validate_field_proof_ledger((root, child, stale_leaf)) + + +def test_ledger_rejects_cycle_and_disconnected_cycle(): + root = _record() + first = _revision(root) + cycle = _reidentified(first, payload_sha256="c" * 64) + cycle = replace(cycle, supersedes_proof_id=cycle.proof_id) + + with pytest.raises(ValueError, match="revision cycle"): + validate_field_proof_ledger((cycle,)) + with pytest.raises(ValueError, match="revision cycle"): + validate_field_proof_ledger((root, first, cycle)) + + +def test_preview_types_are_immutable_and_accepted_exact_scope_is_eligible(): + proposed = _reidentified(_record(), rights_status="approved") + + preview = preview_field_proof_batch( + (), + (proposed,), + as_of="2026-07-20T00:00:00Z", + commercial_mode=False, + rights_registry=_rights_registry(), + ) + + assert isinstance(preview, BatchFieldProofPreview) + assert isinstance(preview.rows[0], FieldProofPreview) + assert preview.technical_write_eligible is True + assert preview.commercial_evidence_eligible is True + assert preview.technical_blockers == () + assert preview.commercial_blockers == () + assert preview.rows[0].state == "reviewable_new" + assert preview.rows[0].proof_identity == proposed.proof_id + with pytest.raises(Exception): + preview.rows = () + + +@pytest.mark.parametrize( + ("reviewer_decision", "source_status", "payload_status", "expected_state"), + [ + ("accepted", "identified", "reviewed", "reviewable_new"), + ("rejected", "unavailable", "rejected", "reviewable_new"), + ("needs_follow_up", "disputed", "unavailable", "reviewable_new"), + ], +) +def test_review_dispositions_remain_technically_recordable_but_commercially_independent( + reviewer_decision: str, + source_status: str, + payload_status: str, + expected_state: str, +): + proposed = _reidentified( + _record(), + reviewer_decision=reviewer_decision, + source_status=source_status, + payload_status=payload_status, + rights_status="approved", + ) + + preview = preview_field_proof_batch( + (), + (proposed,), + as_of="2026-07-20T00:00:00Z", + commercial_mode=False, + rights_registry=_rights_registry(), + ) + + assert preview.rows[0].state == expected_state + assert preview.rows[0].technical_write_eligible is True + assert preview.technical_write_eligible is True + assert preview.rows[0].commercial_evidence_eligible is ( + reviewer_decision == "accepted" + ) + + +@pytest.mark.parametrize( + ("overrides", "technical_blocker"), + [ + ({"source_status": "unavailable"}, "accepted records require source_status=identified"), + ({"source_status": "disputed"}, "accepted records require source_status=identified"), + ({"payload_status": "unavailable"}, "accepted records require payload_status=reviewed"), + ({"payload_status": "rejected"}, "accepted records require payload_status=reviewed"), + ], +) +def test_accepted_record_with_unreviewable_source_or_payload_is_technically_rejected( + overrides: dict[str, str], technical_blocker: str +): + proposed = _reidentified(_record(), **overrides) + + preview = preview_field_proof_batch( + (), + (proposed,), + as_of="2026-07-20T00:00:00Z", + commercial_mode=False, + rights_registry=_rights_registry(), + ) + + assert preview.technical_write_eligible is False + assert technical_blocker in preview.technical_blockers[0] + + +@pytest.mark.parametrize( + ("record_overrides", "registry", "commercial_blocker"), + [ + ({"source_id": "unknown"}, _rights_registry(), "commercial_rights:unknown_source"), + ({}, _rights_registry(commercial_use="unverified"), "commercial_rights:commercial_rights_unverified"), + ({"field_key": "unsupported_field"}, _rights_registry(), "registered_field_scope_missing:unsupported_field"), + ({"rights_status": "unverified"}, _rights_registry(), "record_rights_status:unverified"), + ({"rights_decision_ref": ""}, _rights_registry(), "rights_decision_ref_required"), + ], +) +def test_commercial_eligibility_fails_closed_without_exact_approved_rights_scope( + record_overrides: dict[str, str], + registry: dict[str, SourceRights], + commercial_blocker: str, +): + proposed = _reidentified(_record(rights_status="approved"), **record_overrides) + + preview = preview_field_proof_batch( + (), + (proposed,), + as_of="2026-07-20T00:00:00Z", + commercial_mode=False, + rights_registry=registry, + ) + + assert preview.commercial_evidence_eligible is False + assert commercial_blocker in preview.commercial_blockers[0] + + +def test_research_mode_keeps_technical_recording_independent_from_commercial_eligibility(): + proposed = _record(rights_status="unverified") + + preview = preview_field_proof_batch( + (), + (proposed,), + as_of="2026-07-20T00:00:00Z", + commercial_mode=False, + rights_registry=_rights_registry(commercial_use="unverified"), + ) + + assert preview.technical_write_eligible is True + assert preview.commercial_evidence_eligible is False + assert preview.state == "reviewable_batch" + + +def test_batch_preview_uses_a_virtual_ledger_for_same_batch_revision(): + root = _reidentified(_record(), rights_status="approved") + child = _reidentified(_revision(root), rights_status="approved") + + preview = preview_field_proof_batch( + (), + (root, child), + as_of="2026-07-20T00:00:00Z", + commercial_mode=True, + rights_registry=_rights_registry(), + ) + + assert [row.state for row in preview.rows] == ["reviewable_new", "reviewable_revision"] + assert preview.technical_write_eligible is True + assert preview.commercial_evidence_eligible is True + + +def _records_digest(records: tuple[ProspectiveFieldProofRecord, ...]) -> str: + return hashlib.sha256( + json.dumps( + [asdict(record) for record in records], + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + ).hexdigest() + + +def _registry_digest(registry: dict[str, SourceRights]) -> str: + payload = [ + {"lookup_key": source_id, "rights": asdict(registry[source_id])} + for source_id in sorted(registry) + ] + return hashlib.sha256( + json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + + +def test_preview_receipt_is_deterministic_and_binds_every_review_input(): + existing = (_reidentified(_record(), rights_status="approved"),) + proposed = ( + _reidentified( + _record(ticker="AMD", payload_sha256="c" * 64), rights_status="approved" + ), + ) + registry = _rights_registry() + + first = preview_field_proof_batch( + existing, + proposed, + as_of="2026-07-20T00:00:00Z", + commercial_mode=False, + rights_registry=registry, + ) + second = preview_field_proof_batch( + existing, + proposed, + as_of="2026-07-20T00:00:00+00:00", + commercial_mode=False, + rights_registry=dict(reversed(tuple(registry.items()))), + ) + + expected_ledger_digest = _records_digest(existing) + expected_input_digest = _records_digest(proposed) + expected_registry_digest = _registry_digest(registry) + expected_receipt = hashlib.sha256( + json.dumps( + { + "schema_version": SCHEMA_VERSION, + "review_cutoff": "2026-07-20T00:00:00+00:00", + "commercial_mode": False, + "ledger_digest": expected_ledger_digest, + "input_digest": expected_input_digest, + "source_rights_registry_digest": expected_registry_digest, + }, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + ).hexdigest() + + assert first.ledger_digest == expected_ledger_digest + assert first.input_digest == expected_input_digest + assert first.source_rights_registry_digest == expected_registry_digest + assert first.preview_receipt == expected_receipt + assert second.preview_receipt == expected_receipt + + +def test_preview_receipt_changes_with_input_cutoff_mode_or_registry(): + proposed = _reidentified(_record(), rights_status="approved") + registry = _rights_registry() + baseline = preview_field_proof_batch( + (), + (proposed,), + as_of="2026-07-20T00:00:00Z", + commercial_mode=False, + rights_registry=registry, + ) + + changed_input = preview_field_proof_batch( + (), + (_reidentified(proposed, payload_sha256="d" * 64),), + as_of="2026-07-20T00:00:00Z", + commercial_mode=False, + rights_registry=registry, + ) + changed_cutoff = preview_field_proof_batch( + (), + (proposed,), + as_of="2026-07-21T00:00:00Z", + commercial_mode=False, + rights_registry=registry, + ) + changed_mode = preview_field_proof_batch( + (), + (proposed,), + as_of="2026-07-20T00:00:00Z", + commercial_mode=True, + rights_registry=registry, + ) + changed_registry = preview_field_proof_batch( + (), + (proposed,), + as_of="2026-07-20T00:00:00Z", + commercial_mode=False, + rights_registry=_rights_registry(supported_fields=("eps_consensus",)), + ) + + assert len( + { + baseline.preview_receipt, + changed_input.preview_receipt, + changed_cutoff.preview_receipt, + changed_mode.preview_receipt, + changed_registry.preview_receipt, + } + ) == 5 + + +def test_registry_digest_is_order_independent_and_binds_lookup_keys(): + proposed = _reidentified(_record(), rights_status="approved") + reviewed = _rights_registry()["reviewed_csv"] + second = replace( + reviewed, + source_id="second_source", + display_name="Second Source", + fallback_priority=2, + ) + registry = {"second_source": second, "reviewed_csv": reviewed} + reordered = dict(reversed(tuple(registry.items()))) + renamed = {"second_source": second, "renamed_lookup": reviewed} + + baseline = preview_field_proof_batch( + (), + (proposed,), + as_of="2026-07-20T00:00:00Z", + commercial_mode=False, + rights_registry=registry, + ) + reordered_preview = preview_field_proof_batch( + (), + (proposed,), + as_of="2026-07-20T00:00:00Z", + commercial_mode=False, + rights_registry=reordered, + ) + renamed_preview = preview_field_proof_batch( + (), + (proposed,), + as_of="2026-07-20T00:00:00Z", + commercial_mode=False, + rights_registry=renamed, + ) + + assert baseline.source_rights_registry_digest == _registry_digest(registry) + assert reordered_preview.source_rights_registry_digest == baseline.source_rights_registry_digest + assert reordered_preview.preview_receipt == baseline.preview_receipt + assert renamed_preview.source_rights_registry_digest != baseline.source_rights_registry_digest + assert renamed_preview.preview_receipt != baseline.preview_receipt + + +def test_append_requires_confirmation_receipt_and_a_nonempty_batch(tmp_path: Path): + ledger = tmp_path / "proofs.csv" + proposed = _reidentified(_record(), rights_status="approved") + registry = _rights_registry() + preview = preview_field_proof_batch( + (), + (proposed,), + as_of="2026-07-20T00:00:00Z", + commercial_mode=False, + rights_registry=registry, + ) + + with pytest.raises(ValueError, match="confirm_reviewed"): + append_reviewed_field_proof_batch( + ledger, + (proposed,), + confirm_reviewed=False, + commercial_mode=False, + rights_registry=registry, + review_cutoff=preview.review_cutoff, + preview_receipt=preview.preview_receipt, + ) + with pytest.raises(ValueError, match="preview_receipt"): + append_reviewed_field_proof_batch( + ledger, + (proposed,), + confirm_reviewed=True, + commercial_mode=False, + rights_registry=registry, + review_cutoff=preview.review_cutoff, + preview_receipt=None, + ) + + empty = preview_field_proof_batch( + (), + (), + as_of="2026-07-20T00:00:00Z", + commercial_mode=False, + rights_registry=registry, + ) + with pytest.raises(ValueError, match="empty_batch"): + append_reviewed_field_proof_batch( + ledger, + (), + confirm_reviewed=True, + commercial_mode=False, + rights_registry=registry, + review_cutoff=empty.review_cutoff, + preview_receipt=empty.preview_receipt, + ) + assert not ledger.exists() + + +def test_append_rejects_stale_receipt_after_ledger_change_without_writing(tmp_path: Path): + ledger = tmp_path / "proofs.csv" + proposed = _reidentified( + _record(ticker="AMD", payload_sha256="c" * 64), rights_status="approved" + ) + registry = _rights_registry() + preview = preview_field_proof_batch( + (), + (proposed,), + as_of="2026-07-20T00:00:00Z", + commercial_mode=False, + rights_registry=registry, + ) + intervening = _reidentified(_record(), rights_status="approved") + _write_csv(ledger, (intervening,)) + before = ledger.read_bytes() + + with pytest.raises(ValueError, match="preview receipt mismatch"): + append_reviewed_field_proof_batch( + ledger, + (proposed,), + confirm_reviewed=True, + commercial_mode=False, + rights_registry=registry, + review_cutoff=preview.review_cutoff, + preview_receipt=preview.preview_receipt, + ) + + assert ledger.read_bytes() == before + + +@pytest.mark.parametrize("change", ["input", "cutoff", "mode", "registry"]) +def test_append_rejects_receipt_after_any_other_bound_input_changes( + tmp_path: Path, change: str +): + ledger = tmp_path / "proofs.csv" + proposed = _reidentified(_record(), rights_status="approved") + records = (proposed,) + cutoff = "2026-07-20T00:00:00Z" + commercial_mode = False + registry = _rights_registry() + preview = preview_field_proof_batch( + (), + records, + as_of=cutoff, + commercial_mode=commercial_mode, + rights_registry=registry, + ) + if change == "input": + records = (_reidentified(proposed, payload_sha256="d" * 64),) + elif change == "cutoff": + cutoff = "2026-07-21T00:00:00Z" + elif change == "mode": + commercial_mode = True + else: + registry = _rights_registry(supported_fields=("eps_consensus",)) + + with pytest.raises(ValueError, match="preview receipt mismatch"): + append_reviewed_field_proof_batch( + ledger, + records, + confirm_reviewed=True, + commercial_mode=commercial_mode, + rights_registry=registry, + review_cutoff=cutoff, + preview_receipt=preview.preview_receipt, + ) + assert not ledger.exists() + + +def test_mixed_validity_batch_is_all_or_nothing(tmp_path: Path): + ledger = tmp_path / "proofs.csv" + valid = _reidentified(_record(), rights_status="approved") + invalid = _reidentified( + _record(ticker="AMD", payload_sha256="c" * 64), + rights_status="approved", + source_status="unavailable", + ) + registry = _rights_registry() + preview = preview_field_proof_batch( + (), + (valid, invalid), + as_of="2026-07-20T00:00:00Z", + commercial_mode=False, + rights_registry=registry, + ) + + assert preview.technical_write_eligible is False + with pytest.raises(ValueError, match="rejected_batch"): + append_reviewed_field_proof_batch( + ledger, + (valid, invalid), + confirm_reviewed=True, + commercial_mode=False, + rights_registry=registry, + review_cutoff=preview.review_cutoff, + preview_receipt=preview.preview_receipt, + ) + assert not ledger.exists() + + +def test_append_writes_one_header_and_rows_once_in_reviewed_order(tmp_path: Path): + ledger = tmp_path / "proofs.csv" + root = _reidentified(_record(), rights_status="approved") + child = _reidentified(_revision(root), rights_status="approved") + registry = _rights_registry() + preview = preview_field_proof_batch( + (), + (root, child), + as_of="2026-07-20T00:00:00Z", + commercial_mode=True, + rights_registry=registry, + ) + + result = append_reviewed_field_proof_batch( + ledger, + (root, child), + confirm_reviewed=True, + commercial_mode=True, + rights_registry=registry, + review_cutoff=preview.review_cutoff, + preview_receipt=preview.preview_receipt, + ) + + assert result == ledger + assert load_field_proofs(ledger) == (root, child) + assert ledger.read_text(encoding="utf-8").count(",".join(FIELDS)) == 1 + + prior_bytes = ledger.read_bytes() + grandchild = _reidentified( + _revision( + child, + observed_at="2026-07-20T05:00:00Z", + retrieved_at="2026-07-20T05:00:01Z", + reviewed_at="2026-07-20T05:00:02Z", + payload_sha256="c" * 64, + ), + rights_status="approved", + ) + revision_preview = preview_field_proof_batch( + (root, child), + (grandchild,), + as_of="2026-07-21T00:00:00Z", + commercial_mode=True, + rights_registry=registry, + ) + append_reviewed_field_proof_batch( + ledger, + (grandchild,), + confirm_reviewed=True, + commercial_mode=True, + rights_registry=registry, + review_cutoff=revision_preview.review_cutoff, + preview_receipt=revision_preview.preview_receipt, + ) + + assert ledger.read_bytes().startswith(prior_bytes) + assert load_field_proofs(ledger) == (root, child, grandchild) + assert ledger.read_text(encoding="utf-8").count(",".join(FIELDS)) == 1 + + +def test_append_adds_a_delimiter_after_valid_ledger_without_terminal_newline( + tmp_path: Path, +): + ledger = tmp_path / "proofs.csv" + root = _reidentified(_record(), rights_status="approved") + child = _reidentified(_revision(root), rights_status="approved") + registry = _rights_registry() + _write_csv(ledger, (root,)) + prior_bytes = ledger.read_bytes().rstrip(b"\r\n") + ledger.write_bytes(prior_bytes) + preview = preview_field_proof_batch( + (root,), + (child,), + as_of="2026-07-20T00:00:00Z", + commercial_mode=True, + rights_registry=registry, + ) + + append_reviewed_field_proof_batch( + ledger, + (child,), + confirm_reviewed=True, + commercial_mode=True, + rights_registry=registry, + review_cutoff=preview.review_cutoff, + preview_receipt=preview.preview_receipt, + ) + + appended = ledger.read_bytes() + assert appended.startswith(prior_bytes) + assert appended[len(prior_bytes) :].startswith(b"\n") + assert load_field_proofs(ledger) == (root, child) + + +def test_existing_ledger_is_locked_while_receipt_is_revalidated( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + ledger = tmp_path / "proofs.csv" + root = _reidentified(_record(), rights_status="approved") + child = _reidentified(_revision(root), rights_status="approved") + registry = _rights_registry() + _write_csv(ledger, (root,)) + preview = preview_field_proof_batch( + (root,), + (child,), + as_of="2026-07-20T00:00:00Z", + commercial_mode=True, + rights_registry=registry, + ) + original_preview = field_proof.preview_field_proof_batch + lock_observed = False + + def preview_while_probing_lock(*args, **kwargs): + nonlocal lock_observed + with ledger.open("r+b") as probe: + with pytest.raises(BlockingIOError): + fcntl.flock(probe.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + lock_observed = True + return original_preview(*args, **kwargs) + + monkeypatch.setattr(field_proof, "preview_field_proof_batch", preview_while_probing_lock) + + append_reviewed_field_proof_batch( + ledger, + (child,), + confirm_reviewed=True, + commercial_mode=True, + rights_registry=registry, + review_cutoff=preview.review_cutoff, + preview_receipt=preview.preview_receipt, + ) + + assert lock_observed is True + + +def test_absent_ledger_uses_exclusive_creation(tmp_path: Path): + ledger = tmp_path / "proofs.csv" + opener = getattr(field_proof, "_open_new_ledger_exclusive", None) + + assert opener is not None + with opener(ledger) as first: + assert first.fileno() >= 0 + with pytest.raises(FileExistsError): + opener(ledger) + + +def test_encoding_failure_occurs_before_missing_destination_is_created( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + ledger = tmp_path / "proofs.csv" + proposed = _reidentified(_record(), rights_status="approved") + registry = _rights_registry() + preview = preview_field_proof_batch( + (), + (proposed,), + as_of="2026-07-20T00:00:00Z", + commercial_mode=True, + rights_registry=registry, + ) + + def fail_encoding(*args, **kwargs): + raise UnicodeError("injected encoding failure") + + monkeypatch.setattr( + field_proof, "_encode_append_payload", fail_encoding, raising=False + ) + + with pytest.raises(UnicodeError, match="injected encoding failure"): + append_reviewed_field_proof_batch( + ledger, + (proposed,), + confirm_reviewed=True, + commercial_mode=True, + rights_registry=registry, + review_cutoff=preview.review_cutoff, + preview_receipt=preview.preview_receipt, + ) + + assert not ledger.exists() + + +def test_partial_write_failure_removes_the_missing_ledger_it_created( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + ledger = tmp_path / "proofs.csv" + proposed = _reidentified(_record(), rights_status="approved") + registry = _rights_registry() + preview = preview_field_proof_batch( + (), + (proposed,), + as_of="2026-07-20T00:00:00Z", + commercial_mode=True, + rights_registry=registry, + ) + + def write_part_then_fail(handle, payload: bytes): + handle.write(payload[: max(1, len(payload) // 2)]) + raise OSError("injected new-ledger partial write failure") + + monkeypatch.setattr(field_proof, "_write_append_payload", write_part_then_fail) + + with pytest.raises(OSError, match="injected new-ledger partial write failure"): + append_reviewed_field_proof_batch( + ledger, + (proposed,), + confirm_reviewed=True, + commercial_mode=True, + rights_registry=registry, + review_cutoff=preview.review_cutoff, + preview_receipt=preview.preview_receipt, + ) + + assert not ledger.exists() + + +def test_first_flush_failure_removes_the_missing_ledger_after_rollback_sync( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + ledger = tmp_path / "proofs.csv" + proposed = _reidentified(_record(), rights_status="approved") + registry = _rights_registry() + preview = preview_field_proof_batch( + (), + (proposed,), + as_of="2026-07-20T00:00:00Z", + commercial_mode=True, + rights_registry=registry, + ) + original_flush = field_proof._flush_and_fsync + calls = 0 + + def fail_once_then_sync(handle): + nonlocal calls + calls += 1 + if calls == 1: + handle.flush() + raise OSError("injected new-ledger flush failure") + original_flush(handle) + + monkeypatch.setattr(field_proof, "_flush_and_fsync", fail_once_then_sync) + + with pytest.raises(OSError, match="injected new-ledger flush failure"): + append_reviewed_field_proof_batch( + ledger, + (proposed,), + confirm_reviewed=True, + commercial_mode=True, + rights_registry=registry, + review_cutoff=preview.review_cutoff, + preview_receipt=preview.preview_receipt, + ) + + assert calls == 2 + assert not ledger.exists() + + +def test_failed_new_ledger_cleanup_preserves_a_concurrently_replaced_path( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + ledger = tmp_path / "proofs.csv" + proposed = _reidentified(_record(), rights_status="approved") + registry = _rights_registry() + preview = preview_field_proof_batch( + (), + (proposed,), + as_of="2026-07-20T00:00:00Z", + commercial_mode=True, + rights_registry=registry, + ) + replacement = b"concurrent replacement" + + def replace_path_then_fail(handle, payload: bytes): + handle.write(payload[:1]) + ledger.unlink() + ledger.write_bytes(replacement) + raise OSError("injected write failure after replacement") + + monkeypatch.setattr(field_proof, "_write_append_payload", replace_path_then_fail) + + with pytest.raises(OSError, match="injected write failure after replacement"): + append_reviewed_field_proof_batch( + ledger, + (proposed,), + confirm_reviewed=True, + commercial_mode=True, + rights_registry=registry, + review_cutoff=preview.review_cutoff, + preview_receipt=preview.preview_receipt, + ) + + assert ledger.read_bytes() == replacement + + +def test_partial_write_failure_rolls_existing_ledger_back_to_exact_prior_bytes( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + ledger = tmp_path / "proofs.csv" + root = _reidentified(_record(), rights_status="approved") + child = _reidentified(_revision(root), rights_status="approved") + registry = _rights_registry() + _write_csv(ledger, (root,)) + prior_bytes = ledger.read_bytes() + preview = preview_field_proof_batch( + (root,), + (child,), + as_of="2026-07-20T00:00:00Z", + commercial_mode=True, + rights_registry=registry, + ) + + def write_part_then_fail(handle, payload: bytes): + handle.write(payload[: max(1, len(payload) // 2)]) + raise OSError("injected partial write failure") + + monkeypatch.setattr( + field_proof, "_write_append_payload", write_part_then_fail, raising=False + ) + + with pytest.raises(OSError, match="injected partial write failure"): + append_reviewed_field_proof_batch( + ledger, + (child,), + confirm_reviewed=True, + commercial_mode=True, + rights_registry=registry, + review_cutoff=preview.review_cutoff, + preview_receipt=preview.preview_receipt, + ) + + assert ledger.read_bytes() == prior_bytes + assert load_field_proofs(ledger) == (root,) + + +def test_flush_failure_rolls_existing_ledger_back_and_syncs_the_rollback( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + ledger = tmp_path / "proofs.csv" + root = _reidentified(_record(), rights_status="approved") + child = _reidentified(_revision(root), rights_status="approved") + registry = _rights_registry() + _write_csv(ledger, (root,)) + prior_bytes = ledger.read_bytes() + preview = preview_field_proof_batch( + (root,), + (child,), + as_of="2026-07-20T00:00:00Z", + commercial_mode=True, + rights_registry=registry, + ) + original_flush = getattr(field_proof, "_flush_and_fsync", None) + calls = 0 + + def fail_once_then_sync(handle): + nonlocal calls + calls += 1 + if calls == 1: + handle.flush() + raise OSError("injected flush failure") + assert original_flush is not None + original_flush(handle) + + monkeypatch.setattr( + field_proof, "_flush_and_fsync", fail_once_then_sync, raising=False + ) + + with pytest.raises(OSError, match="injected flush failure"): + append_reviewed_field_proof_batch( + ledger, + (child,), + confirm_reviewed=True, + commercial_mode=True, + rights_registry=registry, + review_cutoff=preview.review_cutoff, + preview_receipt=preview.preview_receipt, + ) + + assert calls == 2 + assert ledger.read_bytes() == prior_bytes + assert load_field_proofs(ledger) == (root,) + + +def test_research_append_can_record_technical_proof_while_commercial_mode_fails_closed( + tmp_path: Path, +): + proposed = _record(rights_status="unverified") + registry = _rights_registry(commercial_use="unverified") + research_ledger = tmp_path / "research.csv" + commercial_ledger = tmp_path / "commercial.csv" + research_preview = preview_field_proof_batch( + (), + (proposed,), + as_of="2026-07-20T00:00:00Z", + commercial_mode=False, + rights_registry=registry, + ) + commercial_preview = preview_field_proof_batch( + (), + (proposed,), + as_of="2026-07-20T00:00:00Z", + commercial_mode=True, + rights_registry=registry, + ) + + append_reviewed_field_proof_batch( + research_ledger, + (proposed,), + confirm_reviewed=True, + commercial_mode=False, + rights_registry=registry, + review_cutoff=research_preview.review_cutoff, + preview_receipt=research_preview.preview_receipt, + ) + with pytest.raises(ValueError, match="batch_commercial_evidence_review_required"): + append_reviewed_field_proof_batch( + commercial_ledger, + (proposed,), + confirm_reviewed=True, + commercial_mode=True, + rights_registry=registry, + review_cutoff=commercial_preview.review_cutoff, + preview_receipt=commercial_preview.preview_receipt, + ) + + assert load_field_proofs(research_ledger) == (proposed,) + assert not commercial_ledger.exists() + + +def test_status_reports_absent_as_the_only_valid_empty_state_without_creating_files( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +): + ledger = tmp_path / "missing" / "proofs.csv" + + exit_code = field_proof.main( + ["status", "--ledger", str(ledger), "--json"] + ) + payload = json.loads(capsys.readouterr().out) + + assert exit_code == 0 + assert payload == { + "empty": True, + "ledger": str(ledger), + "ledger_present": False, + "mode": "status_read_only", + "record_count": 0, + "state": "absent", + "valid": True, + "write_performed": False, + } + assert not ledger.exists() + assert not ledger.parent.exists() + + +@pytest.mark.parametrize("contents", [b"", (",".join(FIELDS) + "\n").encode("utf-8")]) +def test_status_catches_present_empty_ledger_as_invalid_without_changing_it( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + contents: bytes, +): + ledger = tmp_path / "proofs.csv" + ledger.write_bytes(contents) + + exit_code = field_proof.main( + ["status", "--ledger", str(ledger), "--json"] + ) + payload = json.loads(capsys.readouterr().out) + + assert exit_code == 2 + assert payload["state"] == "invalid" + assert payload["valid"] is False + assert payload["empty"] is True + assert payload["ledger_present"] is True + assert payload["write_performed"] is False + assert "header" in payload["error"].lower() or "data row" in payload["error"].lower() + assert ledger.read_bytes() == contents + + +def test_status_reports_valid_and_invalid_existing_ledgers_in_text_without_writing( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +): + valid_ledger = tmp_path / "valid.csv" + invalid_ledger = tmp_path / "invalid.csv" + _write_csv(valid_ledger, (_record(),)) + invalid_ledger.write_text("not,the,contract\n", encoding="utf-8") + before = _file_snapshot(tmp_path) + + valid_exit = field_proof.main(["status", "--ledger", str(valid_ledger)]) + valid_output = capsys.readouterr().out.lower() + invalid_exit = field_proof.main(["status", "--ledger", str(invalid_ledger)]) + invalid_output = capsys.readouterr().out.lower() + + assert valid_exit == 0 + assert "read-only" in valid_output + assert "state: valid" in valid_output + assert "record_count: 1" in valid_output + assert invalid_exit == 2 + assert "read-only" in invalid_output + assert "state: invalid" in invalid_output + assert "header" in invalid_output + assert _file_snapshot(tmp_path) == before + + +def test_audit_absent_ledger_is_valid_empty_and_read_only(tmp_path: Path): + ledger = tmp_path / "missing.csv" + before = _file_snapshot(tmp_path) + + audit = field_proof.audit_field_proof_ledger( + ledger, + rights_registry=_rights_registry(), + ) + + assert audit.mode == "audit_read_only" + assert audit.state == "absent" + assert audit.valid is True + assert audit.write_performed is False + assert audit.record_count == audit.scope_count == audit.active_head_count == 0 + assert audit.rows == () + assert audit.preview_receipt_persisted is False + assert audit.receipt_revalidation_required is True + assert _file_snapshot(tmp_path) == before + + +def test_audit_explains_append_history_active_heads_and_current_blockers(tmp_path: Path): + ledger = tmp_path / "proofs.csv" + root = _record() + revision = _revision( + root, + reviewer_decision="needs_follow_up", + source_status="disputed", + payload_status="unavailable", + payload_sha256="", + ) + _write_csv(ledger, (root, revision)) + before = _file_snapshot(tmp_path) + + audit = field_proof.audit_field_proof_ledger( + ledger, + rights_registry=_rights_registry(commercial_use="unverified"), + ) + + assert audit.state == "valid" + assert audit.record_count == 2 + assert audit.scope_count == audit.active_head_count == 1 + assert audit.superseded_count == 1 + assert audit.accepted_count == 1 + assert audit.needs_follow_up_count == 1 + assert audit.latest_reviewed_at == revision.reviewed_at + assert [row.revision_number for row in audit.rows] == [1, 2] + assert [row.history_state for row in audit.rows] == ["superseded", "current"] + assert audit.rows[1].supersedes_proof_id == root.proof_id + assert any("reviewer_decision:needs_follow_up" in item for item in audit.current_blockers) + assert any("source_status:disputed" in item for item in audit.current_blockers) + assert _file_snapshot(tmp_path) == before + + +def test_audit_cli_json_is_stable_and_invalid_ledger_fails_closed_without_writing( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +): + valid = tmp_path / "valid.csv" + invalid = tmp_path / "invalid.csv" + _write_csv(valid, (_record(),)) + invalid.write_text("not,the,contract\n", encoding="utf-8") + before = _file_snapshot(tmp_path) + + first_exit = field_proof.main(["audit", "--ledger", str(valid), "--json"]) + first = capsys.readouterr().out + second_exit = field_proof.main(["audit", "--ledger", str(valid), "--json"]) + second = capsys.readouterr().out + invalid_exit = field_proof.main(["audit", "--ledger", str(invalid)]) + invalid_output = capsys.readouterr().out.lower() + + payload = json.loads(first) + assert first_exit == second_exit == 0 + assert first == second + assert payload["mode"] == "audit_read_only" + assert payload["rows"][0]["history_state"] == "current" + assert invalid_exit == 2 + assert "state: invalid" in invalid_output + assert "header" in invalid_output + assert "traceback" not in invalid_output + assert _file_snapshot(tmp_path) == before + + +@pytest.mark.parametrize("oversized", [False, True]) +def test_loader_converts_malformed_or_oversized_csv_errors_to_controlled_value_error( + tmp_path: Path, oversized: bool +): + ledger = tmp_path / "ledger.csv" + _write_csv_parse_failure(ledger, oversized=oversized) + + with pytest.raises(ValueError, match="field proof CSV parse error"): + load_field_proofs(ledger) + + +@pytest.mark.parametrize("oversized", [False, True]) +def test_status_reports_csv_parse_errors_nonzero_without_traceback_or_mutation( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + oversized: bool, +): + ledger = tmp_path / "ledger.csv" + _write_csv_parse_failure(ledger, oversized=oversized) + before = ledger.read_bytes() + + exit_code = field_proof.main(["status", "--ledger", str(ledger)]) + captured = capsys.readouterr() + + assert exit_code == 2 + assert "field proof csv parse error" in captured.out.lower() + assert "traceback" not in (captured.out + captured.err).lower() + assert ledger.read_bytes() == before + + +@pytest.mark.parametrize("command", ["preview", "record"]) +@pytest.mark.parametrize("oversized", [False, True]) +def test_preview_and_record_report_input_csv_parse_errors_nonzero_without_writing( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + command: str, + oversized: bool, +): + input_path = tmp_path / "input.csv" + ledger = tmp_path / "ledger.csv" + _write_csv_parse_failure(input_path, oversized=oversized) + before = input_path.read_bytes() + monkeypatch.setenv("COMMERCIAL_RESEARCH_MODE", "research") + args = [ + command, + "--input", + str(input_path), + "--ledger", + str(ledger), + "--as-of", + "2026-07-20T00:00:00Z", + ] + if command == "record": + args.extend( + ["--preview-receipt", "e" * 64, "--confirm-reviewed"] + ) + + exit_code = field_proof.main(args) + captured = capsys.readouterr() + + assert exit_code == 2 + assert "field proof csv parse error" in captured.err.lower() + assert "traceback" not in (captured.out + captured.err).lower() + assert input_path.read_bytes() == before + assert not ledger.exists() + + +def test_preview_json_is_stable_and_does_not_mutate_any_scoped_file_or_default_ledger( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +): + input_path = tmp_path / "review" / "field-proofs.csv" + _write_csv(input_path, (_record(),)) + sentinels = ( + tmp_path / "data" / "readiness.csv", + tmp_path / "data" / "canonical.csv", + tmp_path / "data" / "reviewed_data_proof.csv", + tmp_path / "outputs" / "field-proof-preview.json", + tmp_path / "generated" / "field-proof-report.csv", + ) + for index, path in enumerate(sentinels): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(f"sentinel-{index}".encode("utf-8")) + before = _file_snapshot(tmp_path) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("COMMERCIAL_RESEARCH_MODE", "research") + args = [ + "preview", + "--input", + str(input_path), + "--as-of", + "2026-07-20T00:00:00Z", + "--json", + ] + + first_exit = field_proof.main(args) + first_output = capsys.readouterr().out + second_exit = field_proof.main(args) + second_output = capsys.readouterr().out + payload = json.loads(first_output) + + assert first_exit == second_exit == 0 + assert first_output == second_output + assert payload["mode"] == "preview_only" + assert payload["write_performed"] is False + assert payload["preview_receipt"] + assert payload["technical_write_eligible"] is True + assert isinstance(payload["commercial_evidence_eligible"], bool) + assert isinstance(payload["technical_blockers"], list) + assert isinstance(payload["commercial_blockers"], list) + assert not (tmp_path / "data" / "prospective_field_proofs.csv").exists() + assert _file_snapshot(tmp_path) == before + + +def test_preview_text_states_read_only_boundary_and_receipt( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +): + input_path = tmp_path / "input.csv" + ledger = tmp_path / "missing.csv" + _write_csv(input_path, (_record(),)) + monkeypatch.setenv("COMMERCIAL_RESEARCH_MODE", "research") + + exit_code = field_proof.main( + [ + "preview", + "--input", + str(input_path), + "--ledger", + str(ledger), + "--as-of", + "2026-07-20T00:00:00Z", + ] + ) + output = capsys.readouterr().out.lower() + + assert exit_code == 0 + assert "read-only preview" in output + assert "write_performed: false" in output + assert "technical_write_eligible: true" in output + assert "commercial_evidence_eligible:" in output + assert "preview_receipt:" in output + assert "receipt binds: ledger, input, review cutoff, commercial mode, and source-rights registry" in output + assert "preview receipt persisted: false" in output + assert "row 1: reviewable_new" in output + assert "technical=true" in output + assert "commercial=" in output + assert not ledger.exists() + + +def test_preview_reports_specific_input_and_existing_ledger_errors_nonzero_without_writing( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +): + input_path = tmp_path / "input.csv" + ledger = tmp_path / "ledger.csv" + _write_csv(input_path, (_record(),)) + ledger.write_bytes(b"") + before = _file_snapshot(tmp_path) + monkeypatch.setenv("COMMERCIAL_RESEARCH_MODE", "research") + + ledger_exit = field_proof.main( + [ + "preview", + "--input", + str(input_path), + "--ledger", + str(ledger), + "--as-of", + "2026-07-20T00:00:00Z", + ] + ) + ledger_error = capsys.readouterr().err.lower() + missing_exit = field_proof.main( + [ + "preview", + "--input", + str(tmp_path / "missing-input.csv"), + "--ledger", + str(tmp_path / "missing-ledger.csv"), + "--as-of", + "2026-07-20T00:00:00Z", + ] + ) + input_error = capsys.readouterr().err.lower() + + assert ledger_exit == 2 + assert "field proof ledger header" in ledger_error + assert missing_exit == 2 + assert "field proof input does not exist" in input_error + assert "traceback" not in ledger_error + input_error + assert _file_snapshot(tmp_path) == before + + +def test_record_cli_requires_confirmation_and_receipt_before_any_write( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +): + input_path = tmp_path / "input.csv" + ledger = tmp_path / "ledger.csv" + _write_csv(input_path, (_record(),)) + monkeypatch.setenv("COMMERCIAL_RESEARCH_MODE", "research") + common = [ + "record", + "--input", + str(input_path), + "--ledger", + str(ledger), + "--as-of", + "2026-07-20T00:00:00Z", + ] + + without_confirmation = field_proof.main( + [*common, "--preview-receipt", "a" * 64] + ) + confirmation_error = capsys.readouterr().err.lower() + with pytest.raises(SystemExit) as missing_receipt: + field_proof.main([*common, "--confirm-reviewed"]) + receipt_error = capsys.readouterr().err.lower() + + assert without_confirmation == 2 + assert "record requires --confirm-reviewed" in confirmation_error + assert missing_receipt.value.code != 0 + assert "--preview-receipt" in receipt_error + assert not ledger.exists() + + +def test_record_cli_revalidates_exact_preview_then_reports_explicit_append( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +): + input_path = tmp_path / "input.csv" + ledger = tmp_path / "ledger.csv" + proposed = _record() + _write_csv(input_path, (proposed,)) + monkeypatch.setenv("COMMERCIAL_RESEARCH_MODE", "research") + preview_args = [ + "preview", + "--input", + str(input_path), + "--ledger", + str(ledger), + "--as-of", + "2026-07-20T00:00:00Z", + "--json", + ] + + assert field_proof.main(preview_args) == 0 + receipt = json.loads(capsys.readouterr().out)["preview_receipt"] + exit_code = field_proof.main( + [ + "record", + "--input", + str(input_path), + "--ledger", + str(ledger), + "--as-of", + "2026-07-20T00:00:00Z", + "--preview-receipt", + receipt, + "--confirm-reviewed", + "--json", + ] + ) + payload = json.loads(capsys.readouterr().out) + + assert exit_code == 0 + assert payload == { + "ledger": str(ledger), + "mode": "explicit_record_append", + "preview_receipt": receipt, + "recorded_count": 1, + "state": "recorded", + "write_performed": True, + } + assert load_field_proofs(ledger) == (proposed,) diff --git a/tests/test_public_performance_gate.py b/tests/test_public_performance_gate.py index ab4ce009..0abba70d 100644 --- a/tests/test_public_performance_gate.py +++ b/tests/test_public_performance_gate.py @@ -25,6 +25,62 @@ def test_default_performance_contract_covers_the_guided_public_workflow(): assert all(route.full_markers for route in DEFAULT_ROUTE_SPECS) +def test_research_performance_contract_covers_the_commercial_beta_workflow(): + from src.public_performance_gate import RESEARCH_ROUTE_SPECS + + assert [route.name for route in RESEARCH_ROUTE_SPECS] == [ + "Research Desk", + "Discover", + "Company Workbench", + "Monitor", + ] + assert all(route.critical for route in RESEARCH_ROUTE_SPECS) + assert RESEARCH_ROUTE_SPECS[0].first_useful_marker == "Weekly research summary" + assert RESEARCH_ROUTE_SPECS[0].full_markers == ( + "Weekly research summary", + "What should I review next?", + "Open Discover", + "Advanced Evidence", + "Research-only", + ) + assert RESEARCH_ROUTE_SPECS[1].first_useful_marker == "Which stock can I review?" + assert RESEARCH_ROUTE_SPECS[1].full_markers == ( + "Discover", + "Search this review queue", + "Advanced: cohort readiness context", + "Research-only", + ) + assert RESEARCH_ROUTE_SPECS[2].full_markers == ( + "Company Workbench", + "Advanced: selected-company lane coverage", + "What Changed", + "Research Decision Lab", + "Business Trend", + "Valuation", + "Forward View", + "What Remains Withheld", + "Research Conclusion", + "Next Research Task", + "Research-only", + ) + assert RESEARCH_ROUTE_SPECS[3].first_useful_marker == "WEEKLY RESEARCH SUMMARY" + assert RESEARCH_ROUTE_SPECS[3].full_markers == ( + "WEEKLY RESEARCH SUMMARY", + "Research Discipline Review", + "Research change monitor", + "No unresolved evidence change is queued.", + "Open Discover", + "Advanced: five-company Earnings Nowcast readiness", + "Research-only", + ) + assert RESEARCH_ROUTE_SPECS[2].route == "/?mode=research&page=company-workbench&ticker=NVDA&open=1" + assert RESEARCH_ROUTE_SPECS[2].first_useful_marker == "USE NOW" + assert "Selected Company" not in RESEARCH_ROUTE_SPECS[2].full_markers + assert "Forward View" in RESEARCH_ROUTE_SPECS[2].full_markers + assert "What Remains Withheld" in RESEARCH_ROUTE_SPECS[2].full_markers + assert RESEARCH_ROUTE_SPECS[3].full_markers[0] == "WEEKLY RESEARCH SUMMARY" + + def test_nearest_rank_percentile_does_not_select_the_best_run(): from src.public_performance_gate import nearest_rank_percentile @@ -71,6 +127,30 @@ def test_performance_summary_and_gate_keep_cold_warm_and_failure_truth_separate( assert "Data Health: 1 failed timing run(s)" in result.failures +def test_performance_gate_fails_closed_when_required_samples_are_missing(): + from src.public_performance_gate import ( + PerformanceThresholds, + RouteTimingSample, + evaluate_performance_gate, + summarize_route_timings, + ) + + summary = summarize_route_timings( + [RouteTimingSample("Company Workbench", "390x844", "cold", 0.2, 1.2, 3.0, True)] + ) + + result = evaluate_performance_gate( + summary, + critical_routes={"Company Workbench"}, + thresholds=PerformanceThresholds(), + min_cold_runs=1, + min_warm_runs=5, + ) + + assert result.verdict == "failed" + assert "Company Workbench: warm sample count 0 is below 5" in result.failures + + def test_demo_snapshot_identity_uses_the_tracked_manifest_hashes(tmp_path): from src.public_performance_gate import demo_snapshot_identity @@ -97,16 +177,27 @@ def test_demo_snapshot_identity_uses_the_tracked_manifest_hashes(tmp_path): def test_contract_payload_is_read_only_research_safe_and_browser_explicit(tmp_path): - from src.public_performance_gate import performance_contract_payload + from src.public_performance_gate import RESEARCH_ROUTE_SPECS, performance_contract_payload manifest = tmp_path / "data" / "demo" / "manifest.json" manifest.parent.mkdir(parents=True) manifest.write_text(json.dumps({"files": {}}), encoding="utf-8") - payload = performance_contract_payload(tmp_path) + payload = performance_contract_payload( + tmp_path, + route_specs=RESEARCH_ROUTE_SPECS, + workflow="research", + ) rendered = json.dumps(payload).lower() assert payload["mode"] == "contract_only" + assert payload["workflow"] == "research" + assert [row["name"] for row in payload["routes"]] == [ + "Research Desk", + "Discover", + "Company Workbench", + "Monitor", + ] assert payload["browser_requirement"] == "playwright plus a local chrome-compatible executable" assert payload["thresholds"]["warm_full_settle_seconds"] == 5.0 assert payload["thresholds"]["cold_full_settle_seconds"] == 10.0 @@ -124,6 +215,10 @@ def test_makefile_exposes_contract_and_real_browser_performance_commands(): assert "public-performance-gate:" in makefile assert "python3 -m src.public_performance_gate --contract" in makefile assert "python3 -m src.public_performance_gate --browser" in makefile + assert "commercial-beta-performance-contract:" in makefile + assert "commercial-beta-performance-gate:" in makefile + assert "--workflow research --contract" in makefile + assert "--workflow research --browser" in makefile def test_find_chrome_executable_uses_only_an_executable_candidate(tmp_path): @@ -203,6 +298,18 @@ def wait_for_function(self, expression, *, arg, timeout): _wait_for_visible_text(FailingPage(), "USE NOW", timeout_seconds=3) +def test_horizontal_overflow_check_uses_document_widths(): + from src.public_performance_gate import _horizontal_overflow_pixels + + class FakePage: + def evaluate(self, expression): + assert "scrollWidth" in expression + assert "clientWidth" in expression + return 14 + + assert _horizontal_overflow_pixels(FakePage()) == 14 + + def test_reviewed_performance_baseline_documents_reproducible_evidence_boundary(): readme = Path("README.md").read_text(encoding="utf-8") baseline = Path("docs/PERFORMANCE_RELEASE_GATE.md").read_text(encoding="utf-8") @@ -218,6 +325,12 @@ def test_reviewed_performance_baseline_documents_reproducible_evidence_boundary( assert "60 recorded route samples" in baseline assert "product performance evidence only" in baseline.lower() assert "not data-freshness proof" in baseline.lower() + assert "make commercial-beta-performance-contract" in baseline + assert "make commercial-beta-performance-gate" in baseline + assert "e930bd0e1b1062c029a7633a226db8dbc03a506b" in baseline + assert "48 recorded route samples" in baseline + assert "Company Workbench" in baseline + assert "Research Desk" in baseline def test_performance_progress_line_shows_route_viewport_run_and_outcome(): diff --git a/tests/test_public_ux_review_checklist.py b/tests/test_public_ux_review_checklist.py index e5106a29..92c8a7d3 100644 --- a/tests/test_public_ux_review_checklist.py +++ b/tests/test_public_ux_review_checklist.py @@ -58,7 +58,7 @@ def test_public_ux_review_checklist_keeps_operator_details_and_data_claims_out() assert "If in-app browser capture is unavailable or times out, classify the review as environment_limited" in rendered assert "Do not replace screenshot assets from a timed-out, blank, cropped, or loading capture." in rendered assert "Review log template:" in rendered - assert "Issue classification: resolved, intentionally_deferred, environment_limited, skipped, or blocked_with_evidence" in rendered + assert "Issue classification: resolved, resolved_post_fix (fresh verified recapture after a fix), intentionally_deferred, environment_limited, skipped, or blocked_with_evidence" in rendered assert "blocked, candidate-only, skipped, or excluded lane appears as analysis-ready" in rendered assert "broker trading, order routing, auto-trading, direct buy/sell instructions, or investment advice" in rendered assert "make project-status-check" in rendered @@ -256,6 +256,36 @@ def test_public_ux_review_notes_status_marks_share_review_ready_when_all_rows_re assert "share_review_gate: share_review_ready" in rendered +def test_public_ux_review_notes_status_marks_share_review_ready_after_verified_fix(tmp_path): + notes_path = write_public_ux_review_notes(tmp_path) + for page, *_ in PUBLIC_ROUTES: + for viewport in ("desktop", "phone"): + classification = ( + "resolved_post_fix" + if page == "Single-Stock Report" and viewport == "phone" + else "resolved" + ) + record_public_ux_review_note( + notes_path=notes_path, + page=page, + viewport=viewport, + first_answer_visible="yes", + primary_next_action_visible="yes", + advanced_details_collapsed="yes", + classification=classification, + notes=f"{page} {viewport} reviewed.", + ) + + status = public_ux_review_notes_status(notes_path) + + assert status["status"] == "review_complete" + assert status["share_review_gate"] == "share_review_ready" + assert status["pending_rows"] == 0 + assert status["problem_rows"] == [] + assert status["classification_counts"]["resolved"] == 9 + assert status["classification_counts"]["resolved_post_fix"] == 1 + + def test_public_ux_review_notes_status_marks_limited_gate_when_problem_rows_remain(tmp_path): notes_path = write_public_ux_review_notes(tmp_path) for page, *_ in PUBLIC_ROUTES: @@ -282,6 +312,31 @@ def test_public_ux_review_notes_status_marks_limited_gate_when_problem_rows_rema assert "share_review_gate: review_limited" in rendered +def test_public_ux_review_notes_status_keeps_unknown_resolution_labels_fail_closed(tmp_path): + notes_path = write_public_ux_review_notes(tmp_path) + for page, *_ in PUBLIC_ROUTES: + for viewport in ("desktop", "phone"): + classification = ( + "resolved_typo" if page == "Single-Stock Report" and viewport == "phone" else "resolved" + ) + record_public_ux_review_note( + notes_path=notes_path, + page=page, + viewport=viewport, + first_answer_visible="yes", + primary_next_action_visible="yes", + advanced_details_collapsed="yes", + classification=classification, + notes=f"{page} {viewport} reviewed.", + ) + + status = public_ux_review_notes_status(notes_path) + + assert status["status"] == "review_has_deferred_or_limited_items" + assert status["share_review_gate"] == "review_limited" + assert status["problem_rows"][0]["classification"] == "resolved_typo" + + def test_record_public_ux_review_note_updates_one_row_and_advances_queue(tmp_path): notes_path = write_public_ux_review_notes(tmp_path) diff --git a/tests/test_public_v1_release_docs.py b/tests/test_public_v1_release_docs.py index 60e78557..cfe70c6c 100644 --- a/tests/test_public_v1_release_docs.py +++ b/tests/test_public_v1_release_docs.py @@ -11,6 +11,118 @@ def _read(path: str) -> str: return Path(path).read_text(encoding="utf-8") +def test_proof_readiness_reconciliation_docs_keep_historical_proof_separate_from_current_state(): + roadmap = _read("ROADMAP.md") + operator = _read("docs/OPERATOR_GUIDE.md") + prompt = _read("docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md") + + for text in (roadmap, operator, prompt): + assert "make proof-readiness-reconciliation TOP_N=20" in text + assert "historical_supported_currently_blocked" in text + assert "current saved readiness remains authoritative" in text.lower() + assert "explicit_ticker_change" in text + assert "current_canonical_row_missing" in text + assert "does not establish the historical cause" in text.lower() + assert "current-snapshot audit" in roadmap.lower() + assert "does not restore canonical data" in operator.lower() + assert "before reusing a supporting proof outcome" in prompt.lower() + assert "source rights" in prompt.lower() + assert "field scope" in prompt.lower() + assert "changed_tickers" in operator + assert "changed_tickers" in prompt + assert "structured per-ticker" in prompt.lower() + assert "future" in roadmap.lower() + + +def test_provider_neutral_workspace_authorization_is_documented_without_hosted_claims(): + architecture = _read("docs/PRIVATE_BETA_ARCHITECTURE.md") + roadmap = _read("ROADMAP.md") + continuation = _read( + "docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md" + ) + + for document in (architecture, roadmap, continuation): + normalized = " ".join(document.split()) + assert "provider-neutral" in normalized + assert "deny-by-default" in normalized + assert "append-only" in normalized + assert "privacy-safe audit obligation" in normalized + assert "does not prove hosted authentication" in normalized + + assert "src.hosted_access_control.evaluate_workspace_access" in architecture + normalized_architecture = " ".join(architecture.split()) + assert ( + "The evaluator performs no authentication, persistence, audit storage, " + "retention, monitoring" in normalized_architecture + ) + assert ( + "rollback, incident response, or operated capacity. All such states " + "remain external" in normalized_architecture + ) + + normalized_roadmap = " ".join(roadmap.split()) + assert ( + "**Exit gate:** the actual hosted environment directly proves every " + "claimed control, including an observed rollback rehearsal and named " + "owner." in normalized_roadmap + ) + assert ( + "The module has no dashboard, ledger, readiness, provider, persistence, " + "environment, network, or generated-artifact integration." + in normalized_roadmap + ) + assert ( + "does not prove hosted authentication, deployed isolation, audit storage, " + "retention, monitoring, rollback, incident response, operated capacity" + in normalized_roadmap + ) + + normalized_continuation = " ".join(continuation.split()) + assert "Do not create or change hosted accounts" in continuation + assert ( + "does not prove hosted authentication, deployed isolation, persistence, " + "audit storage, retention, monitoring, rollback, incident response, " + "operated capacity" in normalized_continuation + ) + assert ( + "Provider-specific integration remains blocked until the exact identity, " + "storage, logging, host, and operating environment are explicitly approved." + in normalized_continuation + ) + + +def test_two_company_cash_preview_docs_preserve_bounded_portability_boundary(): + methodology = _read("docs/METHODOLOGY.md") + provenance = _read("docs/PROVENANCE_CONTRACT.md") + personal = _read("docs/PERSONAL_RESEARCH_MODE.md") + roadmap = _read("ROADMAP.md") + prompt = _read("docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md") + + for text in (methodology, provenance, personal, roadmap, prompt): + assert "AMD Q1 FY2026" in text + assert "0000002488-26-000076" in text + assert "bounded two-company portability" in text.lower() + assert "cash_preview=1" in personal + assert "production_activation=false" in provenance + assert "readiness_promotions=()" in provenance + assert "does not prove broad company coverage" in roadmap.lower() + assert "do not add a third company" in prompt.lower() + + +def test_makefile_exposes_stdout_only_readiness_preview_contract(): + makefile = _read("Makefile") + + assert "readiness-preview" in makefile.splitlines()[0] + assert ( + "make readiness-preview [TOP_N=20] Preview stable readiness impact, change causes, and promotion evidence in memory without writing files" + in makefile + ) + assert ( + "readiness-preview:\n\t@PYTHONDONTWRITEBYTECODE=1 python3 -m src.readiness_preview --top-n $(or $(TOP_N),20)" + in makefile + ) + + def test_readme_product_tour_matches_v1_public_route_model(): readme = _read("README.md") @@ -25,7 +137,7 @@ def test_readme_product_tour_matches_v1_public_route_model(): assert "First review move: open Stock Selector" in readme assert "screenshots are product evidence only" in readme assert "| What proves current local readiness? | `make status-check TOP_N=5` remains the source for current local counts; screenshots are product evidence only. |" in readme - assert "Start with the five public paths" in readme + assert "The controlled Public workspace keeps its existing five-page path" in readme assert "| Home |" in readme assert "| Stock Selector |" in readme assert "| Single-Stock Report |" in readme @@ -66,6 +178,20 @@ def test_readme_and_roadmap_keep_active_planning_separate_from_completed_history assert "without reopening broad proof loops" in completed +def test_active_roadmap_is_a_concise_current_decision_index(): + roadmap = _read("ROADMAP.md") + + for heading in ( + "## Now", + "## Next", + "## Externally blocked", + "## Later", + "## Completed with evidence", + ): + assert heading in roadmap + assert len(roadmap.splitlines()) <= 320 + + def test_roadmap_current_counts_are_live_command_gated(): roadmap = _read("ROADMAP.md") @@ -115,49 +241,81 @@ def test_readme_surfaces_compact_pilot_share_status_before_local_hygiene(): def test_readme_has_compact_current_next_stages_for_external_reviewers(): readme = _read("README.md") + assert readme.count("## External Reviewer Start Here") == 1 + assert "## Personal Research Start Here" not in readme + assert "Primary product workflow" in readme + assert "Research Desk -> Discover -> Company Workbench -> Monitor" in readme + assert "Secondary controlled demo" in readme + assert "Home -> Stock Selector -> Single-Stock Report -> Data Health -> Proof History" in readme + assert "Data Health and Proof History stay under **Advanced Evidence**" in readme assert "## Now / Next / Not Yet" in readme assert "| Now | GitHub/LinkedIn portfolio demo with public workflow, screenshots, methodology, local run commands, manual gates, and a locally passed performance gate. | Use `make public-check` before sharing; keep generated churn excluded and do not treat local timing as hosted proof. |" in readme assert "| Next | Optional controlled hosted preview and task-based external pilot review. | Hosting remains external until a URL is verified; reviewer feedback must remain anonymous workflow evidence, not investment opinion. |" in readme assert "| Not yet | Full hosted data product, complete fundamentals/peer/optional coverage, or provider-backed automation across the universe. | Do not claim this until external hosting, provider keys, source proof, validation, preview, apply, rebuilt readiness, and proof history support it. |" in readme assert "This is the fastest reviewer answer: the product is shareable as a controlled demo now, deeper coverage is source-gated, and hosting/provider automation stays optional until verified." in readme - assert "## Current Next Stages" in readme - assert "| Performance release candidate | Passed locally on the fixed demo profile | Re-run `make public-performance-gate` for current route evidence; do not mix measurements with broad data refreshes or treat local timing as hosted proof. |" in readme - assert "| LinkedIn publish | Ready after GitHub sync | If the branch is ahead, push reviewed commits after `make public-check`; if GitHub is synced, use the GitHub link and `docs/LINKEDIN_PROJECT_BRIEF.md`; do not claim hosted app availability. |" in readme - assert "| Hosted Streamlit demo | External account required | Run `make hosted-demo-readiness`, then follow `docs/HOSTED_DEMO_DEPLOYMENT.md`; keep GitHub as the public link until the hosted route is verified. |" in readme - assert "| FMP provider activation | External key required | Configure `FMP_API_KEY` outside the repo, then run one reviewed ticker smoke before any broader batch. |" in readme - assert "| Peer readiness upgrade | Source-gated | Keep candidate peers as context only until source-backed peer rows pass review. |" in readme - assert "| Optional earnings / estimates | Locked | Use trusted provider or reviewed manual rows only; do not infer optional context. |" in readme - assert "| Broad proof queues | Do not retry now | Current queues are exhausted; reopen only after keyed provider rows, reviewed manual rows, or changed blockers exist. |" in readme - assert "| Public UX polish | Review limited | Public checks and repeated local cold/warm route timings pass; external reviewer evidence is still required before upgrading the claim. |" in readme - assert "| Generated artifacts | Excluded by default | Keep local CSV/report/sample-report churn unstaged unless one exact artifact is reviewed as public evidence. |" in readme - assert "The local fixed-demo performance gate has passed and remains a regression check" in readme - assert "The active evidence stage is a narrow, append-only Earnings Nowcast pilot" in readme - assert "hosting and external review remain separate external stages" in readme - assert readme.index("## External Reviewer Start Here") < readme.index("## Current Next Stages") + assert "local Commercial Research Beta foundation" in readme + assert "not a hosted or commercially launched product" in readme + assert "Authentication, private workspaces, operated data rights, real beta users, and repeatable provider operations remain separate gates" in readme assert readme.index("## External Reviewer Start Here") < readme.index("## Now / Next / Not Yet") - assert readme.index("## Now / Next / Not Yet") < readme.index("## Current Next Stages") - assert readme.index("## Current Next Stages") < readme.index("## What You Can Analyze") + assert readme.index("## Now / Next / Not Yet") < readme.index("## What You Can Analyze") -def test_public_status_language_uses_review_limited_until_live_route_gate_passes(): +def test_public_status_language_keeps_share_review_ready_local_only(): readme = _read("README.md") - assert "| Public UX polish | Review limited |" in readme - assert "Share-review ready" not in readme + assert "This repository is ready to review as a controlled GitHub/LinkedIn portfolio demo." in readme + assert "It is not currently published as a hosted Streamlit app." in readme + assert "Hosting remains external until a URL is verified" in readme assert "make public-performance-gate" in readme -def test_active_roadmap_puts_performance_before_hosting_and_external_pilot(): +def test_active_roadmap_keeps_completed_performance_separate_from_external_gates(): roadmap = _read("ROADMAP.md") + completed = roadmap.index("## Completed with evidence") performance = roadmap.index("### P0: Performance Release Candidate") - hosted = roadmap.index("### P1: Controlled Hosted Preview Verification") - pilot = roadmap.index("### P1: Controlled Pilot Review") - - assert performance < hosted < pilot + assert roadmap.index("## Externally blocked") < completed < performance + assert "`hosted_account_and_controls_required`" in roadmap + assert "`independent_reviewers_required`" in roadmap assert "data/demo/manifest.json" in roadmap assert "first useful" in roadmap.lower() assert "p90" in roadmap.lower() + assert "make commercial-beta-release-check" in roadmap + assert "make commercial-beta-performance-gate" in roadmap + + +def test_performance_release_gate_records_immutable_research_evidence(): + performance = _read("docs/PERFORMANCE_RELEASE_GATE.md") + + assert "immutable release-candidate" in performance + assert re.search(r"commit `[0-9a-f]{40}`", performance) + assert "48 recorded route samples, zero failures, and no horizontal overflow" in performance + for route in ("Research Desk", "Discover", "Company Workbench", "Monitor"): + assert f"| {route} | 1280x720 |" in performance + assert f"| {route} | 390x844 |" in performance + assert "/tmp/stock-command-center-commercial-beta-performance.json" in performance + assert "Keep it out of\nGit" in performance + + +def test_hosted_handoff_covers_research_routes_health_and_rollback_without_claiming_a_host(): + hosted = _read("docs/HOSTED_DEMO_DEPLOYMENT.md") + + assert "Research Desk -> Discover -> Company Workbench -> Monitor" in hosted + assert "make commercial-beta-performance-gate BASE_URL=" in hosted + assert "## Health Check And Rollback" in hosted + assert "external_account_required" in hosted + assert "Do not claim private or authenticated access" in hosted + + +def test_private_beta_architecture_keeps_operating_controls_external_and_independent(): + architecture = _read("docs/PRIVATE_BETA_ARCHITECTURE.md") + + assert "external_operations_required" in architecture + assert "incident response" in architecture + assert "rollback" in architecture + assert "owner capacity" in architecture + assert "authentication, workspaces, user data separation" in architecture + assert "A local runbook does not prove" in architecture def test_methodology_defines_lane_level_freshness_policy_without_claiming_live_data(): @@ -234,6 +392,12 @@ def test_product_direction_decision_stays_provisional_until_external_evidence_ex def test_linkedin_brief_has_now_next_not_yet_share_framing(): linkedin = _read("docs/LINKEDIN_PROJECT_BRIEF.md") + assert "Stock Research Command Center | Evidence-First Company Research" in linkedin + assert "Research Desk -> Discover -> Company Workbench -> Monitor" in linkedin + assert "stable GitHub repository link only after this reviewed feature reaches the default branch" in linkedin + assert "Draft engineering preview" in linkedin + assert "readiness counts" not in linkedin.lower() + assert "Workbench answer-first screenshot" in linkedin assert "## Now / Next / Not Yet" in linkedin assert "| Now | GitHub/LinkedIn portfolio demo with the guided public workflow, screenshots, methodology, and local run commands. | Share the GitHub link and curated screenshot after GitHub is synced and `make public-check` passes. |" in linkedin assert "| Next | Source-backed Earnings Nowcast evidence pilot, plus optional hosted preview and controlled review. | Keep synthetic fixtures separate from real evidence; do not imply a hosted URL, private access, predictive validation, or provider-backed automation before verification. |" in linkedin @@ -345,7 +509,8 @@ def test_readme_has_external_reviewer_handoff_before_operator_detail(): assert readme.index("## External Reviewer Start Here") < readme.index("## External Reviewer Handoff") assert readme.index("## External Reviewer Handoff") < readme.index("## Data Coverage Strategy") assert "Confirm `README.md` starts with `External Reviewer Start Here`" in checklist - assert "then `make dashboard` and the Home -> Stock Selector -> Single-Stock Report -> Data Health -> Proof History path" in checklist + assert "then Research Desk -> Discover -> Company Workbench -> Monitor" in checklist + assert "Home -> Stock Selector -> Single-Stock Report -> Data Health -> Proof History as the secondary controlled Public demo" in checklist assert "Keep terminal proof commands secondary" in checklist assert "Put the best demo commands near the top" not in checklist @@ -487,6 +652,18 @@ def test_dashboard_qa_tracks_v1_replacement_browser_checks(): assert "the current in-app browser shows `localhost refused to connect`" not in qa +def test_dashboard_qa_records_commercial_beta_research_mode_live_review(): + qa = _read("docs/DASHBOARD_QA.md") + + assert "Commercial Beta Research Workflow Live Review" in qa + assert "1280x720" in qa + assert "390x844" in qa + assert "Research Desk, Discover, Company Workbench, and Monitor" in qa + assert "ArrowInvalid" in qa + assert "no horizontal overflow" in qa + assert "product evidence only" in qa + + def test_public_demo_and_linkedin_copy_use_v1_route_sequence(): makefile = _read("Makefile") brief = _read("docs/LINKEDIN_PROJECT_BRIEF.md") @@ -504,6 +681,11 @@ def test_public_demo_and_linkedin_copy_use_v1_route_sequence(): assert "make project-status-check Read current coverage, blockers, and executable next steps" in makefile assert "Check current counts: make status-check TOP_N=5" in makefile assert "make linkedin-share-check" in makefile + assert "Stock Research Command Center | Evidence-First Company Research" in makefile + assert "Research Desk -> Discover -> Company Workbench -> Monitor" in makefile + assert "stable GitHub repository link only after this reviewed feature reaches the default branch" in makefile + assert "Draft engineering preview" in makefile + assert "count-safe Company Workbench answer visual" in makefile assert "GitHub's generated OpenGraph card" in makefile @@ -534,6 +716,201 @@ def test_active_roadmap_and_price_history_maintenance_are_finite_and_read_only() assert "read-only batch closeout" in readme +def test_commercial_beta_continuation_prompt_is_persistent_but_evidence_bound(): + roadmap = _read("ROADMAP.md") + prompt = _read("docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md") + + assert "Commercial Research Beta Continuation Contract" in roadmap + assert "/goal" in prompt + assert "codex/personal-research-mode-mvp" in prompt + assert "pull/113" in prompt + assert "commit `781ba2481` or a later verified descendant" in prompt + assert "commit `54f3977d7` or a later verified descendant" in prompt + assert "classify it once" in prompt + assert "avoid identical retry loops" in prompt + assert "Continue automatically while any safe, meaningful, in-scope local task remains" in prompt + assert "Do not mark the objective complete" in prompt + assert "Stage 1 — Answer-first workflow hardening" in prompt + assert "Stage 6 — Operating maturity and product direction" in prompt + assert "Never use `git add -A`" in prompt + assert "Keep PR #113 draft" in prompt + assert "Do not merge into main or deploy publicly without explicit approval" in prompt + assert "Generated CSV, JSON" in prompt + assert "Point-in-time consensus and rights: `permitted_point_in_time_consensus_and_rights_required`" in prompt + assert "Hosted account and controls: `hosted_account_and_controls_required`" in prompt + assert "Independent reviewers: `independent_reviewers_required`" in prompt + assert "Trusted peer/source review: `trustworthy_peer_source_and_review_required`" in prompt + assert "Calibration cohort: `calibration_cohort_required`" in prompt + assert "Operated owner/incident/rollback capacity: `operated_owner_incident_rollback_capacity_required`" in prompt + assert "Research-only; no investment advice" in prompt + assert "Keep the goal active whenever any applicable gate remains incomplete or unproven" in prompt + + +def test_consensus_source_review_docs_keep_review_collection_and_activation_separate(): + roadmap = _read("ROADMAP.md") + data_strategy = _read("docs/DATA_STRATEGY.md") + pilot = _read("docs/EARNINGS_NOWCAST_PILOT.md") + methodology = _read("docs/METHODOLOGY.md") + provenance = _read("docs/PROVENANCE_CONTRACT.md") + prompt = _read("docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md") + + for text in (roadmap, data_strategy, pilot, methodology, provenance, prompt): + assert "earnings-consensus-source-review" in text + assert "collection preview" in text.lower() + assert "read-only" in text.lower() + assert "explicit provider" in roadmap.lower() + assert "original one-based" in provenance.lower() + assert "auto_apply=false" in pilot + assert "source-review-before-preview" in prompt.lower() + + +def test_consensus_source_review_docs_use_distinct_source_and_collection_inputs(): + data_strategy = _read("docs/DATA_STRATEGY.md") + pilot = _read("docs/EARNINGS_NOWCAST_PILOT.md") + prompt = _read("docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md") + + for text in (data_strategy, pilot, prompt): + assert "SOURCE_INPUT=" in text + assert "COLLECTION_INPUT=" in text + assert "distinct input contracts" in text.lower() + assert "earnings-consensus-source-review INPUT=" not in text + + +def test_quarterly_cash_generation_docs_preserve_no_file_and_market_maturity_boundaries(): + methodology = _read("docs/METHODOLOGY.md") + provenance = _read("docs/PROVENANCE_CONTRACT.md") + personal_mode = _read("docs/PERSONAL_RESEARCH_MODE.md") + roadmap = _read("ROADMAP.md") + prompt = _read("docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md") + + assert "cash from operations + reported capital expenditures" in methodology + assert "explicit_filed_quarter" in provenance + assert "no new data file, writer, template, or generated artifact" in provenance + assert "reviewed quarterly source adapter" in personal_mode + assert "methodology maturity" in roadmap + assert "does not prove broad real-company coverage or market validation" in roadmap + assert ( + "Quarterly cash-generation source adapter: `one_company_source_preview_accepted_for_review`" + in prompt + ) + assert "no supplemental data file" in prompt + + +def test_quarterly_adapter_acceptance_docs_keep_review_and_activation_separate(): + methodology = _read("docs/METHODOLOGY.md") + provenance = _read("docs/PROVENANCE_CONTRACT.md") + personal_mode = _read("docs/PERSONAL_RESEARCH_MODE.md") + roadmap = _read("ROADMAP.md") + prompt = _read("docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md") + + assert "accepted_for_review is not production activation" in methodology + assert "production_activation=false" in provenance + assert "readiness_promotions=()" in provenance + assert "no adapter file is loaded or written" in personal_mode + assert "one-company adapter acceptance harness" in roadmap + assert "bounded exact-source review" in roadmap + assert "They do **not** prove production activation" in roadmap + assert "Quarterly adapter acceptance" in prompt + assert "accepted_for_review" in prompt + + +def test_mobile_research_first_action_docs_preserve_readiness_and_market_boundaries(): + personal_mode = _read("docs/PERSONAL_RESEARCH_MODE.md") + roadmap = _read("ROADMAP.md") + prompt = _read("docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md") + + assert "mobile first-action density" in personal_mode.lower() + assert "does not change readiness" in personal_mode.lower() + assert "phone first-action" in roadmap.lower() + assert "mobile first-action density" in prompt.lower() + + +def test_personal_research_evidence_detours_preserve_workspace_and_return_path(): + roadmap = _read("ROADMAP.md") + personal_mode = _read("docs/PERSONAL_RESEARCH_MODE.md") + dashboard_qa = _read("docs/DASHBOARD_QA.md") + prompt = _read("docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md") + + for text in (roadmap, personal_mode, dashboard_qa, prompt): + assert "Data Health and Proof History stay inside Personal Research mode" in text + assert "Return to Company Workbench" in text + assert "does not change readiness" in text + + +def test_pilot_freshness_docs_fail_closed_on_declared_source_dates(): + data_strategy = _read("docs/DATA_STRATEGY.md") + roadmap = _read("ROADMAP.md") + prompt = _read("docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md") + + for text in (data_strategy, roadmap, prompt): + assert "declared source dates" in text.lower() + assert "file mtimes" in text.lower() + assert "make readiness-preview TOP_N=20" in text + assert "does not make saved readiness current" in text.lower() + assert "does not rebuild readiness" in data_strategy.lower() + assert "make readiness" in prompt + + +def test_stale_readiness_continuation_gate_docs_keep_rankings_non_executable(): + roadmap = _read("ROADMAP.md") + data_strategy = _read("docs/DATA_STRATEGY.md") + dashboard_qa = _read("docs/DASHBOARD_QA.md") + prompt = _read("docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md") + + for text in (roadmap, data_strategy, dashboard_qa, prompt): + lowered = text.lower() + assert "stale readiness continuation gate" in lowered + assert "make readiness-preview TOP_N=20" in text + assert "planning context only" in lowered + assert "separate intentional reviewed write" in lowered + assert "does not refresh data" in roadmap.lower() + assert "does not prove market validation" in roadmap.lower() + assert "auto-refresh status" in roadmap.lower() + assert "session source preflight" in roadmap.lower() + assert "commercial-beta release" in roadmap.lower() + assert "auto-refresh status" in prompt.lower() + assert "session source preflight" in prompt.lower() + assert "advanced data health cards" in roadmap.lower() + assert "advanced data health cards" in dashboard_qa.lower() + assert "advanced data health cards" in prompt.lower() + + +def test_readiness_promotion_evidence_docs_keep_technical_and_rights_states_independent(): + roadmap = _read("ROADMAP.md") + data_strategy = _read("docs/DATA_STRATEGY.md") + methodology = _read("docs/METHODOLOGY.md") + provenance = _read("docs/PROVENANCE_CONTRACT.md") + prompt = _read("docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md") + + for text in (roadmap, data_strategy, methodology, provenance, prompt): + lowered = text.lower() + assert "technical" in lowered + assert "commercial" in lowered + assert "field" in lowered + assert "make readiness" in text + assert "composite or unregistered source values" in data_strategy.lower() + assert "does not establish price-source provenance" in provenance + assert "local_evidence_review_required" in prompt + assert "not current readiness counts or rebuild approval" in roadmap + + +def test_readiness_change_cause_docs_explain_method_fit_without_company_judgment(): + roadmap = _read("ROADMAP.md") + data_strategy = _read("docs/DATA_STRATEGY.md") + methodology = _read("docs/METHODOLOGY.md") + provenance = _read("docs/PROVENANCE_CONTRACT.md") + prompt = _read("docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md") + + for text in (roadmap, data_strategy, methodology, provenance, prompt): + lowered = text.lower() + assert "transition" in lowered + assert "method" in lowered + assert "current readiness totals" in lowered + assert "acquisition/spac" in methodology.lower() + assert "not a negative company signal" in roadmap.lower() + assert "cannot alter scope" in prompt.lower() + + def test_thesis_journal_docs_preserve_append_only_research_boundary(): readme = _read("README.md") roadmap = _read("ROADMAP.md") @@ -591,3 +968,871 @@ def test_research_comparison_docs_preserve_non_ranking_boundary(): assert "never calculates a score or winner" in methodology assert "Research Comparison Contract" in provenance assert "candidate peer context cannot satisfy trusted-peer readiness" in provenance + + +def test_sec_cash_generation_pilot_docs_preserve_review_boundary(): + methodology = _read("docs/METHODOLOGY.md") + provenance = _read("docs/PROVENANCE_CONTRACT.md") + strategy = _read("docs/DATA_STRATEGY.md") + personal = _read("docs/PERSONAL_RESEARCH_MODE.md") + roadmap = _read("ROADMAP.md") + prompt = _read("docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md") + + assert "explicit_filed_table_outflow" in methodology + assert "acceptanceDateTime" in provenance + assert "sec_companyfacts" in strategy + assert "accepted_for_review is not production activation" in personal + assert "NVIDIA Q1 FY2027" in roadmap + assert "does not activate Company Workbench" in roadmap + assert "sec-quarterly-cash-preview" in prompt + assert "do not repeat the NVIDIA pilot" in prompt + + +def test_company_workbench_cash_preview_docs_preserve_explicit_no_activation_boundary(): + methodology = _read("docs/METHODOLOGY.md") + provenance = _read("docs/PROVENANCE_CONTRACT.md") + personal = _read("docs/PERSONAL_RESEARCH_MODE.md") + roadmap = _read("ROADMAP.md") + prompt = _read("docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md") + + assert "cash_preview=1" in personal + assert "Cash-generation review preview" in personal + assert "not production evidence" in personal + assert "production_activation=false" in provenance + assert "readiness_promotions=()" in provenance + assert "no canonical persistence" in provenance + assert "complete withholding" in methodology + assert "Advanced-only technical lineage" in methodology + assert "one explicit user-flow composition" in roadmap + assert "does not prove a second company" in roadmap + assert "do not repeat the NVIDIA pilot" in prompt + assert "bounded second-company proof" in prompt + + +def test_evidence_integrity_docs_preserve_fail_closed_valuation_and_cutoff_backtest_boundary(): + roadmap = _read("ROADMAP.md") + methodology = _read("docs/METHODOLOGY.md") + prompt = _read("docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md") + + safeguards = ( + "reject non-finite valuation inputs", + "require a canonical real `YYYY-MM-DD` denominator period end", + "reject blank, malformed, and non-calendar denominator period ends", + "reject post-cutoff retrieval evidence", + "canonicalize Revenue/EPS independently through explicit `supersedes_source_ref` lineage", + "retain one event per ticker/period", + "withhold ambiguous leaves per metric so one metric does not suppress the other", + "use cutoff-bounded prior-year benchmarks so post-cutoff revisions cannot leak", + ) + + for text in (roadmap, methodology, prompt): + for safeguard in safeguards: + assert safeguard in text + + assert ( + "Evidence-integrity hardening anchor: commit `7d463bae7` or a later verified descendant" + in prompt + ) + + +def test_prospective_field_proof_docs_preserve_stage_a_recording_boundary(): + readme = _read("README.md") + roadmap = _read("ROADMAP.md") + operator = _read("docs/OPERATOR_GUIDE.md") + prompt = _read("docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md") + + commands = ( + "make prospective-field-proof-status", + "make prospective-field-proof-preview", + "make prospective-field-proof-record", + ) + for text in (readme, roadmap, operator, prompt): + for command in commands: + assert command in text + lowered = text.lower() + assert "prospective-only" in lowered + assert "legacy narrative proof is not upgraded" in lowered + assert "absent ledger is a valid empty state" in lowered + assert "technical_write_eligible" in text + assert "commercial_evidence_eligible" in text + assert "preview receipt" in lowered + assert "ledger, input, cutoff, commercial mode, and source-rights registry" in lowered + assert "does not activate readiness" in lowered + + assert "No sample field-proof rows are checked in" in readme + assert "No sample field-proof rows are checked in" in roadmap + assert ( + "The implemented structured per-ticker/per-field record is prospective-only" + in prompt + ) + assert "structured per-ticker/per-field proof record is prospective future work" not in prompt + assert "No sample field-proof rows are checked in" in operator + assert not Path("data/prospective_field_proofs.csv").exists() + + for text in (roadmap, operator, prompt): + lowered = text.lower() + assert "does not update canonical data" in lowered + assert "does not update proof-readiness reconciliation" in lowered + assert "does not activate company workbench" in lowered + assert "separate design" in lowered + + assert "cooperative local locking" in operator.lower() + assert "not crash-safe" in operator.lower() + assert "not a database transaction" in operator.lower() + assert "writers that do not cooperate" in operator.lower() + + +def test_release_docs_distinguish_tracked_and_excluded_readiness_snapshots(): + readme = _read("README.md") + roadmap = _read("ROADMAP.md") + + assert "For 10-20 external reviewer sessions" in readme + assert "For 5-10 external reviewer sessions" not in readme + assert "`make pilot-readiness-packet` writes `outputs/pilot_readiness_packet.md`" in readme + assert "`make pilot-readiness-packet` is not read-only" in readme + + assert "tracked June 7 readiness snapshot" in roadmap + assert "excluded July 21 local generated working-data snapshot" in roadmap + assert "remains stale under this roadmap's declared-date policy" in roadmap + assert "zero stable readiness changes" in roadmap + assert "not committed PR evidence" in roadmap + assert "does not authorize staging or a readiness rebuild" in roadmap + for stale_count in ( + "current inspection finds 152", + "146/146 promotions", + "current-snapshot audit reports 3,506", + "read-only current snapshot reports 21,246", + ): + assert stale_count not in roadmap + volatile_observation = re.compile( + r"\b(?:current|last observed|last verified|remains)[^.\n]*" + r"(?:\d{1,3}(?:,\d{3})+|\d+/\d+|\d+\s+" + r"(?:\w+\s+){0,2}(?:snapshots|rows|promotions|conflicts|outcomes|tickers))", + re.IGNORECASE, + ) + assert not volatile_observation.search(roadmap) + + +def test_public_mobile_handoff_docs_cover_direct_open_loading_and_remove_stale_measurement(): + roadmap = _read("ROADMAP.md") + dashboard_qa = _read("docs/DASHBOARD_QA.md") + prompt = _read("docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md") + + for text in (roadmap, dashboard_qa, prompt): + normalized = " ".join(text.split()) + assert "direct-open loading state" in normalized + assert "Selected ticker -> `Use now` -> `Still withheld` -> `Open Data Health`" in normalized + assert "44px" in normalized + assert "at least 50px" in normalized + assert "no horizontal overflow" in normalized + assert "no traceback" in normalized + assert "four-column layout" in normalized + assert "836.53125px" not in text + assert "7.46875px" not in text + + normalized_roadmap = " ".join(roadmap.split()) + normalized_qa = " ".join(dashboard_qa.split()) + normalized_prompt = " ".join(prompt.split()) + assert "does not change readiness, source, research, or generated-artifact state" in normalized_roadmap + assert "does not prove hosted behavior, accessibility conformance" in normalized_roadmap + assert "does not change readiness, source, research, or generated-artifact state" in normalized_qa + assert "Neither form of local presentation evidence proves data freshness, source rights, hosted behavior, accessibility compliance, external reviewer behavior, or predictive validity" in normalized_qa + assert "not hosted, accessibility-conformance, external-reviewer, freshness, demand, or market evidence" in normalized_prompt + assert "changes no readiness, source, research, or generated-artifact state" in normalized_prompt + assert "fresh screenshots and audit notes remain" not in dashboard_qa + assert "earlier screenshots predate this regression fix" in dashboard_qa + assert "no new screenshot artifact was created" in dashboard_qa + + +def test_continuation_docs_keep_maturity_lanes_and_external_unblocks_truthful(): + roadmap = _read("ROADMAP.md") + prompt = _read("docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md") + + for text in (roadmap, prompt): + assert "Stage A-G labels are continuation maturity lanes only" in text + assert "do not replace the numbered Stage 0-6 exit gates" in text + assert "Stage B — local field-proof audit and operator hardening" in text + assert "no readiness mapping" in text.lower() + assert "separate design" in text.lower() + + assert ( + "The next executable maturity choice is a separately designed Company Workbench activation preview" + not in roadmap + ) + assert "is the second approved local priority after legacy surface quarantine" in roadmap + assert "Activation remains non-active and separately designed" in roadmap + + classifications = ( + "permitted_point_in_time_consensus_and_rights_required", + "hosted_account_and_controls_required", + "independent_reviewers_required", + "trustworthy_peer_source_and_review_required", + "calibration_cohort_required", + "operated_owner_incident_rollback_capacity_required", + "point_in_time_benchmark_universe_and_rights_required", + "accessibility_manual_review_environment_required", + "paper_position_lab_design_approval_required", + ) + for classification in classifications: + assert prompt.count(classification) == 1 + + assert "Subagent review is engineering review only; no GitHub human reviews exist." in prompt + assert "10-20 independent task-based reviewers" in prompt + assert "one bounded reviewed peer relationship" in prompt + assert "at least 100 leakage-safe out-of-sample events" in prompt + assert "a named owner and directly rehearsed incident and rollback capacity" in prompt + + +def test_approved_next_stage_program_is_ordered_non_blocking_and_evidence_bound(): + roadmap = _read("ROADMAP.md") + prompt = _read("docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md") + + ordered_priorities = ( + "Priority 1 — Legacy portfolio, ranking, and action-language quarantine", + "Priority 2 — Stage B field-proof audit and operator hardening", + "Priority 3 — In-app research-record authoring", + "Priority 4 — Point-in-time benchmark and universe foundation", + "Priority 5 — One permitted consensus source and one reviewed peer relationship", + "Priority 6 — Controlled hosted operating boundary", + "Priority 7 — Accessibility evidence beyond screenshots", + "Priority 8 — Independent workflow validation", + "Priority 9 — Out-of-sample calibration cohort", + "Priority 10 — Separately approved hypothetical paper-position laboratory", + ) + + for text in (roadmap, prompt): + assert "Approved Next-Stage Maturity Program" in text + positions = [text.index(priority) for priority in ordered_priorities] + assert positions == sorted(positions) + assert all(text.count(priority) == 1 for priority in ordered_priorities) + assert "A blocked priority does not become complete" in text + assert "move to the next safe executable priority" in text + assert "at least 100 valid leakage-safe out-of-sample events" in text + assert "Live brokerage remains out of scope" in text + assert "earnings-consensus-collection-record" in text + assert "append-only evidence record" in text + assert "does not activate readiness or numerical probability" in text + + assert "automated generation cannot become reviewer-authored evidence" in roadmap + assert "corporate actions, delistings, survivorship, and leakage" in roadmap + assert "10-20 independent workflow sessions" in roadmap + assert "Research Desk -> Discover -> Company Workbench -> Monitor" in roadmap + assert "governed by the Approved Next-Stage Maturity Program" in roadmap + assert "after the single reviewed relationship in Priority 5" in roadmap + assert "provider-neutral control contracts" in prompt + assert "provider-specific implementation" in prompt.lower() + assert "separate approved design" in prompt + + +def test_external_dependency_entries_own_distinct_conditions_and_last_observed_evidence(): + prompt = _read("docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md") + + labels = ( + "Point-in-time consensus and rights", + "Hosted account and controls", + "Independent reviewers", + "Trusted peer/source review", + "Calibration cohort", + "Operated owner/incident/rollback capacity", + "Point-in-time benchmark/universe data and rights", + "Accessibility manual-review environment", + "Paper-position laboratory design approval", + ) + bullets = { + label: next( + line for line in prompt.splitlines() if line.startswith(f"- {label}:") + ) + for label in labels + } + for bullet in bullets.values(): + assert "Last observed:" in bullet + assert "Exact unblock condition:" in bullet + observed = bullet.split("Last observed:", 1)[1].split( + "Exact unblock condition:", 1 + )[0] + assert not re.search(r"\d", observed) + + hosted = bullets["Hosted account and controls"].lower() + for required in ("host account", "verified url", "access controls", "isolation"): + assert required in hosted + for operated_only in ( + "health", + "incident", + "rollback", + "audit", + "retention", + "entitlements", + "monitoring", + ): + assert operated_only not in hosted + + operated = bullets["Operated owner/incident/rollback capacity"].lower() + for required in ( + "named owner", + "rehearsed incident", + "rollback", + "audit", + "retention", + "entitlements", + "monitoring", + "health-check", + ): + assert required in operated + for hosted_only in ("host account", "verified url", "access controls", "isolation"): + assert hosted_only not in operated + + reviewers = bullets["Independent reviewers"] + assert "independent human GitHub review of PR #113" in reviewers + assert "when a human submits review evidence" not in reviewers + assert "generic human review evidence" not in reviewers.lower() + + +def test_research_decision_lab_release_docs_bind_local_completion_to_current_evidence(): + methodology = _read("docs/METHODOLOGY.md") + provenance = _read("docs/PROVENANCE_CONTRACT.md") + personal = _read("docs/PERSONAL_RESEARCH_MODE.md") + roadmap = _read("ROADMAP.md") + decision_prompt = _read("docs/internal/RESEARCH_DECISION_LAB_CONTINUATION_GOAL_PROMPT.md") + commercial_prompt = _read("docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md") + design = _read("docs/superpowers/specs/2026-07-22-research-decision-lab-design.md") + browser_contract = _read("src/browser_qa_evidence.py") + readme = _read("README.md") + + assert "Research plan -> evidence -> invalidation -> scenario -> review trigger -> learning" in methodology + for lane in ("Plan", "Evidence", "Invalidation", "Scenario", "Review trigger", "Learning"): + assert f"`{lane}`" in methodology + assert "Decision Lab lanes remain independent" in methodology + + assert "Research Decision Lab Contract" in provenance + assert "writes no journal, outcome, source, readiness, proof, report, screenshot, or timing artifact" in provenance + assert "A valid lane cannot promote, repair, or clear another lane" in provenance + + assert "What Changed -> Research Decision Lab -> Business Trend" in personal + assert "Weekly Research Summary -> Research Discipline Review -> Research change monitor" in personal + assert "No process item is currently due from saved reviewer-authored evidence" in personal + + assert "Implemented locally — Research Decision Lab" in roadmap + assert "Stage 4 — Documentation and release evidence: completed locally" in roadmap + assert "Local Decision Lab implementation is complete" in decision_prompt + assert "Local Decision Lab implementation is complete" in commercial_prompt + assert "Status:** Implemented locally; external maturity gates remain separate" in design + + assert '"Research Decision Lab"' in browser_contract + assert '"Research Discipline Review"' in browser_contract + assert "Research Decision Lab" in readme + assert "Research Discipline Review" in readme + + external_boundary = ( + "does not prove source coverage, predictive accuracy, investment performance, independent adoption, " + "hosted reliability, commercial demand, competitive superiority, or product-market fit" + ) + for text in (roadmap, decision_prompt, commercial_prompt): + assert external_boundary in text + + +def test_legacy_research_utility_quarantine_is_consistent_across_product_docs(): + readme = _read("README.md") + product_spec = _read("PRODUCT_SPEC.md") + readiness = _read("READINESS_MODEL.md") + roadmap = _read("ROADMAP.md") + prompt = _read("docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md") + design = _read("docs/superpowers/specs/2026-07-22-legacy-research-utility-quarantine-design.md") + + boundary = "Legacy research utility — not part of Personal Research Mode" + for text in (readme, product_spec, readiness, roadmap, prompt, design): + assert boundary in text + + assert "Research Desk -> Discover -> Company Workbench -> Monitor" in readme + assert "Operator-only legacy compatibility utilities" in product_spec + assert "compatibility-only readiness rows" in readiness + assert "Priority 1 — completed locally" in roadmap + assert "Priority 2 — Stage B field-proof audit and operator hardening" in roadmap + assert "Priority 1 is complete locally" in prompt + assert "Priority 2 is complete locally" in prompt + + for text in (readme, product_spec, readiness): + lowered = text.lower() + assert "cannot feed research decision lab" in lowered + assert "cannot change readiness" in lowered + assert "cannot produce recommendations, sizing, or transaction behavior" in lowered + + +def test_stage_b_field_proof_audit_is_documented_as_read_only_and_no_mapping(): + readme = _read("README.md") + operator = _read("docs/OPERATOR_GUIDE.md") + roadmap = _read("ROADMAP.md") + prompt = _read("docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md") + design = _read("docs/superpowers/specs/2026-07-22-field-proof-stage-b-audit-design.md") + + for text in (readme, operator, roadmap, prompt, design): + assert "make prospective-field-proof-audit" in text + assert "preview_receipt_persisted=false" in text + assert "receipt_revalidation_required=true" in text + + assert "Stage B — completed locally" in roadmap + assert "Priority 3 — In-app research-record authoring" in roadmap + assert "Priority 2 is complete locally" in prompt + assert "Priority 3 — completed locally after direct desktop/phone runtime review" in prompt + + for text in (operator, roadmap, prompt): + lowered = text.lower() + assert "does not activate readiness" in lowered + assert "does not update canonical data" in lowered + assert "does not activate company workbench" in lowered + + +def test_priority_three_authoring_release_docs_require_current_runtime_evidence_before_local_completion(): + roadmap = _read("ROADMAP.md") + prompt = _read("docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md") + design = _read("docs/superpowers/specs/2026-07-22-in-app-research-record-authoring-design.md") + plan = _read("docs/superpowers/plans/2026-07-22-in-app-research-record-authoring.md") + readme = _read("README.md") + product_spec = _read("PRODUCT_SPEC.md") + + assert "# In-App Research-Record Authoring Implementation Plan" in plan + assert "REQUIRED SUB-SKILL" in plan + assert "Validate -> Preview -> Confirm and save" in plan + + release_contract = ( + "Thesis, evidence, catalyst, and outcome records are all available in the collapsed Company Workbench composer.", + "A valid record requires an exact preview and explicit confirmation before save.", + "Drafts are untrusted and preview receipts are session-only.", + "Production tests never append repository ledgers; persistence tests use temporary ledgers.", + "A saved record cannot change readiness, forecasts, probabilities, recommendations, or any other ledger.", + ) + historical_priority_contract = ( + "Priority 3 is complete locally only after all automated acceptance tests and direct desktop/phone review pass; Priority 4 is next and incomplete.", + "Priority 4 exit requires one bounded permitted point-in-time dataset with rights, identity, corporate-action, delisting, survivorship, cutoff, reproduction, and leakage gates all passing.", + ) + + for text in (readme, product_spec, roadmap, prompt, design): + for statement in release_contract: + assert statement in text + + for statement in historical_priority_contract: + assert statement in design + + current_priority_contract = ( + "Priority 4's local validator is frozen; its permitted real-data exit gate remains externally incomplete.", + "Priority 6's provider-neutral authorization contract is complete locally; hosted implementation remains environment-dependent.", + "Priority 7 accessibility remediation is the next safe executable local lane.", + ) + for text in (readme, product_spec, roadmap, prompt): + for statement in current_priority_contract: + assert statement in text + assert historical_priority_contract[0] not in text + + assert "selected `profile_key` and normalized ticker" in design + assert "current ledger fingerprint" in plan.lower() + + +def test_active_product_docs_use_evidence_first_positioning_and_current_stage_truth(): + readme = _read("README.md") + roadmap = _read("ROADMAP.md") + methodology = _read("docs/METHODOLOGY.md") + prompt = _read("docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md") + + assert "Evidence-First Research Workbench" in readme + assert "serious individual equity researchers and small research teams" in readme + assert ( + "Priority 7 accessibility remediation is the next safe executable local lane." + in " ".join(methodology.split()) + ) + assert "Current verified synchronization anchor: `88d7a9cd057f9e671360bf1203a6a253c0301675`" in prompt + assert "exact-head GitHub Actions run `30246863731` passed" in prompt + assert "4,215 passing tests" in prompt + + for text in (readme, roadmap, methodology, prompt): + assert "Priority 4 is next and incomplete" not in text + assert "next executable local methodology lane is the\nprovider-neutral hosted-control contract in Priority 6" not in text + + +def test_accessibility_evidence_records_same_page_skip_fix_without_overclaim(): + roadmap = _read("ROADMAP.md") + evidence = _read("docs/ACCESSIBILITY_EVIDENCE.md") + prompt = _read("docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md") + + for text in (roadmap, evidence, prompt): + assert 'target="_blank"' in text + assert 'target="_self"' in text + assert "#public-page-answer" in text + assert "blocked_environment" in text + + assert "K01 and K02 therefore remain `blocked_environment`" in evidence + assert "no complete keyboard-only traversal is claimed" in evidence + assert "framework-control target-size audit" in evidence + assert "exactly `24x24`" in evidence + assert "enlarged the tooltip wrapper, not its nested" in evidence + assert "`Open Data Health` at `102x24`" in evidence + assert "does not prove pointer-spacing exceptions" in evidence + assert "Priority 7 remains incomplete" in evidence + + +def test_priority_three_release_docs_record_controller_runtime_evidence_without_claiming_production_persistence(): + readme = _read("README.md") + product_spec = _read("PRODUCT_SPEC.md") + roadmap = _read("ROADMAP.md") + prompt = _read("docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md") + design = _read("docs/superpowers/specs/2026-07-22-in-app-research-record-authoring-design.md") + + completion = "Priority 3 — completed locally after direct desktop/phone runtime review and the required automated acceptance matrix." + assert completion in roadmap + assert completion in prompt + assert completion in design + + assert "Desktop `1280x720`: `clientWidth=scrollWidth=1280`." in design + assert "Phone `390x844`: `clientWidth=scrollWidth=390`." in design + assert "No successful production save was attempted; persistence evidence remains temporary-ledger AppTest and direct persistence tests only." in design + assert "`6b7cdbd3b`, `996d86610`, `10c2c155c`, and `e67b16d04`" in design + assert "Commit identifier will be added only after the corresponding commit exists." not in design + + hardening = ( + "Hardening commit `07758114c` closes the confirmation race: all three append engines " + "share one resolved-ledger cooperative lock, receipts bind resolved ledger identity, " + "every new preview resets confirmation, and uncertain post-append teardown requires " + "one-shot read-side reload before success." + ) + for text in (roadmap, prompt, design): + assert hardening in text + + integrity = ( + "Final integrity commit `e3a090dba` ensures confirmation appends only the " + "receipt-matched recomputed record and enforces one readable active thesis lineage: " + "revisions must supersede the exact active entry and preserve its thesis ID. The Company " + "Workbench locks and explains that relationship, with temporary-ledger create -> revise " + "-> reload coverage." + ) + for text in (readme, product_spec, roadmap, prompt, design): + assert integrity in text + + confirmation_integrity = ( + "Confirmation-integrity commit `5a6c55921` binds every displayed preview field, " + "preview time, and destination label to the exact receipt. If an append raises after " + "it may have written, confirmation returns one-shot `save_pending_reload` with the exact " + "record ID unless the locked ledger is provably unchanged; it never invites a blind " + "duplicate retry." + ) + for text in (readme, product_spec, roadmap, prompt, design): + assert confirmation_integrity in text + + +def test_accessibility_task_protocol_is_reproducible_and_cannot_claim_conformance_from_incomplete_runs(): + protocol = _read("docs/ACCESSIBILITY_TASK_PROTOCOL.md") + roadmap = _read("ROADMAP.md") + prompt = _read("docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md") + + required_protocol = ( + "commit SHA", + "Research Desk -> Discover -> Company Workbench -> Monitor", + "keyboard_only", + "zoom_200", + "zoom_400", + "forced_colors", + "reduced_motion", + "screen_reader", + "passed_direct", + "blocked_environment", + "No result may be inferred from a screenshot", + "Do not save a research record", + "No WCAG conformance claim", + ) + for phrase in required_protocol: + assert phrase in protocol + + for text in (roadmap, prompt): + assert "docs/ACCESSIBILITY_TASK_PROTOCOL.md" in text + assert "the protocol is not completion evidence" in text + + +def test_priority_four_approved_design_preserves_point_in_time_and_no_write_boundaries(): + design_path = ( + "docs/superpowers/specs/" + "2026-07-23-point-in-time-universe-foundation-design.md" + ) + design = _read(design_path) + roadmap = _read("ROADMAP.md") + prompt = _read( + "docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md" + ) + normalized_design = " ".join(design.split()) + + required_design = ( + "Stable security identifier; never derived from ticker", + "`complete_snapshot` or `event_history`", + "`raw`, `normalized`, `excluded`, and `analysis_eligible`", + "`source_rights_eligibility`", + "`reproduction_ready`", + "`leakage_safe`", + "membership_count_and_sha256_at_cutoff_v1", + "create no directory or artifact", + "Synthetic fixtures prove software behavior only", + "does not satisfy that exit gate by itself", + ) + for phrase in required_design: + assert phrase in normalized_design + + for text in (roadmap, prompt): + assert design_path in text + assert "current ticker-centric universe" in text + assert "Synthetic fixtures remain test-only" in text + + +def test_priority_four_local_validator_is_documented_without_claiming_real_data_completion(): + roadmap = _read("ROADMAP.md") + methodology = _read("docs/METHODOLOGY.md") + prompt = _read( + "docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md" + ) + implementation = ( + "Implemented locally: read-only immutable-package status/preview with ten " + "independent states: manifest, technical, temporal, identity, membership, " + "corporate action, delisting, source rights, reproduction, and leakage." + ) + synthetic_boundary = ( + "Synthetic fixtures remain test-only and local software evidence cannot " + "complete Priority 4." + ) + real_data_exit = ( + "Priority 4 remains open until one bounded permitted real dataset is " + "independently reviewed, reproduces the expected membership count and digest, " + "and passes rights, identity, corporate-action, delisting, survivorship, " + "cutoff, partition, reproduction, and leakage gates." + ) + independent_readiness = ( + "This local evidence does not change independent readiness for actuals, " + "consensus, Revenue, EPS, valuation, catalysts, outcomes, backtesting, or " + "calibration." + ) + research_boundaries = ( + "It does not provide investment advice; numerical probability remains " + "unavailable without calibration; Q4 evidence and EPS split-basis " + "compatibility remain explicit; synthetic evidence stays test-only; candidate " + "peer evidence remains candidate-context-only." + ) + commands = { + "make point-in-time-universe-status MANIFEST=", + "make point-in-time-universe-preview MANIFEST= TOP_N=20", + } + + for text in (roadmap, methodology, prompt): + assert implementation in text + assert "membership_count_and_sha256_at_cutoff_v1" in text + assert synthetic_boundary in text + assert text.count(real_data_exit) == 1 + assert independent_readiness in text + assert research_boundaries in text + assert set( + re.findall(r"`(make point-in-time-universe-[^`]+)`", text) + ) == commands + + assert "Priority 4 is complete" not in roadmap + assert "Priority 4 is complete" not in methodology + assert "Priority 4 is complete" not in prompt + assert "Priority 4 — completed" not in roadmap + assert "Priority 4 — completed" not in prompt + + assert "Start from current repository truth, not chat memory." in prompt + assert ( + "Verify authoritative remote commit " + "`69c49968e77bfd55fa259695089e1f34ac2fddfb` or a later descendant " + "before relying on this implementation evidence." + ) in prompt + assert "exact-head GitHub Actions run `30185232040` passed" in prompt + assert ( + "compare the actual product tree and remote PR head instead of recommitting " + "an already synchronized package" + ) in prompt + assert ( + "Point-in-time universe production-validator lineage anchor: commit " + "`1361472bce6d23cc537ef222c3735bb640c9838a`" + ) in prompt + assert ( + "When external evidence is unavailable, record its exact unblock condition " + "once and continue to the next safe executable lane." + ) in prompt + assert ( + "Never claim overall completion without direct current evidence for every " + "applicable exit gate." + ) in prompt + assert ( + "Do not run readiness rebuilds or generated-artifact commands without " + "explicit approval." + ) in prompt + + +def test_priority_four_resource_budgets_and_review_state_are_documented_truthfully(): + roadmap = _read("ROADMAP.md") + methodology = _read("docs/METHODOLOGY.md") + prompt = _read( + "docs/internal/COMMERCIAL_RESEARCH_BETA_CONTINUATION_GOAL_PROMPT.md" + ) + history = _read("docs/internal/POINT_IN_TIME_UNIVERSE_REVIEW_HISTORY.md") + prior_review_closures = ( + "The second through fourth fresh whole-branch reviews drove the raw-row " + "rights, cutoff-relative history, publication chronology, immutable " + "bounded-read, aggregate-budget, and structured-input parser closures." + ) + budgets = ( + "Local resource budgets for one supplied package: preview sample 100 rows; " + "manifest 1 MiB; each contract CSV 32 MiB; four contract snapshots combined " + "64 MiB; source-rights registry 4 MiB; declared rows 250,000 per contract; " + "package traversal 32 entries." + ) + scope_boundary = ( + "These local bounds do not prove scale, hosted reliability, or market " + "readiness." + ) + external_gate = ( + "No permitted independently reviewed real dataset, accepted expected " + "count/digest, or source-rights proof is on record." + ) + v5_findings = ( + "The fifth fresh whole-branch review confirmed those closures and found " + "three Important trust-boundary defects: C0/C1 characters in structural " + "identifiers could render the newline-delimited membership digest ambiguous " + "and forge public status lines, while manifest creation could predate its " + "cutoff or bound evidence." + ) + v5_remediation = ( + "Commits `b2bbd9961` and `c643d066b` remediate those V5 findings locally " + "with one shared C0/C1 plus Unicode line/paragraph-separator boundary, " + "safe structural-token rendering, an explicit " + "creation-at-or-after-cutoff manifest gate, and exact-row chronology " + "against every contract timestamp." + ) + first_review_follow_up = ( + "The first independent R7 review found the Unicode separator and " + "`listing_state_after` bypass gaps; `c643d066b` closes them locally." + ) + v6_finding = ( + "The sixth fresh whole-branch review confirmed those closures and found " + "one remaining Important non-scalar input defect: lone Unicode surrogate " + "code points could reach public output." + ) + v6_remediation = ( + "Commit `f143d48ed` rejects Unicode category `Cs` through the shared " + "boundary and defensively ASCII-escapes it while valid " + "supplementary-plane scalars remain deterministic." + ) + v7_findings = ( + "The seventh fresh whole-branch review confirmed the V6 correction and " + "found four further trust-boundary defects (two Critical, one Important, " + "and one Minor): duplicate JSON/YAML mapping keys could silently change " + "manifest and rights meaning; invalid or unresolved successor and " + "listing-state evidence could authorize stale original-member digests; " + "malformed CSV headers could discard contract bodies and continue; and " + "non-RFC3339 manifest or policy timestamps were accepted." + ) + v7_remediation = ( + "The local seventh-review remediation rejects duplicate keys at every " + "mapping depth, requires strict RFC3339 UTC manifest and policy " + "timestamps with at most six fractional-second digits, stops malformed " + "headers as package-level input-identity failures, and enforces explicit " + "policy/event/listing-state, successor-identity, and " + "membership-consistency gates without inferring or repairing a successor " + "or membership." + ) + scoped_review_state = ( + "An independent scoped re-review then confirmed the four original findings " + "and the two compatibility regressions were addressed." + ) + eighth_review_state = ( + "The eighth fresh whole-branch review then found three Critical, nine " + "Important, and two Minor defects across sub-microsecond ordering, event-time " + "identity, listing chronology and rights, walk-forward bootstrap aggregation, " + "identity/action reconciliation, eligible provenance, package-contained " + "bounded reads, manifest type handling, standalone rights loading, and " + "literal-safe Make arguments." + ) + eighth_remediation_state = ( + "Remediation 9A through 9G closed every finding test-first. Independent scoped " + "re-reviews confirmed no remaining Critical or Important finding in each " + "corrected scope; the two Minor contracts now reject identical issuer/security " + "IDs and recursively freeze manifest semantics." + ) + freeze_reconciliation = ( + "Freeze reconciliation consolidated 21 overlapping remediation test files " + "into six domain suites and one shared fixture module, removed one exact " + "duplicate plus cross-remediation private imports, and closed five additional " + "local correctness gaps: ambiguous parents cannot authorize forks; pre-action " + "cutoffs do not poison later required coverage; decision-consumed " + "listing-state evidence is retained in eligible provenance; manifest nesting " + "is explicitly bounded; and structural source IDs cannot forge status output." + ) + local_verification = ( + "Full branch verification at freeze reconciliation is 4,084 passing tests, " + "one environment-limited socket test skipped, and one existing dependency " + "deprecation warning." + ) + remaining_boundary = ( + "The final fresh whole-slice review found one Important cutoff-relative " + "event regression; it was reproduced, fixed, and confirmed closed with no " + "remaining Critical or Important issue. The consolidated package was " + "synchronized at `69c49968e77bfd55fa259695089e1f34ac2fddfb`, and exact-head " + "GitHub Actions run `30185232040` passed" + ) + remaining_external_boundary = ( + "Real-data evidence remains pending; Priority 4 remains externally incomplete." + ) + controlled_failures = ( + "Duplicate JSON/YAML mapping keys and malformed contract headers also " + "fail nonzero, traceback-free, and write-free through the direct " + "validator and CLI/Make boundaries." + ) + + normalized_history = " ".join(history.split()) + for statement in ( + prior_review_closures, + v5_findings, + v5_remediation, + first_review_follow_up, + v6_finding, + v6_remediation, + v7_findings, + v7_remediation, + scoped_review_state, + eighth_review_state, + eighth_remediation_state, + freeze_reconciliation, + local_verification, + remaining_boundary, + remaining_external_boundary, + controlled_failures, + budgets, + scope_boundary, + external_gate, + ): + assert statement in normalized_history + + history_link = "docs/internal/POINT_IN_TIME_UNIVERSE_REVIEW_HISTORY.md" + for text in (roadmap, methodology, prompt): + normalized = " ".join(text.split()) + assert history_link in text + assert "Priority 4 remains externally incomplete" in normalized + assert budgets in normalized + assert scope_boundary in normalized + assert external_gate in normalized + assert prior_review_closures not in normalized + assert v5_findings not in normalized + assert eighth_review_state not in normalized + assert freeze_reconciliation not in normalized + + assert prior_review_closures in normalized_history + assert v5_findings in normalized_history + assert v5_remediation in normalized_history + assert first_review_follow_up in normalized_history + assert v6_finding in normalized_history + assert v6_remediation in normalized_history + assert v7_findings in normalized_history + assert v7_remediation in normalized_history + assert scoped_review_state in normalized_history + assert eighth_review_state in normalized_history + assert eighth_remediation_state in normalized_history + assert freeze_reconciliation in normalized_history + assert local_verification in normalized_history + assert remaining_boundary in normalized_history + assert remaining_external_boundary in normalized_history + assert controlled_failures in normalized_history + assert budgets in normalized_history + assert scope_boundary in normalized_history + assert external_gate in normalized_history diff --git a/tests/test_quarterly_business_trend.py b/tests/test_quarterly_business_trend.py new file mode 100644 index 00000000..40f03c3c --- /dev/null +++ b/tests/test_quarterly_business_trend.py @@ -0,0 +1,380 @@ +from src.earnings_nowcast_contract import QuarterlyActual +import csv + +from src.quarterly_cash_generation import QuarterlyBusinessObservation + +from src.quarterly_business_trend import ( + build_quarterly_trend_packet_from_load, + build_quarterly_trend_packet, + load_quarterly_actuals_csv, + quarterly_trend_rows, +) + + +def _actual( + period: str, + *, + revenue: float | None = None, + eps: float | None = None, + source_ref: str | None = None, + supersedes: str | None = None, + revenue_currency: str = "USD", + revenue_basis: str = "reported", + eps_basis: str = "gaap", + split_adjustment_basis: str = "as_reported", +) -> QuarterlyActual: + year = int(period[:4]) + quarter = int(period[-1]) + month_day = {1: "03-31", 2: "06-30", 3: "09-30", 4: "12-31"}[quarter] + return QuarterlyActual( + ticker="SYN1", + fiscal_period=period, + period_end_date=f"{year}-{month_day}", + reported_at=f"{year + (1 if quarter == 4 else 0)}-{('02-15' if quarter == 4 else '05-15')}T12:00:00+00:00", + revenue_actual=revenue, + eps_actual=eps, + source="synthetic_test_fixture", + source_ref=source_ref or f"fixture:{period}", + retrieved_at="2026-07-17T12:00:00+00:00", + revenue_currency=revenue_currency, + revenue_unit_scale=1.0, + revenue_basis=revenue_basis, + eps_currency="USD", + eps_basis=eps_basis, + eps_share_basis="diluted", + eps_operations_basis="reported", + split_adjustment_basis=split_adjustment_basis, + supersedes_source_ref=supersedes, + ) + + +def _business_observation( + period: str, + metric: str, + value: float, + *, + source_ref: str | None = None, + supersedes: str | None = None, +) -> QuarterlyBusinessObservation: + year = int(period[:4]) + quarter = int(period[-1]) + month_day = {1: "03-31", 2: "06-30", 3: "09-30", 4: "12-31"}[quarter] + return QuarterlyBusinessObservation( + ticker="SYN1", + fiscal_period=period, + period_end_date=f"{year}-{month_day}", + metric=metric, + value=value, + currency="USD", + unit_scale=1.0, + accounting_basis="reported", + duration_basis="three_months", + source="synthetic_test_fixture", + source_ref=source_ref or f"fixture:{period}:{metric}", + published_at=f"{year + (1 if quarter == 4 else 0)}-{('02-15' if quarter == 4 else '05-15')}T12:00:00+00:00", + retrieved_at="2026-07-18T12:00:00+00:00", + q4_evidence_state="explicit_filed_quarter" if quarter == 4 else "not_q4", + supersedes_source_ref=supersedes, + ) + + +def _business_history() -> list[QuarterlyBusinessObservation]: + values = { + "2024-Q1": {"operating_income": 20, "cash_from_operations": 24, "capital_expenditures": -8}, + "2024-Q4": {"operating_income": 20, "cash_from_operations": 30, "capital_expenditures": -10}, + "2025-Q1": {"operating_income": 30, "cash_from_operations": 36, "capital_expenditures": -12}, + } + return [ + _business_observation(period, metric, value) + for period, metrics in values.items() + for metric, value in metrics.items() + ] + + +def test_quarterly_trend_calculates_sequential_and_year_over_year_only_from_explicit_periods(): + packet = build_quarterly_trend_packet( + "SYN1", + [ + _actual("2024-Q1", revenue=80, eps=0.8), + _actual("2024-Q4", revenue=100, eps=1.0), + _actual("2025-Q1", revenue=120, eps=1.2), + ], + as_of="2026-07-17T23:59:59+00:00", + ) + + assert packet.status == "ready" + assert packet.latest_fiscal_period == "2025-Q1" + assert packet.revenue.latest_value == 120 + assert packet.revenue.sequential_change_pct == 20.0 + assert packet.revenue.year_over_year_change_pct == 50.0 + assert packet.eps.sequential_change_pct == 20.0 + assert packet.eps.year_over_year_change_pct == 50.0 + assert packet.withheld_metrics == ("operating_margin", "free_cash_flow", "fcf_margin") + assert packet.q4_policy == "explicit_filed_quarter_only" + + +def test_quarterly_trend_keeps_supplemental_metrics_withheld_without_observations(): + packet = build_quarterly_trend_packet( + "SYN1", + [_actual("2025-Q1", revenue=120, eps=1.2)], + ) + + assert packet.operating_margin.status == "withheld" + assert packet.free_cash_flow.status == "withheld" + assert packet.fcf_margin.status == "withheld" + assert packet.withheld_metrics == ("operating_margin", "free_cash_flow", "fcf_margin") + + +def test_quarterly_trend_composes_supplemental_changes_without_changing_revenue_or_eps(): + packet = build_quarterly_trend_packet( + "SYN1", + [ + _actual("2024-Q1", revenue=80, eps=0.8), + _actual("2024-Q4", revenue=100, eps=1.0), + _actual("2025-Q1", revenue=120, eps=1.2), + ], + business_observations=_business_history(), + ) + + assert packet.revenue.latest_value == 120 + assert packet.revenue.sequential_change_pct == 20.0 + assert packet.eps.latest_value == 1.2 + assert packet.eps.year_over_year_change_pct == 50.0 + assert packet.operating_margin.status == "ready" + assert packet.operating_margin.latest_value == 0.25 + assert packet.operating_margin.sequential_change_pct == 25.0 + assert packet.operating_margin.year_over_year_change_pct == 0.0 + assert packet.free_cash_flow.status == "ready" + assert packet.free_cash_flow.latest_value == 24.0 + assert packet.free_cash_flow.sequential_change_pct == 20.0 + assert packet.free_cash_flow.year_over_year_change_pct == 50.0 + assert packet.fcf_margin.status == "ready" + assert packet.fcf_margin.latest_value == 0.2 + assert packet.fcf_margin.sequential_change_pct == 0.0 + assert packet.fcf_margin.year_over_year_change_pct == 0.0 + assert packet.withheld_metrics == () + + +def test_quarterly_trend_keeps_ambiguous_capex_blocker_independent(): + observations = [ + _business_observation("2025-Q1", "operating_income", 30), + _business_observation("2025-Q1", "cash_from_operations", 36), + _business_observation("2025-Q1", "capital_expenditures", -12, source_ref="fixture:capex:a"), + _business_observation("2025-Q1", "capital_expenditures", -18, source_ref="fixture:capex:b"), + ] + + packet = build_quarterly_trend_packet( + "SYN1", + [_actual("2025-Q1", revenue=120, eps=1.2)], + business_observations=observations, + ) + + assert packet.revenue.status == "partial" + assert packet.eps.status == "partial" + assert packet.operating_margin.status == "partial" + assert packet.operating_margin.latest_value == 0.25 + assert packet.free_cash_flow.status == "blocked" + assert packet.fcf_margin.status == "blocked" + assert "ambiguous" in packet.free_cash_flow.withheld_reason + + +def test_free_cash_flow_remains_independent_when_revenue_actuals_are_missing(): + observations = [ + _business_observation("2025-Q1", "cash_from_operations", 36), + _business_observation("2025-Q1", "capital_expenditures", -12), + ] + + packet = build_quarterly_trend_packet( + "SYN1", + [], + business_observations=observations, + ) + + assert packet.status == "blocked" + assert packet.revenue.status == "blocked" + assert packet.free_cash_flow.status == "partial" + assert packet.free_cash_flow.latest_value == 24.0 + assert packet.fcf_margin.status == "blocked" + + +def test_quarterly_trend_resolves_explicit_revision_lineage(): + original = _actual("2025-Q4", revenue=100, eps=1.0, source_ref="fixture:original") + revised = _actual( + "2025-Q4", + revenue=110, + eps=1.1, + source_ref="fixture:revised", + supersedes="fixture:original", + ) + + packet = build_quarterly_trend_packet("SYN1", [original, revised]) + + assert packet.latest_fiscal_period == "2025-Q4" + assert packet.revenue.latest_value == 110 + assert packet.revenue.latest_source_ref == "fixture:revised" + assert packet.revision_count == 1 + + +def test_quarterly_trend_fails_closed_for_conflicting_unversioned_periods(): + packet = build_quarterly_trend_packet( + "SYN1", + [ + _actual("2025-Q4", revenue=100, eps=1.0, source_ref="fixture:a"), + _actual("2025-Q4", revenue=130, eps=1.3, source_ref="fixture:b"), + ], + ) + + assert packet.status == "blocked" + assert packet.revenue.status == "blocked" + assert packet.eps.status == "blocked" + assert packet.ambiguous_periods == ("2025-Q4",) + assert "ambiguous" in packet.revenue.withheld_reason + + +def test_quarterly_trend_withholds_incompatible_metric_definitions_and_missing_comparisons(): + packet = build_quarterly_trend_packet( + "SYN1", + [ + _actual("2024-Q4", revenue=100, eps=1.0, revenue_currency="USD", eps_basis="gaap"), + _actual("2025-Q1", revenue=120, eps=1.2, revenue_currency="CAD", eps_basis="adjusted"), + ], + ) + + assert packet.revenue.status == "partial" + assert packet.revenue.sequential_change_pct is None + assert packet.revenue.year_over_year_change_pct is None + assert "incompatible" in packet.revenue.withheld_reason + assert packet.eps.sequential_change_pct is None + assert "incompatible" in packet.eps.withheld_reason + + +def test_quarterly_trend_blocks_companyfacts_unverified_eps_but_keeps_revenue(): + packet = build_quarterly_trend_packet( + "SYN1", + [ + _actual( + "2024-Q1", + revenue=80, + eps=0.8, + split_adjustment_basis="companyfacts_split_basis_unverified", + ), + _actual( + "2024-Q4", + revenue=100, + eps=1.0, + split_adjustment_basis="companyfacts_split_basis_unverified", + ), + _actual( + "2025-Q1", + revenue=120, + eps=1.2, + split_adjustment_basis="companyfacts_split_basis_unverified", + ), + ], + ) + + assert packet.status == "partial" + assert packet.revenue.status == "ready" + assert packet.revenue.latest_value == 120 + assert packet.eps.status == "blocked" + assert packet.eps.latest_value is None + assert "split basis" in packet.eps.withheld_reason.lower() + + +def test_quarterly_trend_never_uses_mixed_unverified_eps_periods(): + packet = build_quarterly_trend_packet( + "SYN1", + [ + _actual("2024-Q1", revenue=80, eps=0.8), + _actual("2024-Q4", revenue=100, eps=1.0), + _actual( + "2025-Q1", + revenue=120, + eps=99.0, + split_adjustment_basis="companyfacts_split_basis_unverified", + ), + ], + ) + + assert packet.eps.status == "partial" + assert packet.eps.latest_fiscal_period == "2024-Q4" + assert packet.eps.latest_value == 1.0 + assert "2025-Q1" in packet.eps.withheld_reason + + +def test_quarterly_trend_does_not_derive_missing_q4_or_invent_unsupported_metrics(): + packet = build_quarterly_trend_packet("SYN1", [_actual("2025-Q3", revenue=90, eps=0.9)]) + rows = quarterly_trend_rows(packet) + + assert packet.status == "partial" + assert packet.latest_fiscal_period == "2025-Q3" + assert packet.revenue.sequential_change_pct is None + assert "previous quarter unavailable" in packet.revenue.missing_comparisons + assert {row["Metric"] for row in rows} == {"Revenue", "EPS", "Operating margin", "Free cash flow", "FCF margin"} + unsupported = [row for row in rows if row["Metric"] == "Operating margin"][0] + assert unsupported["State"] == "withheld" + assert "explicit versioned quarterly source contract" in unsupported["Boundary"] + + +def test_quarterly_trend_filters_post_cutoff_and_other_tickers(): + future = _actual("2025-Q4", revenue=200, eps=2.0) + other = QuarterlyActual(**{**future.__dict__, "ticker": "OTHER", "source_ref": "fixture:other"}) + + packet = build_quarterly_trend_packet( + "SYN1", + [future, other], + as_of="2025-01-01T00:00:00+00:00", + ) + + assert packet.status == "blocked" + assert packet.available_periods == () + assert packet.message == "No source-backed quarterly actual is available by the review cutoff." + + +def test_quarterly_actual_csv_loader_validates_rows_and_reports_rejections(tmp_path): + path = tmp_path / "quarterly_actuals.csv" + fields = list(_actual("2025-Q1", revenue=100, eps=1.0).__dict__) + valid = {key: getattr(_actual("2025-Q1", revenue=100, eps=1.0), key) for key in fields} + invalid = {**valid, "fiscal_period": "2025-FY", "source_ref": "fixture:invalid"} + with path.open("w", newline="", encoding="utf-8") as handle: + writer = csv.DictWriter(handle, fieldnames=fields) + writer.writeheader() + writer.writerow(valid) + writer.writerow(invalid) + + result = load_quarterly_actuals_csv(path) + + assert [row.fiscal_period for row in result.actuals] == ["2025-Q1"] + assert result.accepted_count == 1 + assert result.rejected_count == 1 + assert result.rejected_rows[0]["row_number"] == 3 + assert "fiscal_period" in result.rejected_rows[0]["reason"] + + +def test_partially_rejected_canonical_load_blocks_all_quarterly_trends(tmp_path): + path = tmp_path / "quarterly_actuals.csv" + fields = list(_actual("2025-Q1", revenue=100, eps=1.0).__dict__) + valid = {key: getattr(_actual("2025-Q1", revenue=100, eps=1.0), key) for key in fields} + invalid = {**valid, "fiscal_period": "2025-FY", "source_ref": "fixture:invalid"} + with path.open("w", newline="", encoding="utf-8") as handle: + writer = csv.DictWriter(handle, fieldnames=fields) + writer.writeheader() + writer.writerow(valid) + writer.writerow(invalid) + + packet = build_quarterly_trend_packet_from_load( + "SYN1", load_quarterly_actuals_csv(path) + ) + + assert packet.status == "blocked" + assert packet.available_periods == () + assert packet.revenue.status == "blocked" + assert packet.eps.status == "blocked" + assert packet.operating_margin.status == "blocked" + assert packet.free_cash_flow.status == "blocked" + assert packet.fcf_margin.status == "blocked" + assert packet.canonical_rejected_rows == ( + {"row_number": 3, "reason": packet.canonical_rejected_rows[0]["reason"]}, + ) + assert "fiscal_period" in packet.canonical_rejected_rows[0]["reason"] + assert "partially rejected" in packet.message.lower() diff --git a/tests/test_quarterly_cash_generation.py b/tests/test_quarterly_cash_generation.py new file mode 100644 index 00000000..9bb5df2f --- /dev/null +++ b/tests/test_quarterly_cash_generation.py @@ -0,0 +1,214 @@ +import math + +import pytest + +from src.earnings_nowcast_contract import QuarterlyActual +from src.quarterly_cash_generation import ( + QuarterlyBusinessObservation, + derive_quarterly_business_metrics, +) + + +def _observation( + period="2025-Q1", + metric="cash_from_operations", + value=100.0, + **overrides, +): + year = int(period[:4]) + quarter = int(period[-1]) + month_day = {1: "03-31", 2: "06-30", 3: "09-30", 4: "12-31"}[quarter] + values = { + "ticker": "syn1", + "fiscal_period": period, + "period_end_date": f"{year}-{month_day}", + "metric": metric, + "value": value, + "currency": "usd", + "unit_scale": 1.0, + "accounting_basis": "gaap", + "duration_basis": "three_months", + "source": "synthetic_test_fixture", + "source_ref": f"fixture:{period}:{metric}", + "published_at": "2025-05-15T12:00:00+00:00", + "retrieved_at": "2026-07-18T12:00:00+00:00", + "q4_evidence_state": "explicit_filed_quarter" if quarter == 4 else "not_q4", + "supersedes_source_ref": None, + } + values.update(overrides) + return QuarterlyBusinessObservation(**values) + + +def _actual( + period="2025-Q1", + *, + revenue=200.0, + currency="USD", + unit_scale=1.0, + basis="gaap", + source_ref=None, +): + year = int(period[:4]) + quarter = int(period[-1]) + month_day = {1: "03-31", 2: "06-30", 3: "09-30", 4: "12-31"}[quarter] + return QuarterlyActual( + ticker="SYN1", + fiscal_period=period, + period_end_date=f"{year}-{month_day}", + reported_at="2025-05-15T12:00:00+00:00", + revenue_actual=revenue, + eps_actual=None, + source="synthetic_test_fixture", + source_ref=source_ref or f"fixture:{period}:revenue", + retrieved_at="2026-07-18T12:00:00+00:00", + revenue_currency=currency, + revenue_unit_scale=unit_scale, + revenue_basis=basis, + ) + + +def test_observation_normalizes_identity_and_preserves_reported_capex_sign(): + row = _observation(metric="capital_expenditures", value=-20.0) + + assert row.ticker == "SYN1" + assert row.currency == "USD" + assert row.metric == "capital_expenditures" + assert row.value == -20.0 + assert row.published_at == "2025-05-15T12:00:00+00:00" + + +@pytest.mark.parametrize( + ("overrides", "message"), + [ + ({"metric": "ebitda"}, "unsupported quarterly business metric"), + ({"fiscal_period": "2025-FY"}, "fiscal_period must use"), + ({"value": math.inf}, "value must be finite"), + ({"unit_scale": 0}, "unit_scale must be positive"), + ({"published_at": "2025-05-15T12:00:00"}, "timezone-aware"), + ({"currency": ""}, "currency is required"), + ], +) +def test_observation_rejects_invalid_evidence(overrides, message): + with pytest.raises(ValueError, match=message): + _observation(**overrides) + + +def test_observation_requires_explicit_q4_and_rejects_q4_state_on_other_quarters(): + with pytest.raises(ValueError, match="Q4 requires explicit filed-quarter evidence"): + _observation(period="2025-Q4", q4_evidence_state="not_q4") + with pytest.raises(ValueError, match="non-Q4 evidence must use not_q4"): + _observation(period="2025-Q1", q4_evidence_state="explicit_filed_quarter") + + +def test_derivation_uses_explicit_formula_and_preserves_component_sources(): + result = derive_quarterly_business_metrics( + "SYN1", + [ + _observation(metric="operating_income", value=50.0), + _observation(metric="cash_from_operations", value=60.0), + _observation(metric="capital_expenditures", value=-20.0), + ], + [_actual(revenue=200.0)], + ) + + points = {point.metric: point for point in result.points} + assert points["operating_margin"].value == 0.25 + assert points["free_cash_flow"].value == 40.0 + assert points["fcf_margin"].value == 0.20 + assert points["free_cash_flow"].source_refs == ( + "fixture:2025-Q1:cash_from_operations", + "fixture:2025-Q1:capital_expenditures", + ) + assert points["operating_margin"].source_refs == ( + "fixture:2025-Q1:operating_income", + "fixture:2025-Q1:revenue", + ) + assert result.blockers == () + assert result.supplied_observation_count == 3 + + +def test_derivation_filters_post_cutoff_without_fabricating_points(): + result = derive_quarterly_business_metrics( + "SYN1", + [_observation(published_at="2026-01-01T00:00:00+00:00")], + [_actual()], + as_of="2025-12-31T23:59:59+00:00", + ) + + assert result.points == () + assert "2025-Q1:cash_from_operations:post_cutoff" in result.blockers + + +def test_derivation_resolves_explicit_revision_lineage(): + original = _observation( + metric="operating_income", + value=40.0, + source_ref="fixture:operating:original", + ) + revised = _observation( + metric="operating_income", + value=50.0, + source_ref="fixture:operating:revised", + supersedes_source_ref="fixture:operating:original", + ) + + result = derive_quarterly_business_metrics("SYN1", [original, revised], [_actual()]) + point = next(point for point in result.points if point.metric == "operating_margin") + + assert point.value == 0.25 + assert point.source_refs[0] == "fixture:operating:revised" + assert result.revision_count == 1 + + +def test_ambiguous_capex_blocks_only_fcf_metrics(): + observations = [ + _observation(metric="operating_income", value=50.0), + _observation(metric="cash_from_operations", value=60.0), + _observation(metric="capital_expenditures", value=-20.0, source_ref="fixture:capex:a"), + _observation(metric="capital_expenditures", value=-30.0, source_ref="fixture:capex:b"), + ] + + result = derive_quarterly_business_metrics("SYN1", observations, [_actual()]) + points = {point.metric: point for point in result.points} + + assert points["operating_margin"].value == 0.25 + assert "free_cash_flow" not in points + assert "fcf_margin" not in points + assert "2025-Q1:capital_expenditures:ambiguous_revision" in result.blockers + + +@pytest.mark.parametrize( + "overrides", + [ + {"currency": "CAD"}, + {"unit_scale": 1_000.0}, + {"accounting_basis": "adjusted"}, + {"duration_basis": "year_to_date"}, + {"period_end_date": "2025-03-30"}, + ], +) +def test_incompatible_components_do_not_create_free_cash_flow(overrides): + cfo = _observation(metric="cash_from_operations", value=60.0) + capex = _observation(metric="capital_expenditures", value=-20.0, **overrides) + + result = derive_quarterly_business_metrics("SYN1", [cfo, capex], [_actual()]) + + assert not any(point.metric in {"free_cash_flow", "fcf_margin"} for point in result.points) + assert "2025-Q1:free_cash_flow:incompatible_components" in result.blockers + + +def test_incompatible_revenue_definition_blocks_margins_but_not_free_cash_flow(): + observations = [ + _observation(metric="operating_income", value=50.0, accounting_basis="reported"), + _observation(metric="cash_from_operations", value=60.0, accounting_basis="reported"), + _observation(metric="capital_expenditures", value=-20.0, accounting_basis="reported"), + ] + + result = derive_quarterly_business_metrics("SYN1", observations, [_actual(basis="gaap")]) + points = {point.metric: point for point in result.points} + + assert points["free_cash_flow"].value == 40.0 + assert "operating_margin" not in points + assert "fcf_margin" not in points + assert "2025-Q1:operating_margin:incompatible_revenue" in result.blockers + assert "2025-Q1:fcf_margin:incompatible_revenue" in result.blockers diff --git a/tests/test_quarterly_cash_generation_adapter.py b/tests/test_quarterly_cash_generation_adapter.py new file mode 100644 index 00000000..de088fc3 --- /dev/null +++ b/tests/test_quarterly_cash_generation_adapter.py @@ -0,0 +1,379 @@ +from pathlib import Path + +import pytest + +from src.commercial_source_rights import ( + build_source_rights_registry, + load_source_rights_registry, +) +from src.earnings_nowcast_contract import QuarterlyActual +from src.quarterly_cash_generation import QuarterlyBusinessObservation +from src.quarterly_cash_generation_adapter import ( + assess_quarterly_cash_generation_adapter, +) + + +def _rights_registry( + *, + commercial_use: str = "approved", + supported_fields: list[str] | None = None, +): + return build_source_rights_registry( + [ + { + "source_id": "synthetic_adapter", + "display_name": "Synthetic adapter fixture", + "permitted_use": "test_only", + "commercial_use": commercial_use, + "redistribution": "not_applicable_test_only", + "storage_limits": "in_memory_test_only", + "attribution": "synthetic test fixture", + "rate_limits": "not_applicable_test_only", + "authentication": "none", + "expected_freshness": "not_applicable_test_only", + "supported_fields": supported_fields + or [ + "operating_income", + "cash_from_operations", + "capital_expenditures", + ], + "fallback_priority": 1, + } + ] + ) + + +def _observation( + period: str = "2025-Q1", + metric: str = "cash_from_operations", + value: float = 100.0, + **overrides, +) -> QuarterlyBusinessObservation: + year = int(period[:4]) + quarter = int(period[-1]) + month_day = {1: "03-31", 2: "06-30", 3: "09-30", 4: "12-31"}[quarter] + values = { + "ticker": "SYN1", + "fiscal_period": period, + "period_end_date": f"{year}-{month_day}", + "metric": metric, + "value": value, + "currency": "USD", + "unit_scale": 1.0, + "accounting_basis": "gaap", + "duration_basis": "three_months", + "source": "synthetic_adapter", + "source_ref": f"fixture:{period}:{metric}", + "published_at": f"{year + (1 if quarter == 4 else 0)}-05-15T12:00:00+00:00", + "retrieved_at": "2026-07-18T12:00:00+00:00", + "q4_evidence_state": "explicit_filed_quarter" if quarter == 4 else "not_q4", + "supersedes_source_ref": None, + } + values.update(overrides) + return QuarterlyBusinessObservation(**values) + + +def _actual( + period: str = "2025-Q1", + *, + revenue: float = 200.0, + **overrides, +) -> QuarterlyActual: + year = int(period[:4]) + quarter = int(period[-1]) + month_day = {1: "03-31", 2: "06-30", 3: "09-30", 4: "12-31"}[quarter] + values = { + "ticker": "SYN1", + "fiscal_period": period, + "period_end_date": f"{year}-{month_day}", + "reported_at": f"{year + (1 if quarter == 4 else 0)}-05-15T12:00:00+00:00", + "revenue_actual": revenue, + "eps_actual": None, + "source": "synthetic_test_fixture", + "source_ref": f"fixture:{period}:revenue", + "retrieved_at": "2026-07-18T12:00:00+00:00", + "revenue_currency": "USD", + "revenue_unit_scale": 1.0, + "revenue_basis": "gaap", + } + values.update(overrides) + return QuarterlyActual(**values) + + +def _complete_rows(period: str = "2025-Q1") -> list[QuarterlyBusinessObservation]: + return [ + _observation(period, "operating_income", 50.0), + _observation(period, "cash_from_operations", 60.0), + _observation(period, "capital_expenditures", -20.0), + ] + + +def _assess( + observations, + *, + ticker: str = "SYN1", + source_id: str = "synthetic_adapter", + rights_registry=None, + revenue_actuals=None, + as_of: str | None = None, +): + return assess_quarterly_cash_generation_adapter( + ticker, + source_id, + observations, + [_actual()] if revenue_actuals is None else revenue_actuals, + rights_registry=rights_registry or _rights_registry(), + as_of=as_of, + ) + + +def test_complete_one_company_batch_is_accepted_for_review_without_activation(): + result = assess_quarterly_cash_generation_adapter( + "SYN1", + "synthetic_adapter", + _complete_rows(), + [_actual(revenue=200.0)], + rights_registry=_rights_registry(), + ) + + assert result.status == "accepted_for_review" + assert result.blockers == () + assert result.accepted_observation_count == 3 + assert result.reviewed_metrics == ( + "capital_expenditures", + "cash_from_operations", + "operating_income", + ) + assert result.derived_point_count == 3 + assert result.explicit_q4_periods == () + assert result.rights_status == "approved" + assert result.production_activation is False + assert result.readiness_promotions == () + + +@pytest.mark.parametrize( + ("ticker", "source_id", "observations", "blocker"), + [ + ("", "synthetic_adapter", (), "ticker_required"), + ("SYN1", "", (), "source_id_required"), + ("SYN1", "synthetic_adapter", (), "observations_required"), + ], +) +def test_required_identity_and_observations_fail_closed( + ticker, + source_id, + observations, + blocker, +): + result = _assess(observations, ticker=ticker, source_id=source_id) + + assert result.status == "blocked" + assert blocker in result.blockers + assert result.accepted_observation_count == 0 + assert result.production_activation is False + assert result.readiness_promotions == () + + +def test_mixed_ticker_and_source_mismatch_are_reported_together(): + rows = [ + _observation(), + _observation(ticker="OTHER", source_ref="fixture:other:ticker"), + _observation( + metric="operating_income", + source="other_source", + source_ref="fixture:other:source", + ), + ] + + result = _assess(rows) + + assert "mixed_ticker:OTHER" in result.blockers + assert "source_mismatch:other_source" in result.blockers + assert result.status == "blocked" + + +def test_unknown_or_unverified_rights_block_acceptance(): + unknown = _assess(_complete_rows(), source_id="unknown") + unverified = _assess( + _complete_rows(), + rights_registry=_rights_registry(commercial_use="unverified"), + ) + + assert "source_rights:unknown_source" in unknown.blockers + assert "source_rights:commercial_rights_unverified" in unverified.blockers + + +def test_approved_source_must_explicitly_support_every_component(): + result = _assess( + _complete_rows(), + rights_registry=_rights_registry(supported_fields=["operating_income"]), + ) + + assert result.blockers == ( + "source_fields_missing:capital_expenditures,cash_from_operations", + ) + assert result.status == "blocked" + + +def test_post_cutoff_observations_block_acceptance(): + result = _assess( + _complete_rows(), + as_of="2025-05-14T23:59:59+00:00", + ) + + assert "2025-Q1:operating_income:post_cutoff" in result.blockers + assert "2025-Q1:cash_from_operations:post_cutoff" in result.blockers + assert "2025-Q1:capital_expenditures:post_cutoff" in result.blockers + assert "complete_derived_period_required" in result.blockers + assert result.status == "blocked" + + +def test_explicit_revision_leaf_is_accepted(): + original = _observation( + metric="operating_income", + value=40.0, + source_ref="fixture:operating:original", + ) + revised = _observation( + metric="operating_income", + value=50.0, + source_ref="fixture:operating:revised", + supersedes_source_ref="fixture:operating:original", + ) + rows = [ + original, + revised, + _observation(metric="cash_from_operations", value=60.0), + _observation(metric="capital_expenditures", value=-20.0), + ] + + result = _assess(rows) + + assert result.status == "accepted_for_review" + assert result.blockers == () + assert result.accepted_observation_count == 4 + assert result.derived_point_count == 3 + + +def test_ambiguous_revision_blocks_complete_period_acceptance(): + rows = [ + _observation(metric="operating_income", value=50.0), + _observation(metric="cash_from_operations", value=60.0), + _observation( + metric="capital_expenditures", + value=-20.0, + source_ref="fixture:capex:a", + ), + _observation( + metric="capital_expenditures", + value=-30.0, + source_ref="fixture:capex:b", + ), + ] + + result = _assess(rows) + + assert "2025-Q1:capital_expenditures:ambiguous_revision" in result.blockers + assert "complete_derived_period_required" in result.blockers + assert result.status == "blocked" + + +def test_missing_component_is_named_and_blocks_complete_period(): + rows = [ + _observation(metric="operating_income", value=50.0), + _observation(metric="cash_from_operations", value=60.0), + ] + + result = _assess(rows) + + assert "2025-Q1:missing_component:capital_expenditures" in result.blockers + assert "complete_derived_period_required" in result.blockers + assert result.status == "blocked" + + +def test_missing_revenue_preserves_fcf_but_blocks_complete_period_acceptance(): + result = _assess(_complete_rows(), revenue_actuals=[]) + + assert "2025-Q1:operating_margin:incompatible_revenue" in result.blockers + assert "2025-Q1:fcf_margin:incompatible_revenue" in result.blockers + assert "complete_derived_period_required" in result.blockers + assert result.derived_point_count == 1 + assert result.status == "blocked" + + +def test_incompatible_cash_flow_components_block_complete_period_acceptance(): + rows = [ + _observation(metric="operating_income", value=50.0), + _observation(metric="cash_from_operations", value=60.0), + _observation(metric="capital_expenditures", value=-20.0, currency="CAD"), + ] + + result = _assess(rows) + + assert "2025-Q1:free_cash_flow:incompatible_components" in result.blockers + assert "complete_derived_period_required" in result.blockers + assert result.status == "blocked" + + +def test_explicit_filed_q4_batch_is_accepted_and_recorded(): + result = _assess( + _complete_rows("2025-Q4"), + revenue_actuals=[_actual("2025-Q4")], + ) + + assert result.status == "accepted_for_review" + assert result.explicit_q4_periods == ("2025-Q4",) + assert result.blockers == () + + +def test_q4_without_explicit_filed_quarter_is_rejected_before_acceptance(): + with pytest.raises(ValueError, match="Q4 requires explicit filed-quarter evidence"): + _observation("2025-Q4", q4_evidence_state="not_q4") + + +def test_checked_in_sec_rights_explicitly_support_reviewed_cash_components(): + rows = [ + _observation( + metric=row.metric, + value=row.value, + source="sec_companyfacts", + source_ref=( + "https://www.sec.gov/Archives/edgar/data/1/example.htm" + f"#{row.metric}" + ), + ) + for row in _complete_rows() + ] + + result = _assess( + rows, + source_id="sec_companyfacts", + rights_registry=load_source_rights_registry(), + ) + + assert result.status == "accepted_for_review" + assert result.blockers == () + assert result.rights_status == "approved" + assert result.production_activation is False + assert result.readiness_promotions == () + + +def test_adapter_acceptance_module_has_no_file_network_or_cli_surface(): + source = Path("src/quarterly_cash_generation_adapter.py").read_text(encoding="utf-8") + makefile = Path("Makefile").read_text(encoding="utf-8") + + for forbidden in ( + "argparse", + "requests", + "urllib", + "Path(", + ".open(", + "read_text(", + "write_text(", + "csv", + "json", + "output_dir", + "__main__", + ): + assert forbidden not in source + assert "quarterly-cash-generation-adapter" not in makefile diff --git a/tests/test_readiness_engine.py b/tests/test_readiness_engine.py index e1dc4931..beb17b53 100644 --- a/tests/test_readiness_engine.py +++ b/tests/test_readiness_engine.py @@ -2,7 +2,55 @@ import pandas as pd -from src.readiness_engine import build_ticker_readiness_report, save_previous_ticker_readiness_snapshot +from src.readiness_engine import ( + build_peer_readiness_report, + build_ticker_readiness_report, + save_previous_ticker_readiness_snapshot, +) + + +def _file_manifest(root: Path) -> dict[str, bytes]: + return { + path.relative_to(root).as_posix(): path.read_bytes() + for path in root.rglob("*") + if path.is_file() + } + + +def test_ticker_readiness_no_write_returns_reports_without_mutating_files(tmp_path: Path, monkeypatch): + monkeypatch.delenv("STOOQ_API_KEY", raising=False) + monkeypatch.delenv("SEC_USER_AGENT", raising=False) + data_dir = tmp_path / "data" + data_dir.mkdir() + pd.DataFrame( + [ + { + "ticker": "NVDA", + "name": "NVIDIA", + "exchange": "NASDAQ", + "asset_type": "company", + "sector": "Technology", + "source": "test_fixture", + } + ] + ).to_csv(data_dir / "universe_master.csv", index=False) + pd.DataFrame( + [{"ticker": "NVDA", "scope": "active_research", "theme": "AI"}] + ).to_csv(data_dir / "universe_active.csv", index=False) + before = _file_manifest(tmp_path) + + reports = build_ticker_readiness_report( + tmp_path, + data_dir=data_dir, + output_dir=tmp_path / "outputs", + write_outputs=False, + ) + + assert "ticker_readiness_report" in reports + assert "data_source_status" in reports + assert set(reports["ticker_readiness_report"]["ticker"]) == {"NVDA"} + assert _file_manifest(tmp_path) == before + assert not (tmp_path / "outputs").exists() def _price_rows(ticker: str, periods: int) -> list[dict[str, object]]: @@ -754,8 +802,8 @@ def test_readiness_requires_source_and_minimum_ready_peer_metrics(tmp_path: Path ).to_csv(data_dir / "fundamentals.csv", index=False) pd.DataFrame( [ - {"ticker": "AAA", "peer_ticker": "BBB", "peer_group": "Test", "source": "fixture"}, - {"ticker": "AAA", "peer_ticker": "CCC", "peer_group": "Test", "source": "fixture"}, + {"ticker": "AAA", "peer_ticker": "BBB", "peer_group": "Test", "peer_role": "core_peer", "relationship_rationale": "Synthetic fixture overlap", "comparability_basis": "business model; growth and margin", "valuation_anchor_eligible": "yes", "source": "fixture", "as_of_date": "2026-06-30"}, + {"ticker": "AAA", "peer_ticker": "CCC", "peer_group": "Test", "peer_role": "secondary_peer", "relationship_rationale": "Synthetic fixture overlap", "comparability_basis": "business model; growth and margin", "valuation_anchor_eligible": "yes", "source": "fixture", "as_of_date": "2026-06-30"}, ] ).to_csv(data_dir / "peers.csv", index=False) pd.DataFrame(columns=["ticker", "source"]).to_csv(data_dir / "earnings.csv", index=False) @@ -860,8 +908,8 @@ def test_peer_valuation_comparison_requires_dcf_ready_peer_inputs(tmp_path: Path ).to_csv(data_dir / "fundamentals.csv", index=False) pd.DataFrame( [ - {"ticker": "AAA", "peer_ticker": "BBB", "peer_group": "Test", "source": "fixture"}, - {"ticker": "AAA", "peer_ticker": "CCC", "peer_group": "Test", "source": "fixture"}, + {"ticker": "AAA", "peer_ticker": "BBB", "peer_group": "Test", "peer_role": "core_peer", "relationship_rationale": "Synthetic fixture overlap", "comparability_basis": "business model; growth and margin", "valuation_anchor_eligible": "yes", "source": "fixture", "as_of_date": "2026-06-30"}, + {"ticker": "AAA", "peer_ticker": "CCC", "peer_group": "Test", "peer_role": "secondary_peer", "relationship_rationale": "Synthetic fixture overlap", "comparability_basis": "business model; growth and margin", "valuation_anchor_eligible": "yes", "source": "fixture", "as_of_date": "2026-06-30"}, ] ).to_csv(data_dir / "peers.csv", index=False) pd.DataFrame(columns=["ticker", "source"]).to_csv(data_dir / "earnings.csv", index=False) @@ -880,3 +928,74 @@ def test_peer_valuation_comparison_requires_dcf_ready_peer_inputs(tmp_path: Path assert worklist.loc["AAA", "workflow_group"] == "peer_valuation_unlock" assert worklist.loc["AAA", "peer_trend_status"] == "peer_trend_possible" assert worklist.loc["AAA", "peer_valuation_status"] == "peer_valuation_blocked" + + +def test_peer_trend_readiness_stays_independent_when_valuation_anchor_evidence_is_missing(tmp_path: Path): + data_dir = tmp_path / "data" + data_dir.mkdir() + master = pd.DataFrame( + [ + {"ticker": "AAA", "asset_type": "company"}, + {"ticker": "BBB", "asset_type": "company"}, + {"ticker": "CCC", "asset_type": "company"}, + ] + ) + master.to_csv(data_dir / "universe_master.csv", index=False) + pd.DataFrame(_price_rows("AAA", 60) + _price_rows("BBB", 60) + _price_rows("CCC", 60)).to_csv( + data_dir / "prices.csv", index=False + ) + pd.DataFrame( + [ + {"ticker": ticker, "revenue": 100, "free_cash_flow": 20, "fcf_margin": 0.2, "shares_outstanding": 10, "source": "fixture"} + for ticker in ("AAA", "BBB", "CCC") + ] + ).to_csv(data_dir / "fundamentals.csv", index=False) + pd.DataFrame( + [ + {"ticker": "AAA", "peer_ticker": "BBB", "peer_group": "Test", "source": "fixture", "as_of_date": "2026-06-30"}, + {"ticker": "AAA", "peer_ticker": "CCC", "peer_group": "Test", "source": "fixture", "as_of_date": "2026-06-30"}, + ] + ).to_csv(data_dir / "peers.csv", index=False) + pd.DataFrame(columns=["ticker", "peer_ticker", "candidate_state"]).to_csv(data_dir / "peer_candidates.csv", index=False) + + report = build_peer_readiness_report(tmp_path, data_dir, master, {"price_ready": {"min_rows": 5}, "momentum_ready": {"min_rows": 20}, "peer_ready": {"min_peers": 2}}).set_index("ticker") + + assert bool(report.loc["AAA", "peer_trend_comparison_ready"]) is True + assert bool(report.loc["AAA", "peer_valuation_ready"]) is False + assert int(report.loc["AAA", "peer_valuation_anchor_eligible_count"]) == 0 + assert report.loc["AAA", "peer_blocker_type"] == "peer_comparability_unreviewed" + assert "peer role" in report.loc["AAA", "next_peer_action"].lower() + + +def test_only_explicitly_eligible_peers_can_unlock_peer_valuation_readiness(tmp_path: Path): + data_dir = tmp_path / "data" + data_dir.mkdir() + master = pd.DataFrame( + [ + {"ticker": "AAA", "asset_type": "company"}, + {"ticker": "BBB", "asset_type": "company"}, + {"ticker": "CCC", "asset_type": "company"}, + ] + ) + pd.DataFrame(_price_rows("AAA", 60) + _price_rows("BBB", 60) + _price_rows("CCC", 60)).to_csv( + data_dir / "prices.csv", index=False + ) + pd.DataFrame( + [ + {"ticker": ticker, "revenue": 100, "free_cash_flow": 20, "fcf_margin": 0.2, "shares_outstanding": 10, "source": "fixture"} + for ticker in ("AAA", "BBB", "CCC") + ] + ).to_csv(data_dir / "fundamentals.csv", index=False) + pd.DataFrame( + [ + {"ticker": "AAA", "peer_ticker": peer, "peer_group": "Test", "peer_role": role, "relationship_rationale": "Reviewed operating overlap", "comparability_basis": "business model; growth and margin", "valuation_anchor_eligible": "yes", "source": "fixture", "as_of_date": "2026-06-30"} + for peer, role in (("BBB", "core_peer"), ("CCC", "secondary_peer")) + ] + ).to_csv(data_dir / "peers.csv", index=False) + pd.DataFrame(columns=["ticker", "peer_ticker", "candidate_state"]).to_csv(data_dir / "peer_candidates.csv", index=False) + + report = build_peer_readiness_report(tmp_path, data_dir, master, {"price_ready": {"min_rows": 5}, "momentum_ready": {"min_rows": 20}, "peer_ready": {"min_peers": 2}}).set_index("ticker") + + assert int(report.loc["AAA", "peer_valuation_anchor_eligible_count"]) == 2 + assert bool(report.loc["AAA", "peer_valuation_ready"]) is True + assert report.loc["AAA", "peer_valuation_anchor_blockers"] == "" diff --git a/tests/test_readiness_ops.py b/tests/test_readiness_ops.py index 5f679b48..5b489770 100644 --- a/tests/test_readiness_ops.py +++ b/tests/test_readiness_ops.py @@ -21,6 +21,7 @@ render_readiness_ops_evidence, ) from src.dcf_input_proof_queue import DcfInputProofRow +from src.continuation_gate import ContinuationGate def _write(path: Path, text: str) -> None: @@ -590,6 +591,27 @@ def test_coverage_frontier_ranks_batch_lanes_without_implying_data_available(tmp assert "make optional-context-source-ladder-queue TOP_N=10" in rendered +def test_coverage_frontier_marks_ranked_rows_planning_only_when_readiness_is_stale(tmp_path: Path): + lanes = build_readiness_ops_lanes(_sample_root(tmp_path)) + frontier = build_coverage_frontier(lanes, top_n=2) + gate = ContinuationGate( + state="inspection_only", + next_safe_command="make readiness-preview TOP_N=20", + reason="Selected-profile source dates are newer than saved readiness.", + rebuild_command="make readiness", + stop_rule="Do not start broad refresh or source-proof work.", + suppress_execution=True, + ) + + rendered = render_coverage_frontier(frontier, continuation_gate=gate) + + assert "Stale readiness continuation gate: inspection_only" in rendered + assert "Next safe preview: make readiness-preview TOP_N=20" in rendered + assert "Ranked rows below are planning context only" in rendered + assert "make readiness is a separate intentional reviewed write" in rendered + assert rendered.index("Stale readiness continuation gate") < rendered.index("1.") + + def test_data_coverage_expansion_plan_keeps_batches_proof_gated_and_read_only(tmp_path: Path): lanes = build_readiness_ops_lanes(_sample_root(tmp_path)) steps = build_data_coverage_expansion_plan(lanes, top_n=10) diff --git a/tests/test_readiness_preview.py b/tests/test_readiness_preview.py new file mode 100644 index 00000000..dde7f0ed --- /dev/null +++ b/tests/test_readiness_preview.py @@ -0,0 +1,422 @@ +from pathlib import Path + +import pandas as pd + +from src import readiness_preview +from src.commercial_source_rights import SourceRights +from src.readiness_preview import ( + build_readiness_impact_preview, + compare_readiness_frames, + render_readiness_impact_preview, + review_readiness_changes, + review_readiness_promotions, +) + + +def _row(ticker: str, **overrides: object) -> dict[str, object]: + row: dict[str, object] = { + "ticker": ticker, + "overall_readiness_state": "blocked", + "price_ready": False, + "momentum_ready": False, + "fundamentals_ready": False, + "dcf_ready": False, + "peer_ready": False, + "earnings_ready": False, + "analyst_estimates_ready": False, + "ready_features": "", + "partial_features": "", + "blocked_features": "price, fundamentals", + "excluded_features": "", + "updated_at": "2026-01-01T00:00:00+00:00", + } + row.update(overrides) + return row + + +def _file_manifest(root: Path) -> dict[str, bytes]: + return { + path.relative_to(root).as_posix(): path.read_bytes() + for path in root.rglob("*") + if path.is_file() + } + + +def _rights_registry() -> dict[str, SourceRights]: + return { + "sec_companyfacts": SourceRights( + source_id="sec_companyfacts", + display_name="SEC Companyfacts", + permitted_use="source_backed_company_facts", + commercial_use="approved", + redistribution="derived_data_only", + storage_limits="reviewed local facts", + attribution="SEC EDGAR", + rate_limits="fair access", + authentication="SEC_USER_AGENT", + expected_freshness="filing driven", + supported_fields=("revenue", "shares_outstanding", "filing_dates"), + fallback_priority=1, + ), + "yfinance": SourceRights( + source_id="yfinance", + display_name="yfinance", + permitted_use="research_only", + commercial_use="unverified", + redistribution="not_permitted", + storage_limits="local cache", + attribution="Yahoo Finance", + rate_limits="upstream terms", + authentication="none", + expected_freshness="market dependent", + supported_fields=("prices",), + fallback_priority=90, + ), + "approved_prices": SourceRights( + source_id="approved_prices", + display_name="Approved Price Evidence", + permitted_use="source_backed_market_data", + commercial_use="approved", + redistribution="derived_data_only", + storage_limits="reviewed local rows", + attribution="required", + rate_limits="provider terms", + authentication="provider specific", + expected_freshness="daily", + supported_fields=("prices",), + fallback_priority=2, + ), + } + + +def test_compare_ignores_updated_at_but_reports_stable_fields(): + saved = pd.DataFrame([_row("AAA")]) + timestamp_only = pd.DataFrame([_row("AAA", updated_at="2026-07-18T00:00:00+00:00")]) + changed = pd.DataFrame( + [ + _row( + "AAA", + price_ready=True, + ready_features="price", + blocked_features="fundamentals", + updated_at="2026-07-18T00:00:00+00:00", + ) + ] + ) + + assert compare_readiness_frames(saved, timestamp_only, top_n=20).status == "no_readiness_changes" + preview = compare_readiness_frames(saved, changed, top_n=20) + + assert preview.status == "changes_detected" + assert preview.changed_ticker_count == 1 + assert preview.changed_tickers[0].ticker == "AAA" + assert preview.changed_tickers[0].fields == ("price_ready", "ready_features", "blocked_features") + + +def test_compare_caps_detail_and_preserves_total_with_added_and_removed_rows(): + saved = pd.DataFrame([_row("AAA"), _row("BBB")]) + proposed = pd.DataFrame([_row("CCC"), _row("DDD")]) + + preview = compare_readiness_frames(saved, proposed, top_n=2) + + assert preview.changed_ticker_count == 4 + assert [change.ticker for change in preview.changed_tickers] == ["AAA", "BBB"] + assert all(change.fields == ("row_presence",) for change in preview.changed_tickers) + + +def test_promotion_review_keeps_technical_changes_separate_from_evidence_gates(): + saved = pd.DataFrame([_row("AAA"), _row("BBB"), _row("CCC")]) + proposed = pd.DataFrame( + [ + _row("AAA", fundamentals_ready=True, dcf_ready=True), + _row("BBB", fundamentals_ready=True), + _row("CCC", fundamentals_ready=True), + ] + ) + fundamentals = pd.DataFrame( + [ + { + "ticker": "AAA", + "source": "sec_companyfacts", + "as_of_date": "2025-12-31", + "sec_accession": "0001", + }, + { + "ticker": "BBB", + "source": "sec_companyfacts; sec_filing_document", + "as_of_date": "2025-12-31", + "sec_accession": "0002", + }, + { + "ticker": "CCC", + "source": "sec_companyfacts", + "as_of_date": "", + "sec_accession": "", + }, + ] + ) + + review = review_readiness_promotions( + saved, + proposed, + fundamentals, + rights_registry=_rights_registry(), + top_n=2, + ) + + assert review.status == "evidence_review_required" + assert review.promotion_count == 3 + assert review.fundamentals_promotion_count == 3 + assert review.dcf_promotion_count == 1 + assert review.rights_approved_count == 2 + assert review.rights_review_required_count == 1 + assert review.provenance_complete_count == 2 + assert review.provenance_review_required_count == 1 + assert review.field_scope_complete_count == 0 + assert review.field_scope_review_required_count == 3 + assert review.source_counts == ( + ("sec_companyfacts", 2), + ("sec_companyfacts; sec_filing_document", 1), + ) + assert len(review.evidence_rows) == 2 + assert review.evidence_rows[0].ticker == "AAA" + assert review.evidence_rows[0].promoted_fields == ("fundamentals_ready", "dcf_ready") + assert review.evidence_rows[0].rights_status == "approved" + assert review.evidence_rows[0].missing_supported_fields == ("free_cash_flow", "fcf_margin") + assert review.evidence_rows[1].rights_status == "unknown_source" + assert review.evidence_rows[1].source_id == "sec_companyfacts; sec_filing_document" + + +def test_promotion_review_fails_closed_on_duplicate_fundamentals_rows(): + saved = pd.DataFrame([_row("AAA")]) + proposed = pd.DataFrame([_row("AAA", fundamentals_ready=True)]) + fundamentals = pd.DataFrame( + [ + {"ticker": "AAA", "source": "sec_companyfacts", "as_of_date": "2025-12-31", "sec_accession": "0001"}, + {"ticker": "AAA", "source": "sec_companyfacts", "as_of_date": "2026-03-31", "sec_accession": "0002"}, + ] + ) + + review = review_readiness_promotions( + saved, + proposed, + fundamentals, + rights_registry=_rights_registry(), + top_n=5, + ) + + assert review.status == "evidence_review_required" + assert review.rights_approved_count == 0 + assert review.provenance_complete_count == 0 + assert review.field_scope_complete_count == 0 + assert review.evidence_rows[0].rights_status == "not_evaluated_ambiguous_evidence" + assert review.evidence_rows[0].blockers == ("duplicate_fundamentals_rows",) + + +def test_promotion_review_reports_no_promotions_without_source_rows(): + saved = pd.DataFrame([_row("AAA")]) + proposed = pd.DataFrame([_row("AAA")]) + + review = review_readiness_promotions( + saved, + proposed, + pd.DataFrame(), + rights_registry=_rights_registry(), + top_n=5, + ) + + assert review.status == "no_promotions" + assert review.promotion_count == 0 + assert review.evidence_rows == () + + +def test_change_review_summarizes_semantic_transitions_and_dcf_reasons(): + saved = pd.DataFrame( + [ + _row("AAA", ready_features="price"), + _row("BBB"), + _row("CCC"), + _row("DDD"), + _row("FFF"), + ] + ) + proposed = pd.DataFrame( + [ + _row("AAA", ready_features="price, fundamentals", fundamentals_ready=True), + _row("BBB", partial_features="fundamentals"), + _row("CCC", excluded_features="dcf", name="Example Bank Corp", asset_type="company"), + _row("EEE", name="Added Company", asset_type="company"), + _row("FFF", excluded_features="dcf", name="Example Software", asset_type="company"), + ] + ) + fundamentals = pd.DataFrame( + [ + {"ticker": "CCC", "revenue": 100, "free_cash_flow": 10, "fcf_margin": 0.1, "shares_outstanding": 10}, + {"ticker": "FFF", "revenue": 100, "free_cash_flow": 10, "fcf_margin": 0.1, "shares_outstanding": 10}, + ] + ) + + review = review_readiness_changes(saved, proposed, fundamentals) + + assert review.status == "unexplained_changes" + assert review.added_ticker_count == 1 + assert review.removed_ticker_count == 1 + assert review.newly_ready_counts == (("fundamentals", 1),) + assert review.newly_partial_counts == (("fundamentals", 1),) + assert review.newly_excluded_counts == (("dcf", 2),) + assert review.dcf_exclusion_reason_counts == (("bank_or_bancorp", 1),) + assert review.unexplained_dcf_exclusion_count == 1 + assert review.unexplained_dcf_exclusion_tickers == ("FFF",) + + +def test_change_review_reports_all_dcf_exclusions_explained(): + saved = pd.DataFrame([_row("AAA")]) + proposed = pd.DataFrame( + [_row("AAA", excluded_features="dcf", name="Example Acquisition Corp", asset_type="company")] + ) + + review = review_readiness_changes(saved, proposed, pd.DataFrame()) + + assert review.status == "changes_explained" + assert review.dcf_exclusion_reason_counts == (("acquisition_or_spac", 1),) + assert review.unexplained_dcf_exclusion_count == 0 + + +def test_missing_saved_snapshot_fails_closed_without_building_or_writing(tmp_path: Path, monkeypatch): + called = False + + def _unexpected_build(*args: object, **kwargs: object) -> dict[str, pd.DataFrame]: + nonlocal called + called = True + return {} + + monkeypatch.setattr(readiness_preview, "build_ticker_readiness_report", _unexpected_build) + before = _file_manifest(tmp_path) + + preview = build_readiness_impact_preview(tmp_path) + rendered = render_readiness_impact_preview(preview) + + assert preview.status == "missing_saved_snapshot" + assert called is False + assert "comparison is unavailable" in rendered.lower() + assert "does not make saved readiness current" in rendered + assert _file_manifest(tmp_path) == before + + +def test_preview_builds_in_memory_and_renders_non_unlock_boundary(tmp_path: Path, monkeypatch): + reports_dir = tmp_path / "data" / "reports" + reports_dir.mkdir(parents=True) + pd.DataFrame([_row("AAA")]).to_csv(reports_dir / "ticker_readiness_report.csv", index=False) + pd.DataFrame( + [ + { + "ticker": "AAA", + "source": "sec_companyfacts", + "as_of_date": "2025-12-31", + "sec_accession": "0001", + } + ] + ).to_csv(tmp_path / "data" / "fundamentals.csv", index=False) + + def _build(*args: object, **kwargs: object) -> dict[str, pd.DataFrame]: + assert kwargs["write_outputs"] is False + return {"ticker_readiness_report": pd.DataFrame([_row("AAA", price_ready=True)])} + + monkeypatch.setattr(readiness_preview, "build_ticker_readiness_report", _build) + before = _file_manifest(tmp_path) + + preview = build_readiness_impact_preview(tmp_path, top_n=5, rights_registry=_rights_registry()) + rendered = render_readiness_impact_preview(preview) + + assert preview.status == "changes_detected" + assert preview.promotion_review is not None + assert preview.promotion_review.promotion_count == 0 + assert "Promotion Evidence Review" in rendered + assert "Technical readiness movement is not source-rights or provenance approval." in rendered + assert "DCF price-source provenance is outside this fundamentals review" in rendered + assert "Read-only: no files were created, modified, or deleted." in rendered + assert "This preview does not make saved readiness current." in rendered + assert "An intentional reviewed make readiness run remains the separate rebuild boundary." in rendered + assert _file_manifest(tmp_path) == before + + +def test_preview_integrates_dcf_price_lineage_without_writing(tmp_path: Path, monkeypatch): + reports_dir = tmp_path / "data" / "reports" + reports_dir.mkdir(parents=True) + pd.DataFrame([_row("AAA")]).to_csv(reports_dir / "ticker_readiness_report.csv", index=False) + pd.DataFrame( + [ + { + "ticker": "AAA", + "source": "sec_companyfacts", + "as_of_date": "2025-12-31", + "sec_accession": "0001", + } + ] + ).to_csv(tmp_path / "data" / "fundamentals.csv", index=False) + pd.DataFrame( + [ + { + "ticker": "AAA", + "date": "2026-01-03", + "close": 10.0, + "source": "approved_prices", + "source_ref": "https://example.test/prices/AAA/2026-01-03", + "retrieved_at": "2026-01-04T23:00:00Z", + } + ] + ).to_csv(tmp_path / "data" / "prices.csv", index=False) + + def _build(*args: object, **kwargs: object) -> dict[str, pd.DataFrame]: + assert kwargs["write_outputs"] is False + return { + "ticker_readiness_report": pd.DataFrame( + [_row("AAA", fundamentals_ready=True, dcf_ready=True)] + ) + } + + monkeypatch.setattr(readiness_preview, "build_ticker_readiness_report", _build) + before = _file_manifest(tmp_path) + + preview = build_readiness_impact_preview( + tmp_path, + top_n=5, + rights_registry=_rights_registry(), + review_cutoff="2026-01-05T00:00:00Z", + ) + rendered = render_readiness_impact_preview(preview) + + assert preview.dcf_price_lineage_review is not None + assert preview.dcf_price_lineage_review.status == "price_lineage_review_complete" + assert preview.dcf_price_lineage_review.promotion_count == 1 + assert preview.promotion_review is not None + assert preview.promotion_review.status == "evidence_review_required" + assert "DCF Price Lineage Review" in rendered + assert "Latest price rows: usable=1, missing=0, ambiguous=0" in rendered + assert "Price lineage: complete=1, review_required=0" in rendered + assert "Price retrieval timing: complete=1, review_required=0" in rendered + assert "Registered price scope: complete=1, review_required=0" in rendered + assert "File origin, observation date, and adapter availability are not provider provenance." in rendered + assert "This price review changes no readiness state and does not authorize the separate rebuild." in rendered + assert _file_manifest(tmp_path) == before + + +def test_preview_fails_closed_when_dcf_promotes_without_price_file(tmp_path: Path, monkeypatch): + reports_dir = tmp_path / "data" / "reports" + reports_dir.mkdir(parents=True) + pd.DataFrame([_row("AAA")]).to_csv(reports_dir / "ticker_readiness_report.csv", index=False) + + def _build(*args: object, **kwargs: object) -> dict[str, pd.DataFrame]: + assert kwargs["write_outputs"] is False + return {"ticker_readiness_report": pd.DataFrame([_row("AAA", dcf_ready=True)])} + + monkeypatch.setattr(readiness_preview, "build_ticker_readiness_report", _build) + before = _file_manifest(tmp_path) + + preview = build_readiness_impact_preview(tmp_path, top_n=5, rights_registry=_rights_registry()) + + assert preview.dcf_price_lineage_review is not None + assert preview.dcf_price_lineage_review.status == "price_lineage_review_required" + assert preview.dcf_price_lineage_review.missing_latest_row_count == 1 + assert preview.dcf_price_lineage_review.evidence_rows[0].blockers == ("missing_latest_price_row",) + assert _file_manifest(tmp_path) == before diff --git a/tests/test_refresh_operations.py b/tests/test_refresh_operations.py new file mode 100644 index 00000000..c074a94b --- /dev/null +++ b/tests/test_refresh_operations.py @@ -0,0 +1,185 @@ +from src.refresh_operations import ( + REFRESH_STAGES, + ProviderAttempt, + RefreshBatch, + RefreshOperationRequest, + build_refresh_operation_plan, + evaluate_refresh_batch, +) +from src.auto_refresh_orchestrator import build_default_lane_policies, build_scheduler_plan + + +def test_plan_is_read_only_and_uses_the_first_available_provider(): + plan = build_refresh_operation_plan( + RefreshOperationRequest( + lane="quarterly_actuals", + provider_order=("primary", "fallback"), + available_providers=("fallback",), + batch_limit=25, + freshness_policy="current_filing_only", + ) + ) + + assert plan.status == "planned" + assert plan.selected_provider == "fallback" + assert plan.automatic_apply_enabled is False + assert tuple(stage.name for stage in plan.stages) == REFRESH_STAGES + assert all(stage.state == "pending" for stage in plan.stages) + + +def test_plan_blocks_unavailable_or_exhausted_provider_paths_without_repeating_them(): + unavailable = build_refresh_operation_plan( + RefreshOperationRequest( + lane="prices", + provider_order=("primary",), + available_providers=(), + batch_limit=10, + freshness_policy="daily", + ) + ) + repeated = build_refresh_operation_plan( + RefreshOperationRequest( + lane="prices", + provider_order=("primary", "fallback"), + available_providers=("primary", "fallback"), + batch_limit=10, + freshness_policy="daily", + retry_cap=2, + session_id="refresh-01", + attempts=( + ProviderAttempt("primary", "refresh-01", "failed"), + ProviderAttempt("fallback", "prior-session", "failed"), + ProviderAttempt("fallback", "prior-session", "failed"), + ), + ) + ) + + assert unavailable.status == "blocked" + assert unavailable.failure_reason == "provider_unavailable" + assert repeated.status == "blocked" + assert repeated.failure_reason == "retry_cap_reached" + assert repeated.skipped_providers == ("primary", "fallback") + + +def test_plan_skips_a_failed_provider_already_attempted_in_this_session(): + plan = build_refresh_operation_plan( + RefreshOperationRequest( + lane="fundamentals", + provider_order=("primary", "fallback"), + available_providers=("primary", "fallback"), + batch_limit=10, + freshness_policy="filing_date", + retry_cap=3, + session_id="refresh-01", + attempts=(ProviderAttempt("primary", "refresh-01", "failed"),), + ) + ) + + assert plan.status == "planned" + assert plan.selected_provider == "fallback" + assert plan.skipped_providers == ("primary",) + + +def test_batch_quarantines_schema_provenance_duplicates_and_stale_rows(): + result = evaluate_refresh_batch( + RefreshBatch( + provider="sec", + row_count=4, + expected_schema_identity="quarterly_actuals/v1", + received_schema_identity="quarterly_actuals/v2", + provenance_complete=False, + duplicate_rows=1, + stale_rows=2, + partial_batch=False, + ) + ) + + assert result.status == "quarantine" + assert result.publish_snapshot is False + assert result.rebuild_readiness is False + assert result.is_invalid is True + assert result.is_partial is False + assert result.failure_reasons == ( + "schema_changed", + "provenance_missing", + "duplicate_rows", + "stale_rows", + ) + + +def test_partial_batch_is_withheld_from_preview_and_publish(): + result = evaluate_refresh_batch( + RefreshBatch( + provider="sec", + row_count=3, + expected_schema_identity="quarterly_actuals/v1", + received_schema_identity="quarterly_actuals/v1", + provenance_complete=True, + duplicate_rows=0, + stale_rows=0, + partial_batch=True, + ) + ) + + assert result.status == "partial" + assert result.failure_reasons == ("partial_batch",) + assert result.preview_allowed is False + assert result.publish_snapshot is False + assert result.is_partial is True + assert result.is_invalid is False + + +def test_empty_schema_identity_fails_closed(): + result = evaluate_refresh_batch( + RefreshBatch( + provider="sec", + row_count=1, + expected_schema_identity="", + received_schema_identity=" ", + provenance_complete=True, + duplicate_rows=0, + stale_rows=0, + partial_batch=False, + ) + ) + + assert result.status == "quarantine" + assert result.failure_reasons == ("schema_identity_missing",) + assert result.is_invalid is True + assert result.preview_allowed is False + + +def test_partial_invalid_batch_preserves_both_states(): + result = evaluate_refresh_batch( + RefreshBatch( + provider="sec", + row_count=2, + expected_schema_identity="quarterly_actuals/v1", + received_schema_identity="quarterly_actuals/v2", + provenance_complete=False, + duplicate_rows=0, + stale_rows=0, + partial_batch=True, + ) + ) + + assert result.status == "partial_invalid" + assert result.failure_reasons == ( + "schema_changed", + "provenance_missing", + "partial_batch", + ) + assert result.is_partial is True + assert result.is_invalid is True + assert result.preview_allowed is False + + +def test_scheduler_lanes_keep_automatic_apply_disabled_and_expose_read_only_plans(): + policies = build_default_lane_policies() + scheduler_plan = build_scheduler_plan(policies, schedule="daily") + + assert all(policy.auto_apply is False for policy in policies) + assert scheduler_plan.refresh_operations + assert all(plan.automatic_apply_enabled is False for plan in scheduler_plan.refresh_operations) + assert all(plan.status == "blocked" for plan in scheduler_plan.refresh_operations) + assert all(plan.failure_reason == "provider_unavailable" for plan in scheduler_plan.refresh_operations) diff --git a/tests/test_research_decision_lab.py b/tests/test_research_decision_lab.py new file mode 100644 index 00000000..04967f1f --- /dev/null +++ b/tests/test_research_decision_lab.py @@ -0,0 +1,307 @@ +from dataclasses import FrozenInstanceError, replace +from types import SimpleNamespace + +import pytest + +from src.decision_process_scorecard import build_decision_process_scorecard +from src.research_decision_lab import ( + build_research_decision_lab_state, + build_research_discipline_rows, + decision_lab_cards, + decision_lab_rows, + research_discipline_rows, + unavailable_research_decision_lab_state, +) +from src.research_outcome_review import OutcomeStatus +from src.research_thesis_journal import JournalEntry, derive_journal_state + + +def _entry( + entry_id: str, + entry_type: str, + recorded_at: str, + *, + direction: str = "", + confidence: str = "", + review_due_date: str = "", +) -> JournalEntry: + requires_source = entry_type in {"evidence", "catalyst", "risk", "invalidation"} + return JournalEntry( + schema_version="research-thesis-journal-v1", + entry_id=entry_id, + profile_key="demo", + ticker="ALFA", + thesis_id="thesis-1", + entry_type=entry_type, + recorded_at=recorded_at, + effective_at=recorded_at, + reviewer="fixture-reviewer", + summary=f"{entry_type} fixture", + evidence_direction=direction, + source="fixture" if requires_source else "", + source_ref=f"fixture:{entry_id}" if requires_source else "", + source_published_at=recorded_at if requires_source else "", + confidence=confidence, + review_due_date=review_due_date, + supersedes_entry_id="", + ) + + +def _report( + *, + dcf_ready: bool = True, + asset_type: str = "company", + assumptions: bool = True, +) -> dict[str, object]: + return { + "ticker": "ALFA", + "asset_type": asset_type, + "valuation_readiness": {"dcf_ready": dcf_ready}, + "valuation_snapshot": { + "dcf_result": { + "status": "calculated" if dcf_ready else "insufficient_data", + "assumptions": ( + {"wacc": 0.09, "terminal_growth": 0.03} + if dcf_ready and assumptions + else {} + ), + } + }, + } + + +def _journal(*, variant: str = "complete"): + rows: list[JournalEntry] = [] + if variant != "empty": + due = "2026-07-10" if variant == "overdue" else "" if variant == "unscheduled" else "2026-08-01" + rows.append(_entry("t1", "thesis", "2026-07-01T12:00:00Z", review_due_date=due)) + if variant not in {"empty", "missing_evidence"}: + rows.append(_entry("e1", "evidence", "2026-07-02T12:00:00Z", direction="supporting")) + if variant in {"conflict", "conflict_reviewed"}: + rows.append(_entry("e2", "evidence", "2026-07-03T12:00:00Z", direction="conflicting")) + if variant not in {"empty", "missing_invalidation"}: + rows.append(_entry("i1", "invalidation", "2026-07-04T12:00:00Z")) + if variant not in {"empty", "unscheduled"}: + rows.append(_entry("c1", "confidence", "2026-07-05T12:00:00Z", confidence="0.6")) + if variant == "conflict_reviewed": + rows.append(_entry("r1", "review", "2026-07-06T12:00:00Z", review_due_date="2026-08-01")) + return derive_journal_state( + tuple(rows), + profile_key="demo", + ticker="ALFA", + as_of="2026-07-15T12:00:00Z", + ) + + +def _outcome(state: str = "not_started") -> OutcomeStatus: + if state == "reviewed": + return OutcomeStatus("reviewed", 1, "mixed", "Review the source boundary.", "Use it at the next review.", 0, ()) + if state == "commercial_evidence_blocked": + return OutcomeStatus( + "commercial_evidence_blocked", + 0, + "", + "", + "Review source rights before using this learning record.", + 1, + ("outcome-1: exact-source rights are unverified",), + ) + return OutcomeStatus("not_started", 0, "", "", "Review only after the observation window closes.", 0, ()) + + +def _state( + *, + journal_variant: str = "complete", + outcome_state: str = "not_started", + dcf_ready: bool = True, + asset_type: str = "company", + assumptions: bool = True, + open_change: bool = False, +): + journal = _journal(variant=journal_variant) + review_items = ( + (SimpleNamespace(event=SimpleNamespace(ticker="ALFA"), review_status="open"),) + if open_change + else () + ) + scorecard = build_decision_process_scorecard( + _report(dcf_ready=dcf_ready, asset_type=asset_type, assumptions=assumptions), + profile_key="demo", + journal_state=journal, + review_items=review_items, + ) + return build_research_decision_lab_state( + profile_key="demo", + journal_state=journal, + scorecard=scorecard, + outcome_status=_outcome(outcome_state), + review_items=review_items, + ) + + +def _lane_states(state) -> dict[str, str]: + return {lane.key: lane.state for lane in state.lanes} + + +def test_empty_history_keeps_six_lanes_independent_and_not_started(): + state = _state(journal_variant="empty", dcf_ready=False) + + assert [lane.key for lane in state.lanes] == [ + "plan", + "evidence", + "invalidation", + "scenario", + "review_trigger", + "learning", + ] + assert _lane_states(state) == { + "plan": "not_started", + "evidence": "not_started", + "invalidation": "not_started", + "scenario": "blocked", + "review_trigger": "not_started", + "learning": "not_started", + } + assert state.next_process_step == "Record a current reviewer-authored thesis." + assert state.status == "process_work_needed" + + +def test_complete_history_maps_each_lane_without_creating_a_company_score(): + state = _state(outcome_state="reviewed") + + assert _lane_states(state) == { + "plan": "documented", + "evidence": "current", + "invalidation": "documented", + "scenario": "reviewable", + "review_trigger": "scheduled", + "learning": "reviewed", + } + assert state.status == "process_documented" + assert state.next_process_step.startswith("Continue monitoring") + assert "score" not in str(decision_lab_rows(state)).lower() + + +def test_conflicting_evidence_requires_later_review_and_only_changes_evidence_lane(): + unresolved = _state(journal_variant="conflict") + reviewed = _state(journal_variant="conflict_reviewed") + + assert _lane_states(unresolved)["evidence"] == "conflict_review_needed" + assert _lane_states(reviewed)["evidence"] == "current" + assert unresolved.next_process_step.startswith("Review recorded conflicting evidence") + assert _lane_states(unresolved)["scenario"] == _lane_states(reviewed)["scenario"] == "reviewable" + assert _lane_states(unresolved)["learning"] == _lane_states(reviewed)["learning"] == "not_started" + + +def test_overdue_and_unscheduled_review_states_remain_distinct(): + overdue = _state(journal_variant="overdue") + unscheduled = _state(journal_variant="unscheduled") + + assert _lane_states(overdue)["review_trigger"] == "overdue" + assert overdue.next_process_step.startswith("Review the overdue thesis") + assert _lane_states(unscheduled)["review_trigger"] == "unscheduled" + assert unscheduled.next_process_step.startswith("Schedule the next evidence review") + + +def test_open_evidence_change_sets_review_trigger_without_promoting_other_lanes(): + regular = _state() + changed = _state(open_change=True) + + assert _lane_states(changed)["review_trigger"] == "evidence_change_due" + assert _lane_states(changed)["plan"] == _lane_states(regular)["plan"] + assert _lane_states(changed)["scenario"] == _lane_states(regular)["scenario"] + assert changed.status == "process_work_needed" + + +def test_scenario_states_preserve_ready_blocked_excluded_and_missing_assumptions(): + reviewable = _state() + blocked = _state(dcf_ready=False) + excluded = _state(dcf_ready=False, asset_type="etf") + unavailable = _state(assumptions=False) + + assert _lane_states(reviewable)["scenario"] == "reviewable" + assert _lane_states(blocked)["scenario"] == "blocked" + assert _lane_states(excluded)["scenario"] == "excluded" + assert _lane_states(unavailable)["scenario"] == "unavailable" + assert unavailable.next_process_step.startswith("Restore visible DCF assumptions") + + +def test_learning_commercial_blocker_does_not_change_plan_or_scenario(): + regular = _state() + blocked = _state(outcome_state="commercial_evidence_blocked") + + assert _lane_states(blocked)["learning"] == "commercial_evidence_blocked" + assert _lane_states(blocked)["plan"] == _lane_states(regular)["plan"] == "documented" + assert _lane_states(blocked)["scenario"] == _lane_states(regular)["scenario"] == "reviewable" + + +def test_profile_or_ticker_mismatch_fails_closed(): + journal = _journal() + scorecard = build_decision_process_scorecard( + _report(), profile_key="demo", journal_state=journal, review_items=() + ) + + with pytest.raises(ValueError, match="selected profile and ticker"): + build_research_decision_lab_state( + profile_key="other", + journal_state=journal, + scorecard=scorecard, + outcome_status=_outcome(), + ) + + +def test_invalid_journal_can_render_one_compact_unavailable_contract(): + state = unavailable_research_decision_lab_state( + profile_key="demo", + ticker="ALFA", + reason="Research thesis journal header does not match the append-only contract.", + ) + + assert state.status == "unavailable" + assert set(_lane_states(state).values()) == {"unavailable"} + assert "header does not match" in decision_lab_rows(state)[0]["Evidence"] + + +def test_identity_and_contracts_are_deterministic_and_immutable(): + first = _state() + second = _state() + + assert first.identity == second.identity + assert decision_lab_cards(first) == decision_lab_cards(second) + with pytest.raises(FrozenInstanceError): + first.status = "changed" + + +def test_cohort_rows_preserve_focused_order_and_never_sort_by_process_severity(): + alpha = _state(journal_variant="empty", dcf_ready=False) + beta = replace(_state(), ticker="BETA", identity="beta-identity") + + rows = build_research_discipline_rows( + {"ALFA": alpha, "BETA": beta}, + focused_tickers=("BETA", "ALFA"), + ) + + assert [row.ticker for row in rows] == ["BETA", "ALFA"] + assert [row.cohort_order for row in rows] == [0, 1] + assert "Plan" in rows[1].due_lanes + assert "Evidence" in rows[1].due_lanes + assert [row["Ticker"] for row in research_discipline_rows(rows)] == ["BETA", "ALFA"] + assert "rank" not in str(rows).lower() + assert "market value" not in str(rows).lower() + + +def test_primary_display_copy_contains_no_transaction_or_allocation_language(): + state = _state(outcome_state="reviewed") + display = str(decision_lab_cards(state)).lower() + + for forbidden in ( + "buy", + "sell", + "position size", + "allocation", + "stop loss", + "take profit", + "entry price", + "exit price", + ): + assert forbidden not in display diff --git a/tests/test_research_ledger_lock.py b/tests/test_research_ledger_lock.py new file mode 100644 index 00000000..45399bfa --- /dev/null +++ b/tests/test_research_ledger_lock.py @@ -0,0 +1,88 @@ +import fcntl +import threading +from pathlib import Path + +import pytest + +from src import research_ledger_lock + + +def _second_thread_completes(path: Path) -> bool: + completed = threading.Event() + + def acquire_then_release() -> None: + with research_ledger_lock.ledger_write_lock(path): + completed.set() + + worker = threading.Thread(target=acquire_then_release, daemon=True) + worker.start() + worker.join(timeout=1) + return completed.is_set() and not worker.is_alive() + + +def test_unlock_failure_releases_the_thread_lock_and_leaves_valid_state(tmp_path, monkeypatch): + destination = tmp_path / "journal.csv" + original_flock = research_ledger_lock.fcntl.flock + failed = False + + def fail_first_unlock(fd, operation): + nonlocal failed + if operation == fcntl.LOCK_UN and not failed: + failed = True + raise OSError("unlock unavailable") + return original_flock(fd, operation) + + monkeypatch.setattr(research_ledger_lock.fcntl, "flock", fail_first_unlock) + + with pytest.raises(OSError, match="unlock unavailable"): + with research_ledger_lock.ledger_write_lock(destination): + pass + + state = research_ledger_lock._states[str(research_ledger_lock.resolve_ledger_path(destination))] + assert state.depth == 0 + assert state.handle is None + assert _second_thread_completes(destination) + + +def test_close_failure_is_retried_before_a_second_thread_acquires(tmp_path, monkeypatch): + destination = tmp_path / "journal.csv" + lock_path = tmp_path / "lock-artifact" + original_open = Path.open + handles = [] + + class FailOnceClose: + def __init__(self, handle): + self.handle = handle + self.close_attempts = 0 + + def fileno(self): + return self.handle.fileno() + + def close(self): + self.close_attempts += 1 + if self.close_attempts == 1: + raise OSError("close unavailable") + return self.handle.close() + + def open_lock(path, *args, **kwargs): + handle = original_open(path, *args, **kwargs) + if Path(path) == lock_path and not handles: + wrapped = FailOnceClose(handle) + handles.append(wrapped) + return wrapped + return handle + + monkeypatch.setattr(research_ledger_lock, "_lock_artifact", lambda _path: lock_path) + monkeypatch.setattr(Path, "open", open_lock) + + with pytest.raises(OSError, match="close unavailable"): + with research_ledger_lock.ledger_write_lock(destination): + pass + + state = research_ledger_lock._states[str(research_ledger_lock.resolve_ledger_path(destination))] + assert state.depth == 0 + assert state.handle is handles[0] + assert _second_thread_completes(destination) + assert handles[0].close_attempts == 2 + assert state.depth == 0 + assert state.handle is None diff --git a/tests/test_research_mode_dashboard_contract.py b/tests/test_research_mode_dashboard_contract.py new file mode 100644 index 00000000..a7aad849 --- /dev/null +++ b/tests/test_research_mode_dashboard_contract.py @@ -0,0 +1,847 @@ +from pathlib import Path + +from src import dashboard +from src import dashboard_navigation as nav +from src.focused_research_cohort import FocusedCohort, FocusedCohortMember, build_focused_cohort + + +def test_dashboard_defaults_local_use_to_research_desk_and_preserves_explicit_modes(): + assert dashboard.dashboard_mode_from_query("") == nav.RESEARCH_MODE + assert dashboard.workspace_default_page( + "Home", + mode=nav.RESEARCH_MODE, + has_explicit_page_query=False, + ) == "Research Desk" + assert dashboard.workspace_default_page( + "Home", + mode=nav.PUBLIC_DEMO_MODE, + has_explicit_page_query=False, + ) == "Home" + assert dashboard.workspace_default_page( + "Data Health", + mode=nav.OPERATOR_DEMO_MODE, + has_explicit_page_query=True, + ) == "Data Health" + + +def test_dashboard_quarantines_legacy_deep_links_outside_operator_mode(): + for page in nav.LEGACY_RESEARCH_UTILITY_PAGES: + assert dashboard.workspace_default_page( + page, + mode=nav.RESEARCH_MODE, + has_explicit_page_query=True, + ) == "Research Desk" + assert dashboard.workspace_default_page( + page, + mode=nav.PUBLIC_DEMO_MODE, + has_explicit_page_query=True, + ) == "Home" + assert dashboard.workspace_default_page( + page, + mode=nav.OPERATOR_DEMO_MODE, + has_explicit_page_query=True, + ) == page + + +def test_legacy_renderers_use_one_collapsed_compatibility_boundary(): + source = Path(dashboard.__file__).read_text(encoding="utf-8") + shell_start = source.index("def render_legacy_research_utility_shell(") + shell_end = source.index("\ndef render_monthly_picks(", shell_start) + shell = source[shell_start:shell_end] + monthly_start = source.index("def render_monthly_picks(") + monthly_end = source.index("\ndef _render_monthly_picks_legacy_output(", monthly_start) + monthly = source[monthly_start:monthly_end] + output_start = source.index("def render_output_tab(") + output_end = source.index("\ndef _render_legacy_output_tab(", output_start) + output = source[output_start:output_end] + + assert "Legacy research utility — not part of Personal Research Mode" in shell + assert "recommendations, company ranking for action, position sizing, transaction direction" in shell + assert 'st.expander("Advanced: legacy compatibility output", expanded=False)' in monthly + assert "_render_monthly_picks_legacy_output(catalog)" in monthly + assert 'st.expander("Advanced: legacy compatibility output", expanded=False)' in output + assert "_render_legacy_output_tab(title, output_frames, show_reason_details)" in output + + +def test_decision_lab_and_company_workbench_do_not_consume_legacy_utility_outputs(): + dashboard_source = Path(dashboard.__file__).read_text(encoding="utf-8") + workbench_start = dashboard_source.index("def render_company_workbench(") + workbench_end = dashboard_source.index("\ndef main()", workbench_start) + workbench = dashboard_source[workbench_start:workbench_end] + lab_source = (Path(dashboard.__file__).parent / "research_decision_lab.py").read_text(encoding="utf-8") + + forbidden = ( + "portfolio_review", + "monthly_picks", + "final_watchlist", + "momentum_leaders", + "undervalued_candidates", + ) + for token in forbidden: + assert token not in lab_source + assert token not in workbench + + +def test_dashboard_research_paths_map_to_existing_renderers_without_changing_public_flow(): + assert dashboard.dashboard_page_from_query("research-desk") == "Research Desk" + assert dashboard.dashboard_page_from_query("discover") == "Discover" + assert dashboard.dashboard_page_from_query("company-workbench") == "Company Workbench" + assert dashboard.dashboard_page_from_query("monitor") == "Monitor" + assert dashboard.workspace_path_options("Research Desk", nav.RESEARCH_MODE) == nav.RESEARCH_PATH_PAGE_TITLES + assert dashboard.workspace_path_options("Data Health", nav.RESEARCH_MODE) == nav.RESEARCH_PATH_PAGE_TITLES + [ + "Data Health" + ] + assert dashboard.workspace_path_options("Home", nav.PUBLIC_DEMO_MODE) == nav.PUBLIC_PATH_PAGE_TITLES + assert dashboard.workspace_content_page("Discover", nav.RESEARCH_MODE) == "Stock Selector" + assert dashboard.workspace_content_page("Company Workbench", nav.RESEARCH_MODE) == "Single-Stock Report" + assert dashboard.workspace_content_page("Research Desk", nav.RESEARCH_MODE) == "Research Desk" + assert dashboard.workspace_content_page("Monitor", nav.RESEARCH_MODE) == "Monitor" + + +def test_research_pages_are_not_classified_as_operator_advanced_pages(): + assert all(page in dashboard.USER_PAGE_TITLES for page in nav.RESEARCH_PATH_PAGE_TITLES) + assert all(page not in dashboard.ADVANCED_PAGE_TITLES for page in nav.RESEARCH_PATH_PAGE_TITLES) + assert dashboard.workspace_path_label("Company Workbench", nav.RESEARCH_MODE) == "Company Workbench" + + +def test_research_selector_links_open_company_workbench_without_changing_public_default(): + import pandas as pd + + frame = pd.DataFrame( + [ + { + "Ticker": "NVDA", + "Sector / Theme": "Semiconductors", + "Supported Now": "Price trend and source-backed fundamentals", + "Readiness": "partial", + } + ] + ) + + public_html = dashboard.stock_selector_result_table_html(frame, total_count=1) + research_html = dashboard.stock_selector_result_table_html( + frame, + total_count=1, + target_mode="research", + target_page="company-workbench", + ) + + assert "?mode=public&page=single-stock-report&ticker=NVDA&open=1" in public_html + assert "?mode=research&page=company-workbench&ticker=NVDA&open=1" in research_html + + +def test_research_header_keeps_data_health_inside_the_same_workspace(): + research_html = dashboard.command_center_header_html( + {"master_universe": 10, "price_ready": 8, "dcf_ready": 2, "peer_ready": 1}, + tickers=10, + final_count=0, + latest_price="2026-07-16", + compact=True, + current_page="Research Desk", + current_mode="research", + ) + public_html = dashboard.command_center_header_html( + {}, + tickers=0, + final_count=0, + latest_price="Unavailable", + compact=True, + ) + + assert "?mode=research&page=data-health" in research_html + assert "?mode=public&page=data-health" in public_html + + +def test_research_discover_can_limit_selector_rows_to_the_focused_cohort(): + import pandas as pd + + frame = pd.DataFrame( + [ + {"Ticker": "AAA", "Readiness": "ready"}, + {"Ticker": "BBB", "Readiness": "partial"}, + {"Ticker": "OUT", "Readiness": "ready"}, + ] + ) + + filtered = dashboard.filter_selector_to_tickers(frame, ("BBB", "AAA", "MISSING")) + + assert filtered["Ticker"].tolist() == ["AAA", "BBB"] + assert dashboard.filter_selector_to_tickers(frame, None).equals(frame) + assert dashboard.filter_selector_to_tickers(frame, ()).empty + + +def test_research_discover_renders_selector_before_advanced_cohort_context(): + source = dashboard.Path(dashboard.__file__).read_text(encoding="utf-8") + discover_start = source.index('elif research_mode and selected_page == "Discover":') + discover_end = source.index( + 'elif research_mode and selected_page == "Company Workbench":', + discover_start, + ) + discover = source[discover_start:discover_end] + + heading = discover.index('st.markdown("## Which stock can I review?")') + selector = discover.index("render_stock_selector(", heading) + advanced = discover.index( + 'with st.expander("Advanced: cohort readiness context", expanded=False):' + ) + cohort = discover.index("focused_cohort_cards(focused_cohort)", advanced) + coverage = discover.index( + "focused_cohort_coverage_cards(focused_cohort_coverage)", + advanced, + ) + + assert heading < selector < advanced < cohort < coverage + + +def test_company_workbench_anchors_answer_before_collapsed_navigation_and_passes_target_to_report(): + source = dashboard.Path(dashboard.__file__).read_text(encoding="utf-8") + workbench_start = source.index("def render_company_workbench(") + workbench_end = source.index("\ndef main()", workbench_start) + workbench = source[workbench_start:workbench_end] + + header = workbench.index("render_research_workspace_header(") + target = workbench.index("selected_answer_target = st.empty()", header) + review = workbench.index('with st.expander("Review path", expanded=False):', target) + advanced = workbench.index('with st.expander("Advanced: selected-company lane coverage", expanded=False):', review) + report = workbench.index("render_single_stock_report(", advanced) + + assert header < target < review < advanced < report + assert "compact=True" in workbench[header:target] + assert "selected_answer_target=selected_answer_target" in workbench[report:] + assert 'st.markdown("### Selected Company")' not in workbench + + +def test_company_workbench_loads_cash_preview_only_for_explicit_flag(): + source = dashboard.Path(dashboard.__file__).read_text(encoding="utf-8") + workbench_start = source.index("def render_company_workbench(") + workbench_end = source.index("\ndef main()", workbench_start) + workbench = source[workbench_start:workbench_end] + + assert 'company_workbench_cash_preview_requested(st.query_params.get("cash_preview"))' in source + assert "load_company_workbench_cash_generation_preview(ticker)" in source + gate = workbench.index( + 'company_workbench_cash_preview_requested(st.query_params.get("cash_preview"))' + ) + load = workbench.index( + "load_company_workbench_cash_generation_preview(ticker)", + gate, + ) + report = workbench.index("render_single_stock_report(", load) + canonical = workbench.index("load_dashboard_quarterly_trend(ticker)", report) + + assert gate < load < report < canonical + assert workbench.index("cash_generation_preview = None") < gate + assert "cash_generation_preview=cash_generation_preview" in workbench[report:] + + +def test_cash_preview_renders_after_business_trend_answer_and_before_advanced_lineage(): + source = dashboard.Path(dashboard.__file__).read_text(encoding="utf-8") + report_start = source.index("def render_single_stock_report(") + report_end = source.index("\ndef render_data_health(", report_start) + report = source[report_start:report_end] + + business = report.index('st.markdown("## Business Trend")') + canonical = report.index("quarterly_trend_cards(trend_packet)", business) + preview = report.index( + "cash_generation_preview_cards(cash_generation_preview)", + canonical, + ) + advanced = report.index( + 'st.expander("Advanced: cash-generation preview evidence", expanded=False)', + preview, + ) + rows = report.index("cash_generation_preview_rows(cash_generation_preview)", advanced) + canonical_advanced = report.index( + 'st.expander("Advanced: quarterly trend evidence", expanded=False)', + rows, + ) + + assert business < canonical < preview < advanced < rows < canonical_advanced + + +def test_default_research_navigation_never_enables_cash_preview(): + source = dashboard.Path(dashboard.__file__).read_text(encoding="utf-8") + + assert "cash_preview=1" not in source + assert "ticker=NVDA&open=1&cash_preview=1" not in source + assert "ticker=AMD&open=1&cash_preview=1" not in source + + +def test_research_workbench_data_health_handoff_stays_in_research_mode(): + import pandas as pd + + frame = pd.DataFrame( + [ + { + "Ticker": "NVDA", + "Use Now": "Price and valuation evidence.", + "Still Blocked": "Optional context.", + "Context Only": "Candidate context only.", + "Next Safe Action": "Open Data Health.", + "Review Boundary": "Research-only.", + } + ] + ) + + rendered = dashboard.single_stock_public_summary_html(frame, target_mode="research") + + assert "?mode=research&page=data-health&ticker=NVDA" in rendered + + +def test_dashboard_loads_saved_focused_cohort_coverage_without_refreshing(tmp_path, monkeypatch): + import pandas as pd + + data_dir = tmp_path / "data" + (data_dir / "reports").mkdir(parents=True) + (data_dir / "earnings_nowcast").mkdir(parents=True) + readiness = pd.DataFrame( + [{"ticker": "AAA", "price_ready": True, "fundamentals_ready": True, "dcf_ready": True, "peer_ready": False}] + ) + universe = pd.DataFrame( + [{"ticker": "AAA", "name": "Alpha", "asset_type": "company", "is_active_listing": True}] + ) + fundamentals = pd.DataFrame( + [ + { + "ticker": "AAA", + "source": "sec_companyfacts", + "source_ref": "sec:AAA:10-Q", + "free_cash_flow": 10, + "cash": 20, + "debt": 5, + "shares_outstanding": 100, + } + ] + ) + prices = pd.DataFrame( + [{"date": "2026-07-15", "ticker": "AAA", "close": 100, "adj_close": 100}] + ) + readiness.to_csv(data_dir / "reports" / "ticker_readiness_report.csv", index=False) + universe.to_csv(data_dir / "universe_master.csv", index=False) + fundamentals.to_csv(data_dir / "fundamentals.csv", index=False) + prices.to_csv(data_dir / "prices.csv", index=False) + quarterly_rows = [] + for period, revenue, eps in ( + ("2024-Q1", 100.0, 1.0), + ("2024-Q4", 130.0, 1.3), + ("2025-Q1", 150.0, 1.5), + ): + quarterly_rows.append( + { + "ticker": "AAA", + "fiscal_period": period, + "period_end_date": f"{period[:4]}-{int(period[-1]) * 3:02d}-28", + "reported_at": "2026-05-01T00:00:00Z", + "revenue_actual": revenue, + "eps_actual": eps, + "source": "sec_companyfacts", + "source_ref": f"sec:AAA:{period}", + "retrieved_at": "2026-05-02T00:00:00Z", + "revenue_currency": "USD", + "revenue_unit_scale": 1.0, + "revenue_basis": "gaap", + "eps_currency": "USD", + "eps_basis": "gaap", + "eps_share_basis": "diluted", + "eps_operations_basis": "continuing", + "split_adjustment_basis": "as_reported", + } + ) + pd.DataFrame(quarterly_rows).to_csv( + data_dir / "earnings_nowcast" / "quarterly_actuals.csv", index=False + ) + cohort = build_focused_cohort(readiness, universe, target_size=1, minimum_size=1) + monkeypatch.setattr(dashboard, "DATA_DIR", data_dir) + real_read_csv = pd.read_csv + price_read_nrows: list[object] = [] + + def tracked_read_csv(path, *args, **kwargs): + if dashboard.Path(path) == data_dir / "prices.csv": + price_read_nrows.append(kwargs.get("nrows")) + return real_read_csv(path, *args, **kwargs) + + monkeypatch.setattr(dashboard.pd, "read_csv", tracked_read_csv) + + coverage = dashboard.load_dashboard_focused_cohort_coverage(cohort) + + states = {row.lane: row.state for row in coverage.rows} + assert states["adjusted_daily_price_history"] == "blocked" + assert states["free_cash_flow"] == "blocked" + assert states["shares_outstanding"] == "usable_now" + assert states["trusted_peers"] == "blocked" + assert states["quarterly_revenue"] == "usable_now" + assert states["quarterly_eps"] == "blocked" + fcf = next(row for row in coverage.rows if row.lane == "free_cash_flow") + assert "registered field scope" in fcf.evidence + assert "free_cash_flow" in fcf.evidence + eps = next(row for row in coverage.rows if row.lane == "quarterly_eps") + assert "eps" in eps.evidence + price = next(row for row in coverage.rows if row.lane == "adjusted_daily_price_history") + assert "provenance" in price.evidence.lower() + assert price_read_nrows == [0] + + +def test_dashboard_blocks_partially_rejected_canonical_quarterly_ledger(tmp_path, monkeypatch): + import pandas as pd + + data_dir = tmp_path / "data" + (data_dir / "reports").mkdir(parents=True) + (data_dir / "earnings_nowcast").mkdir(parents=True) + readiness = pd.DataFrame( + [ + { + "ticker": "AAA", + "price_ready": True, + "fundamentals_ready": True, + "dcf_ready": False, + "peer_ready": False, + } + ] + ) + universe = pd.DataFrame( + [{"ticker": "AAA", "name": "Alpha", "asset_type": "company", "is_active_listing": True}] + ) + readiness.to_csv(data_dir / "reports" / "ticker_readiness_report.csv", index=False) + universe.to_csv(data_dir / "universe_master.csv", index=False) + base = { + "ticker": "AAA", + "fiscal_period": "2025-Q1", + "period_end_date": "2025-03-31", + "reported_at": "2025-05-01T00:00:00Z", + "revenue_actual": 100.0, + "eps_actual": 1.0, + "source": "sec_companyfacts", + "source_ref": "sec:AAA:2025-Q1", + "retrieved_at": "2025-05-02T00:00:00Z", + "revenue_currency": "USD", + "revenue_unit_scale": 1.0, + "revenue_basis": "gaap", + "eps_currency": "USD", + "eps_basis": "gaap", + "eps_share_basis": "diluted", + "eps_operations_basis": "continuing", + "split_adjustment_basis": "as_reported", + "supersedes_source_ref": "", + } + pd.DataFrame( + [base, {**base, "fiscal_period": "2025-FY", "source_ref": "sec:AAA:invalid"}] + ).to_csv(data_dir / "earnings_nowcast" / "quarterly_actuals.csv", index=False) + cohort = build_focused_cohort(readiness, universe, target_size=1, minimum_size=1) + monkeypatch.setattr(dashboard, "DATA_DIR", data_dir) + + packet = dashboard.load_dashboard_quarterly_trend("AAA") + coverage = dashboard.load_dashboard_focused_cohort_coverage(cohort) + states = {row.lane: row.state for row in coverage.rows} + + assert packet.status == "blocked" + assert packet.available_periods == () + assert packet.canonical_rejected_rows[0]["row_number"] == 3 + assert states["quarterly_revenue"] == "blocked" + assert states["quarterly_eps"] == "blocked" + + +def test_research_desk_renders_answers_before_advanced_cohort_context(): + source = dashboard.Path(dashboard.__file__).read_text(encoding="utf-8") + desk_start = source.index("def render_research_desk(") + desk_end = source.index("def render_research_monitor(", desk_start) + desk = source[desk_start:desk_end] + + weekly = desk.index('st.markdown("## Weekly research summary")') + weekly_cards = desk.index("weekly_summary_cards(weekly_summary)", weekly) + answers = desk.index("cards = research_desk_cards(", weekly_cards) + answers_html = desk.index("research_desk_cards_html(cards)", answers) + discover = desk.index('st.link_button("Open Discover"', answers_html) + advanced = desk.index('with st.expander("Advanced Evidence", expanded=False):', discover) + cohort = desk.index("focused_cohort_cards(cohort)", advanced) + coverage = desk.index("focused_cohort_coverage_cards(coverage)", cohort) + cohort_frame = desk.index("focused_cohort_frame(cohort)", coverage) + coverage_frame = desk.index("focused_cohort_coverage_frame(coverage)", cohort_frame) + + assert weekly < weekly_cards < answers < answers_html < discover < advanced + assert advanced < cohort < coverage < cohort_frame < coverage_frame + + +def test_research_workspace_phone_styles_compact_profile_and_hide_only_duplicate_freshness(): + source = dashboard.Path(dashboard.__file__).read_text(encoding="utf-8") + start = source.index("def render_research_workspace_styles()") + end = source.index("\ndef render_research_workspace_header(", start) + styles = source[start:end] + + assert ".profile-trust-strip.compact" in styles + assert "grid-template-columns: repeat(3, minmax(0, 1fr));" in styles + assert ".research-workspace-freshness { display: none; }" in styles + assert ".research-workspace-action" in styles + assert ".research-workspace-header.compact { padding: .35rem .9rem; margin-bottom: .15rem; }" in styles + assert ".research-workspace-header.compact h1 { padding: 0; }" in styles + assert ".research-workspace-header.compact .research-workspace-heading p" in styles + assert "font-size: .9rem; line-height: 1.35;" in styles + assert ".research-workspace-header.compact .research-workspace-boundary" in styles + assert "font-size: .85rem; line-height: 1.35; margin-top: .25rem;" in styles + assert ".public-ticker-summary.research" in styles + assert "grid-template-columns: 8rem minmax(0, 1fr) minmax(0, 1fr) minmax(12rem, 0.8fr);" in styles + assert ".public-ticker-summary.research .public-ticker-action" in styles + assert "display: grid;" in styles + assert "@media (max-width: 640px)" in styles + + +def test_company_workbench_keeps_review_path_and_lane_coverage_after_anchored_answer(): + source = dashboard.Path(dashboard.__file__).read_text(encoding="utf-8") + start = source.index("def render_company_workbench(") + end = source.index("\ndef main()", start) + workbench = source[start:end] + + selected_answer = workbench.index("selected_answer_target = st.empty()") + review = workbench.index('with st.expander("Review path", expanded=False):', selected_answer) + path = workbench.index('st.caption(" -> ".join(section_names[:-1]))', review) + coverage = workbench.index('with st.expander("Advanced: selected-company lane coverage", expanded=False):', path) + report = workbench.index("render_single_stock_report(", coverage) + + assert selected_answer < review < path < coverage < report + assert 'st.caption("Review path: "' not in workbench + + +def test_company_workbench_uses_composed_forward_view_and_keeps_details_advanced(): + source = dashboard.Path(dashboard.__file__).read_text(encoding="utf-8") + render_start = source.index("def render_single_stock_report(") + render_end = source.index("\ndef render_data_health(", render_start) + render = source[render_start:render_end] + + assert "build_forward_view(" in render + assert "forward_view_cards(forward_view_packet)" in render + assert 'st.expander("Advanced: Forward View evidence", expanded=False)' in render + assert "forward_view_rows(forward_view_packet)" in render + assert "load_dashboard_nowcast_packet(" in render + assert "fiscal_period" in render + + +def test_dashboard_nowcast_loader_passes_exact_report_period_and_rejects_synthetic(monkeypatch): + calls = [] + + def fake_builder(root, **kwargs): + calls.append((root, kwargs)) + return {"evidence_scope": "source_backed_preview_only", "fiscal_period": kwargs["fiscal_period"]} + + monkeypatch.setattr(dashboard, "build_nowcast_packet", fake_builder) + report = { + "generated_at": "2026-07-17T00:00:00Z", + "earnings_summary": {"fiscal_period": "2026-Q3"}, + } + + packet = dashboard.load_dashboard_nowcast_packet(report, ticker="AAA") + + assert packet["fiscal_period"] == "2026-Q3" + assert calls[0][1] == { + "ticker": "AAA", + "fiscal_period": "2026-Q3", + "as_of_timestamp": "2026-07-17T00:00:00Z", + } + assert dashboard.load_dashboard_nowcast_packet({"earnings_summary": {}}, ticker="AAA") is None + + monkeypatch.setattr( + dashboard, + "build_nowcast_packet", + lambda *args, **kwargs: {"evidence_scope": "synthetic_test_evidence_only"}, + ) + assert dashboard.load_dashboard_nowcast_packet(report, ticker="AAA") is None + + +def test_monitor_and_workbench_integrate_new_evidence_layers_without_new_routes(): + source = dashboard.Path(dashboard.__file__).read_text(encoding="utf-8") + monitor_start = source.index("def render_research_monitor(") + monitor_end = source.index("def render_company_workbench(", monitor_start) + monitor = source[monitor_start:monitor_end] + report_start = source.index("def render_single_stock_report(") + report_end = source.index("\ndef render_data_health(", report_start) + report = source[report_start:report_end] + + assert "cohort_readiness_cards(nowcast_cohort)" in monitor + assert 'st.expander("Advanced: five-company Earnings Nowcast readiness", expanded=False)' in monitor + assert "valuation_regime_cards(valuation_regime)" in report + assert "catalyst_timeline_cards(catalyst_timeline)" in report + assert "outcome_status_cards(outcome_status)" in report + assert dashboard.workspace_path_options("Research Desk", nav.RESEARCH_MODE) == nav.RESEARCH_PATH_PAGE_TITLES + + +def test_monitor_renders_change_answer_before_advanced_readiness(): + source = dashboard.Path(dashboard.__file__).read_text(encoding="utf-8") + monitor_start = source.index("def render_research_monitor(") + monitor_end = source.index("def render_company_workbench(", monitor_start) + monitor = source[monitor_start:monitor_end] + + weekly = monitor.index("weekly_summary_cards(weekly_summary)") + discipline = monitor.index('st.markdown("## Research Discipline Review")', weekly) + answer = monitor.index('st.markdown("## Research change monitor")', discipline) + frame = monitor.index("research_monitor_frame(state.get", answer) + empty = monitor.index("if frame.empty:", frame) + note = monitor.index("render_context_note(", empty) + discover = monitor.index('st.link_button("Open Discover"', note) + cohort = monitor.index("nowcast_cohort = load_dashboard_nowcast_cohort()", discover) + advanced = monitor.index( + 'with st.expander("Advanced: five-company Earnings Nowcast readiness", expanded=False):', + cohort, + ) + readiness_heading = monitor.index('st.markdown("### Earnings evidence readiness")', advanced) + readiness_cards = monitor.index("cohort_readiness_cards(nowcast_cohort)", readiness_heading) + readiness_frame = monitor.index("pd.DataFrame([asdict(row) for row in nowcast_cohort])", readiness_cards) + + assert weekly < discipline < answer < frame < empty < note < discover < cohort < advanced + assert advanced < readiness_heading < readiness_cards < readiness_frame + assert 'tone="success"' not in monitor[empty:discover] + + +def test_monitor_discipline_rows_preserve_focused_cohort_order_without_rank(tmp_path, monkeypatch): + members = tuple( + FocusedCohortMember( + ticker=ticker, + company_name=f"{ticker} Company", + sector="Technology", + industry="Semiconductors", + cohort_rationale="Saved readiness-backed review scope.", + usable_lanes=("price",), + blocked_lanes=("dcf",), + freshness_state="current", + last_review_date="", + next_review_reason="Review saved evidence.", + ) + for ticker in ("BBB", "AAA") + ) + cohort = FocusedCohort( + status="ready", + requested_size=2, + minimum_size=2, + eligible_count=2, + members=members, + message="Two saved companies.", + ) + context = dashboard.build_profile_context(project_root=tmp_path) + monkeypatch.setattr(dashboard, "load_journal_entries", lambda path: ()) + monkeypatch.setattr(dashboard, "load_outcomes", lambda path: ()) + + rows = dashboard.load_dashboard_research_discipline_rows(context, cohort, ()) + + assert [row.ticker for row in rows] == ["BBB", "AAA"] + assert "rank" not in str(rows).lower() + assert all(row.due_lanes == ("Plan", "Evidence") for row in rows) + + +def test_monitor_discipline_failure_is_isolated_to_one_focused_ticker(tmp_path, monkeypatch): + members = tuple( + FocusedCohortMember( + ticker=ticker, + company_name=ticker, + sector="Technology", + industry="Semiconductors", + cohort_rationale="Saved readiness-backed review scope.", + usable_lanes=("price",), + blocked_lanes=("dcf",), + freshness_state="current", + last_review_date="", + next_review_reason="Review saved evidence.", + ) + for ticker in ("BBB", "AAA") + ) + cohort = FocusedCohort("ready", 2, 2, 2, members, "Two saved companies.") + context = dashboard.build_profile_context(project_root=tmp_path) + real_derive = dashboard.derive_journal_state + + def derive_with_one_invalid_ticker(entries, *, profile_key, ticker, as_of): + if ticker == "BBB": + raise ValueError("BBB journal row is invalid") + return real_derive(entries, profile_key=profile_key, ticker=ticker, as_of=as_of) + + monkeypatch.setattr(dashboard, "load_journal_entries", lambda path: ()) + monkeypatch.setattr(dashboard, "load_outcomes", lambda path: ()) + monkeypatch.setattr(dashboard, "derive_journal_state", derive_with_one_invalid_ticker) + + rows = dashboard.load_dashboard_research_discipline_rows(context, cohort, ()) + + assert [(row.ticker, row.status) for row in rows] == [ + ("BBB", "unavailable"), + ("AAA", "process_work_needed"), + ] + assert rows[1].due_lanes == ("Plan", "Evidence") + + +def test_monitor_discipline_empty_state_is_process_only(): + source = dashboard.Path(dashboard.__file__).read_text(encoding="utf-8") + monitor_start = source.index("def render_research_monitor(") + monitor_end = source.index("def render_company_workbench(", monitor_start) + monitor = source[monitor_start:monitor_end] + + assert "No process item is currently due from saved reviewer-authored evidence." in monitor + assert "This does not claim that no market event, risk, or external research need exists." in monitor + + +def test_research_evidence_detours_offer_return_before_evidence_content(): + source = dashboard.Path(dashboard.__file__).read_text(encoding="utf-8") + data_start = source.index('elif content_page == "Data Health":') + data_end = source.index('elif content_page == PROOF_HISTORY_PATH_TITLE:', data_start) + data = source[data_start:data_end] + proof_start = data_end + proof_end = source.index('elif content_page == "Universe Manager":', proof_start) + proof = source[proof_start:proof_end] + + for branch, renderer in ((data, "render_data_health("), (proof, "render_proof_history(")): + header = branch.index("render_research_workspace_header(") + return_link = branch.index("research_evidence_return_link(", header) + button = branch.index("st.link_button(", return_link) + purpose = branch.index('st.caption(return_link["purpose"])', button) + content = branch.index(renderer, purpose) + assert header < return_link < button < purpose < content + + +def test_dashboard_theme_keeps_primary_link_button_contrast_accessible(): + source = dashboard.Path(dashboard.__file__).read_text(encoding="utf-8") + + assert '[data-testid="stLinkButton"] a[kind="primary"] {' in source + assert '[data-testid="stLinkButton"] a[kind="primary"] * {' in source + primary_link_start = source.index('[data-testid="stLinkButton"] a[kind="primary"] {') + primary_link_theme = source[ + primary_link_start : source.index("}", primary_link_start) + 1 + ] + assert "background: #0b3b36 !important;" in primary_link_theme + assert "border-color: #0b3b36 !important;" in primary_link_theme + assert "color: #ffffff !important;" in primary_link_theme + + +def test_new_evidence_loaders_fail_closed_on_invalid_local_ledgers(tmp_path, monkeypatch): + from types import SimpleNamespace + + data_dir = tmp_path / "data" + data_dir.mkdir() + (data_dir / "historical_valuation_observations.csv").write_text("ticker,numerator\nNVDA,nope\n", encoding="utf-8") + (data_dir / "research_outcome_reviews.csv").write_text("bad,header\n1,2\n", encoding="utf-8") + (data_dir / "catalyst_evidence.csv").write_text("bad,header\n1,2\n", encoding="utf-8") + monkeypatch.setattr(dashboard, "DATA_DIR", data_dir) + context = SimpleNamespace(profile_key="default") + + valuation = dashboard.load_dashboard_valuation_regime("NVDA", as_of="2026-07-18T05:00:00Z") + outcome = dashboard.load_dashboard_outcome_status(context, ticker="NVDA") + catalyst = dashboard.load_dashboard_catalyst_timeline( + context, + ticker="NVDA", + as_of="2026-07-18T05:00:00Z", + ) + + assert valuation.state == "insufficient_history" + assert outcome.state == "not_started" + assert catalyst.state == "blocked" + + +def test_optional_dashboard_evidence_loaders_enable_commercial_composition(): + source = dashboard.Path(dashboard.__file__).read_text(encoding="utf-8") + valuation_start = source.index("def load_dashboard_valuation_regime(") + outcome_start = source.index("def load_dashboard_outcome_status(", valuation_start) + catalyst_start = source.index("def load_dashboard_catalyst_timeline(", outcome_start) + scenario_start = source.index("def scenario_lab_input_from_report(", catalyst_start) + + assert "commercial_mode=True" in source[valuation_start:outcome_start] + assert "commercial_mode=True" in source[outcome_start:catalyst_start] + assert "commercial_mode=True" in source[catalyst_start:scenario_start] + + +def test_company_workbench_uses_one_authoritative_task_arbitration(): + source = Path("src/dashboard.py").read_text(encoding="utf-8") + workbench_start = source.index('st.markdown("## Research Conclusion")') + workbench_end = source.index("\n if public_mode and report_payload", workbench_start) + composition = source[workbench_start:workbench_end] + + assert "company_next_research_task(" in composition + assert 'st.markdown("## Next Research Task")' in composition + assert '"title": str(authoritative_task["title"])' in composition + assert '"body": str(authoritative_task["body"])' in composition + assert '"badges": list(authoritative_task["badges"])' in composition + assert '"state": str(authoritative_task["state"])' in composition + assert composition.count('"kicker": "ONE NEXT TASK"') == 1 + + +def test_company_workbench_change_badge_uses_explicit_change_context_kind(): + source = Path("src/dashboard.py").read_text(encoding="utf-8") + change_start = source.index('change_answer = company_change_answer(ticker, research_review_items)') + change_end = source.index('st.markdown("## Business Trend")', change_start) + composition = source[change_start:change_end] + + assert 'change_answer["change_context_kind"]' in composition + assert '"source-backed change"' in composition + assert '"snapshot evidence only"' in composition + assert '"no queued change"' in composition + assert '"source-backed only"' not in composition + + +def test_company_workbench_places_one_decision_lab_after_what_changed_before_business_trend(): + source = Path("src/dashboard.py").read_text(encoding="utf-8") + report_start = source.index("def render_single_stock_report(") + report_end = source.index("\ndef render_data_health(", report_start) + report = source[report_start:report_end] + + selected_answer = report.index("render_single_stock_public_summary(") + what_changed = report.index('st.markdown("## What Changed")', selected_answer) + decision_lab = report.index('st.markdown("## Research Decision Lab")', what_changed) + business_trend = report.index('st.markdown("## Business Trend")', decision_lab) + conclusion = report.index('st.markdown("## Research Conclusion")', business_trend) + next_task = report.index('st.markdown("## Next Research Task")', conclusion) + + assert selected_answer < what_changed < decision_lab < business_trend < conclusion < next_task + assert report.count('st.markdown("## Research Decision Lab")') == 1 + assert 'st.expander("Advanced: Decision Lab evidence", expanded=False)' in report + assert "decision_lab_state.identity" in report + + +def test_research_workflow_skip_link_preserves_route_and_precedes_page_answer(): + source = dashboard.Path(dashboard.__file__).read_text(encoding="utf-8") + + href = dashboard.public_workflow_skip_href( + "Company Workbench", + { + "mode": "research", + "page": "company-workbench", + "ticker": "AVGO", + "open": "1", + }, + mode=nav.RESEARCH_MODE, + ) + assert href == "#public-page-answer" + + main_start = source.index("def main()") + output_frames = source.index( + "output_frames = dashboard_output_frames_for_page(content_page)", + main_start, + ) + research_skip = source.index( + "render_public_workflow_skip_link(\n" + " selected_page,\n" + " st.query_params,\n" + " mode=RESEARCH_MODE,\n" + " )", + output_frames, + ) + answer_target = source.index("render_public_workflow_skip_target()", research_skip) + dispatch = source.index( + 'if research_mode and selected_page == "Research Desk":', + answer_target, + ) + + assert research_skip < answer_target < dispatch + + +def test_research_primary_sections_follow_route_h1_with_level_two_headings(): + source = dashboard.Path(dashboard.__file__).read_text(encoding="utf-8") + expected_level_two = ( + "Weekly research summary", + "Research Discipline Review", + "Research change monitor", + "Which stock can I review?", + "What Changed", + "Research Decision Lab", + "Business Trend", + "Valuation", + "Forward View", + "What Remains Withheld", + "Research Conclusion", + "Next Research Task", + "Advanced Evidence", + ) + + for heading in expected_level_two: + assert f'st.markdown("## {heading}")' in source + assert f'st.markdown("### {heading}")' not in source diff --git a/tests/test_research_outcome_review.py b/tests/test_research_outcome_review.py new file mode 100644 index 00000000..c2066f1e --- /dev/null +++ b/tests/test_research_outcome_review.py @@ -0,0 +1,118 @@ +from pathlib import Path + +import pytest + +from src.research_outcome_review import ( + ResearchOutcome, + append_reviewed_outcome, + derive_outcome_status, + load_outcomes, + preview_outcome, +) +from src.commercial_source_rights import SourceRights + + +def _registry(*supported_fields: str): + return { + "reviewed_research_record": SourceRights( + "reviewed_research_record", "Research Record", "evidence", "approved", + "derived_only", "reviewed", "required", "n/a", "none", "reviewed", + supported_fields, 1, + ) + } + + +def _outcome(**overrides) -> ResearchOutcome: + values = { + "schema_version": "research-outcome-review-v1", + "outcome_id": "outcome-001", + "profile_key": "default", + "ticker": "NVDA", + "thesis_id": "thesis-001", + "original_thesis_entry_id": "entry-001", + "reviewed_at": "2026-07-18T05:00:00Z", + "observation_start": "2026-04-01T00:00:00Z", + "observation_end": "2026-07-17T23:59:59Z", + "reviewer": "owner", + "outcome_state": "mixed", + "summary": "Some operating evidence supported the thesis and some did not.", + "source": "reviewed_research_record", + "source_ref": "journal://entry-001", + "source_published_at": "2026-07-17T23:00:00Z", + "learning": "Separate demand evidence from margin evidence next review.", + } + values.update(overrides) + return ResearchOutcome(**values) + + +def test_outcome_preview_is_read_only_and_forbids_performance_scoring(): + preview = preview_outcome(_outcome(), existing=()) + + assert preview.state == "reviewable" + assert preview.write_performed is False + assert "return" not in preview.fields + assert "skill" not in preview.fields + + +def test_outcome_append_requires_confirmation_and_is_immutable(tmp_path: Path): + ledger = tmp_path / "outcomes.csv" + with pytest.raises(ValueError, match="confirm_reviewed"): + append_reviewed_outcome(ledger, _outcome(), confirm_reviewed=False) + + append_reviewed_outcome(ledger, _outcome(), confirm_reviewed=True) + with pytest.raises(ValueError, match="already exists"): + append_reviewed_outcome(ledger, _outcome(), confirm_reviewed=True) + + assert load_outcomes(ledger) == (_outcome(),) + + +def test_outcome_rejects_future_source_and_invalid_observation_window(): + future = preview_outcome( + _outcome(source_published_at="2026-07-19T00:00:00Z"), + existing=(), + ) + invalid_window = preview_outcome( + _outcome(observation_start="2026-07-18T00:00:00Z", observation_end="2026-07-17T00:00:00Z"), + existing=(), + ) + + assert future.state == "rejected" + assert invalid_window.state == "rejected" + + +def test_derived_status_reports_learning_loop_without_grading_company(): + status = derive_outcome_status((_outcome(),), profile_key="default", ticker="NVDA") + assert status.state == "reviewed" + assert status.review_count == 1 + assert status.latest_outcome_state == "mixed" + assert status.next_action == "Use the recorded learning when the thesis is next reviewed." + + +def test_same_underlying_outcome_cannot_be_duplicated_under_a_new_id(): + duplicate = preview_outcome(_outcome(outcome_id="outcome-002"), existing=(_outcome(),)) + assert duplicate.state == "rejected" + assert "duplicate outcome evidence" in duplicate.reason + + +def test_commercial_reviewed_outcome_requires_exact_source_lane_scope(): + blocked = derive_outcome_status( + (_outcome(),), + profile_key="default", + ticker="NVDA", + commercial_mode=True, + rights_registry=_registry("catalyst_evidence"), + ) + reviewed = derive_outcome_status( + (_outcome(),), + profile_key="default", + ticker="NVDA", + commercial_mode=True, + rights_registry=_registry("research_outcomes"), + ) + + assert blocked.state == "commercial_evidence_blocked" + assert blocked.review_count == 0 + assert blocked.commercial_blocker_count == 1 + assert "research_outcomes" in blocked.commercial_blockers[0] + assert reviewed.state == "reviewed" + assert reviewed.review_count == 1 diff --git a/tests/test_research_record_authoring.py b/tests/test_research_record_authoring.py new file mode 100644 index 00000000..e30b0e70 --- /dev/null +++ b/tests/test_research_record_authoring.py @@ -0,0 +1,1019 @@ +import csv +from contextlib import contextmanager +from dataclasses import replace +from pathlib import Path + +import pytest + +from src import research_record_authoring +from src import research_ledger_lock +from src.research_record_authoring import ( + AuthoringPaths, + build_authoring_draft, + confirm_authoring_preview, + preview_authoring_record, +) +from src.research_thesis_journal import JournalEntry, append_journal_entry, load_journal_entries +from src.catalyst_evidence_timeline import CatalystEvent, append_reviewed_event, load_catalyst_events +from src.research_outcome_review import ResearchOutcome, append_reviewed_outcome, load_outcomes + + +def _paths(tmp_path: Path) -> AuthoringPaths: + return AuthoringPaths( + journal=tmp_path / "research_thesis_journal.csv", + catalysts=tmp_path / "catalyst_evidence.csv", + outcomes=tmp_path / "research_outcome_reviews.csv", + ) + + +def _thesis_entry() -> JournalEntry: + return JournalEntry( + schema_version="research-thesis-journal-v1", + entry_id="entry-existing", + profile_key="demo", + ticker="SYN1", + thesis_id="thesis-syn1", + entry_type="thesis", + recorded_at="2026-07-20T12:00:00Z", + effective_at="2026-07-20T11:00:00Z", + reviewer="fixture-reviewer", + summary="Existing synthetic thesis.", + evidence_direction="", + source="", + source_ref="", + source_published_at="", + confidence="0.50", + review_due_date="2026-08-20", + supersedes_entry_id="", + ) + + +@pytest.mark.parametrize( + ("kind", "fields", "destination"), + ( + ("thesis", {"thesis_id": "thesis-syn1", "summary": "Reviewed hypothesis revision.", "effective_at": "2026-07-22T10:00:00Z", "reviewer": "owner", "confidence": "0.60", "review_due_date": "2026-08-22", "supersedes_entry_id": "entry-existing"}, "research_thesis_journal.csv"), + ("evidence", {"thesis_id": "thesis-syn1", "summary": "Source-backed evidence.", "effective_at": "2026-07-22T10:00:00Z", "reviewer": "owner", "evidence_direction": "supporting", "source": "company_ir", "source_ref": "https://example.invalid/source", "source_published_at": "2026-07-22T09:00:00Z"}, "research_thesis_journal.csv"), + ("catalyst", {"event_type": "earnings", "title": "Scheduled results", "summary": "Reviewed event context.", "effective_at": "2026-08-20T21:00:00Z", "published_at": "2026-07-22T09:00:00Z", "retrieved_at": "2026-07-22T10:00:00Z", "source": "company_ir", "source_ref": "https://example.invalid/event", "evidence_state": "candidate_context_only", "reviewer": "owner"}, "catalyst_evidence.csv"), + ("outcome", {"thesis_id": "thesis-syn1", "original_thesis_entry_id": "entry-existing", "reviewed_at": "2026-07-22T12:00:00Z", "observation_start": "2026-07-20T12:00:00Z", "observation_end": "2026-07-22T11:00:00Z", "reviewer": "owner", "outcome_state": "mixed", "summary": "Reviewed outcome.", "source": "reviewed_research_record", "source_ref": "journal://entry-existing", "source_published_at": "2026-07-22T11:00:00Z", "learning": "Separate the evidence lanes."}, "research_outcome_reviews.csv"), + ), +) +def test_preview_maps_all_four_kinds_without_writing(tmp_path, kind, fields, destination): + paths = _paths(tmp_path) + append_journal_entry(paths.journal, _thesis_entry()) + before = {path: path.read_bytes() if path.exists() else None for path in paths.all()} + draft = build_authoring_draft(kind, profile_key="demo", ticker="syn1", fields=fields) + + preview = preview_authoring_record( + draft, + paths=paths, + previewed_at="2026-07-22T12:30:00Z", + generated_id=f"{kind}-generated", + ) + + assert preview.state == "reviewable" + assert preview.profile_key == "demo" + assert preview.ticker == "SYN1" + assert preview.destination_label == destination + assert preview.write_performed is False + assert preview.receipt + assert {path: path.read_bytes() if path.exists() else None for path in paths.all()} == before + + +def test_preview_rejects_cross_scope_evidence_and_outcome_references(tmp_path): + paths = _paths(tmp_path) + append_journal_entry(paths.journal, _thesis_entry()) + fields = {"thesis_id": "thesis-syn1", "summary": "Evidence.", "effective_at": "2026-07-22T10:00:00Z", "reviewer": "owner", "evidence_direction": "supporting", "source": "company_ir", "source_ref": "ref", "source_published_at": "2026-07-22T09:00:00Z"} + + preview = preview_authoring_record( + build_authoring_draft("evidence", profile_key="other", ticker="SYN1", fields=fields), + paths=paths, + previewed_at="2026-07-22T12:30:00Z", + generated_id="evidence-generated", + ) + + assert preview.state == "rejected" + assert preview.reason == "thesis_id must reference an existing thesis in this profile and ticker" + assert preview.receipt == "" + + +@pytest.mark.parametrize( + ("kind", "ledger_name", "fields"), + ( + ( + "catalyst", + "catalysts", + { + "event_type": "earnings", + "title": "Scheduled results", + "summary": "Reviewed event context.", + "effective_at": "2026-08-20T21:00:00Z", + "published_at": "2026-07-22T09:00:00Z", + "retrieved_at": "2026-07-22T10:00:00Z", + "source": "company_ir", + "source_ref": "https://example.invalid/event", + "evidence_state": "candidate_context_only", + "reviewer": "owner", + }, + ), + ( + "outcome", + "outcomes", + { + "thesis_id": "thesis-syn1", + "original_thesis_entry_id": "entry-existing", + "reviewed_at": "2026-07-22T12:00:00Z", + "observation_start": "2026-07-20T12:00:00Z", + "observation_end": "2026-07-22T11:00:00Z", + "reviewer": "owner", + "outcome_state": "mixed", + "summary": "Reviewed outcome.", + "source": "reviewed_research_record", + "source_ref": "journal://entry-existing", + "source_published_at": "2026-07-22T11:00:00Z", + "learning": "Separate the evidence lanes.", + }, + ), + ), +) +def test_preview_rejects_malformed_destination_ledger(tmp_path, kind, ledger_name, fields): + paths = _paths(tmp_path) + append_journal_entry(paths.journal, _thesis_entry()) + getattr(paths, ledger_name).write_bytes(b"\xff") + + preview = preview_authoring_record( + build_authoring_draft(kind, profile_key="demo", ticker="SYN1", fields=fields), + paths=paths, + previewed_at="2026-07-22T12:30:00Z", + generated_id=f"{kind}-generated", + ) + + assert preview.state == "rejected" + assert preview.receipt == "" + + +@pytest.mark.parametrize( + ("kind", "loader_name", "fields"), + ( + ( + "catalyst", + "load_catalyst_events", + { + "event_type": "earnings", + "title": "Scheduled results", + "summary": "Reviewed event context.", + "effective_at": "2026-08-20T21:00:00Z", + "published_at": "2026-07-22T09:00:00Z", + "retrieved_at": "2026-07-22T10:00:00Z", + "source": "company_ir", + "source_ref": "https://example.invalid/event", + "evidence_state": "candidate_context_only", + "reviewer": "owner", + }, + ), + ( + "outcome", + "load_outcomes", + { + "thesis_id": "thesis-syn1", + "original_thesis_entry_id": "entry-existing", + "reviewed_at": "2026-07-22T12:00:00Z", + "observation_start": "2026-07-20T12:00:00Z", + "observation_end": "2026-07-22T11:00:00Z", + "reviewer": "owner", + "outcome_state": "mixed", + "summary": "Reviewed outcome.", + "source": "reviewed_research_record", + "source_ref": "journal://entry-existing", + "source_published_at": "2026-07-22T11:00:00Z", + "learning": "Separate the evidence lanes.", + }, + ), + ), +) +def test_preview_rejects_destination_ledger_csv_errors(tmp_path, monkeypatch, kind, loader_name, fields): + paths = _paths(tmp_path) + append_journal_entry(paths.journal, _thesis_entry()) + + def raise_csv_error(_path): + raise csv.Error("malformed destination ledger") + + monkeypatch.setattr(research_record_authoring, loader_name, raise_csv_error) + + preview = preview_authoring_record( + build_authoring_draft(kind, profile_key="demo", ticker="SYN1", fields=fields), + paths=paths, + previewed_at="2026-07-22T12:30:00Z", + generated_id=f"{kind}-generated", + ) + + assert preview.state == "rejected" + assert preview.reason == "malformed destination ledger" + assert preview.receipt == "" + + +@pytest.mark.parametrize("profile_key, ticker", (("other", "SYN1"), ("demo", "OTHER"))) +def test_preview_rejects_cross_scope_outcome_thesis_reference(tmp_path, profile_key, ticker): + paths = _paths(tmp_path) + append_journal_entry(paths.journal, _thesis_entry()) + fields = { + "thesis_id": "thesis-syn1", + "original_thesis_entry_id": "entry-existing", + "reviewed_at": "2026-07-22T12:00:00Z", + "observation_start": "2026-07-20T12:00:00Z", + "observation_end": "2026-07-22T11:00:00Z", + "reviewer": "owner", + "outcome_state": "mixed", + "summary": "Reviewed outcome.", + "source": "reviewed_research_record", + "source_ref": "journal://entry-existing", + "source_published_at": "2026-07-22T11:00:00Z", + "learning": "Separate the evidence lanes.", + } + + preview = preview_authoring_record( + build_authoring_draft("outcome", profile_key=profile_key, ticker=ticker, fields=fields), + paths=paths, + previewed_at="2026-07-22T12:30:00Z", + generated_id="outcome-generated", + ) + + assert preview.state == "rejected" + assert preview.reason == "outcome must reference an existing thesis entry in this profile and ticker" + assert preview.receipt == "" + + +def test_preview_propagates_programmer_errors(tmp_path, monkeypatch): + paths = _paths(tmp_path) + draft = build_authoring_draft( + "thesis", + profile_key="demo", + ticker="SYN1", + fields={"thesis_id": "thesis-new"}, + ) + + def raise_programmer_error(*_args, **_kwargs): + raise TypeError("unexpected dataclass shape") + + monkeypatch.setattr(research_record_authoring, "_build_record", raise_programmer_error) + + with pytest.raises(TypeError, match="unexpected dataclass shape"): + preview_authoring_record( + draft, + paths=paths, + previewed_at="2026-07-22T12:30:00Z", + generated_id="thesis-generated", + ) + + +def test_confirmation_requires_review_and_appends_exactly_one_ledger(tmp_path): + paths = _paths(tmp_path) + draft = build_authoring_draft( + "thesis", + profile_key="demo", + ticker="SYN1", + fields={ + "thesis_id": "thesis-new", + "summary": "Reviewed hypothesis.", + "effective_at": "2026-07-22T10:00:00Z", + "reviewer": "owner", + "confidence": "0.60", + "review_due_date": "2026-08-22", + "supersedes_entry_id": "", + }, + ) + preview = preview_authoring_record( + draft, + paths=paths, + previewed_at="2026-07-22T12:30:00Z", + generated_id="thesis-generated", + ) + + denied = confirm_authoring_preview( + preview, + current_draft=draft, + paths=paths, + active_profile_key="demo", + active_ticker="SYN1", + active_kind="thesis", + confirm_reviewed=False, + ) + + assert denied.state == "confirmation_required" + assert not any(path.exists() for path in paths.all()) + + saved = confirm_authoring_preview( + preview, + current_draft=draft, + paths=paths, + active_profile_key="demo", + active_ticker="SYN1", + active_kind="thesis", + confirm_reviewed=True, + ) + + assert saved.state == "saved" + assert saved.record_id == "thesis-generated" + assert saved.write_performed is True + assert [row.entry_id for row in load_journal_entries(paths.journal)] == ["thesis-generated"] + assert not paths.catalysts.exists() + assert not paths.outcomes.exists() + + +def test_confirmation_dispatches_the_freshly_recomputed_record_not_mutable_preview_payload(tmp_path): + paths = _paths(tmp_path) + draft = build_authoring_draft( + "thesis", + profile_key="demo", + ticker="SYN1", + fields={ + "thesis_id": "thesis-new", + "summary": "Exact reviewed hypothesis.", + "effective_at": "2026-07-22T10:00:00Z", + "reviewer": "owner", + "confidence": "0.60", + "review_due_date": "2026-08-22", + "supersedes_entry_id": "", + }, + ) + preview = preview_authoring_record( + draft, + paths=paths, + previewed_at="2026-07-22T12:30:00Z", + generated_id="thesis-generated", + ) + tampered = replace( + preview, + record=replace(preview.record, summary="Unpreviewed mutable payload."), + ) + + saved = confirm_authoring_preview( + tampered, + current_draft=draft, + paths=paths, + active_profile_key="demo", + active_ticker="SYN1", + active_kind="thesis", + confirm_reviewed=True, + ) + + assert saved.state == "saved" + assert saved.record_id == "thesis-generated" + assert load_journal_entries(paths.journal)[0].summary == "Exact reviewed hypothesis." + + +def test_changed_draft_or_ledger_invalidates_preview_without_writing(tmp_path): + paths = _paths(tmp_path) + append_journal_entry(paths.journal, _thesis_entry()) + fields = { + "thesis_id": "thesis-syn1", + "summary": "Evidence.", + "effective_at": "2026-07-22T10:00:00Z", + "reviewer": "owner", + "evidence_direction": "supporting", + "source": "company_ir", + "source_ref": "ref", + "source_published_at": "2026-07-22T09:00:00Z", + } + draft = build_authoring_draft("evidence", profile_key="demo", ticker="SYN1", fields=fields) + preview = preview_authoring_record( + draft, + paths=paths, + previewed_at="2026-07-22T12:30:00Z", + generated_id="evidence-generated", + ) + baseline = paths.journal.read_bytes() + + edited = build_authoring_draft( + "evidence", + profile_key="demo", + ticker="SYN1", + fields={**fields, "summary": "Edited after preview."}, + ) + stale_draft = confirm_authoring_preview( + preview, + current_draft=edited, + paths=paths, + active_profile_key="demo", + active_ticker="SYN1", + active_kind="evidence", + confirm_reviewed=True, + ) + + assert stale_draft.state == "preview_stale" + assert paths.journal.read_bytes() == baseline + + append_journal_entry( + paths.journal, + replace( + _thesis_entry(), + entry_id="entry-concurrent", + entry_type="evidence", + summary="Concurrent evidence.", + evidence_direction="context", + source="company_ir", + source_ref="concurrent-ref", + source_published_at="2026-07-20T11:00:00Z", + confidence="", + review_due_date="", + ), + ) + concurrent = paths.journal.read_bytes() + stale_ledger = confirm_authoring_preview( + preview, + current_draft=draft, + paths=paths, + active_profile_key="demo", + active_ticker="SYN1", + active_kind="evidence", + confirm_reviewed=True, + ) + + assert stale_ledger.state == "preview_stale" + assert paths.journal.read_bytes() == concurrent + + +@pytest.mark.parametrize( + ("kind", "fields", "ledger_name"), + ( + ( + "catalyst", + { + "event_type": "earnings", + "title": "Scheduled results", + "summary": "Reviewed event context.", + "effective_at": "2026-08-20T21:00:00Z", + "published_at": "2026-07-22T09:00:00Z", + "retrieved_at": "2026-07-22T10:00:00Z", + "source": "company_ir", + "source_ref": "https://example.invalid/event", + "evidence_state": "candidate_context_only", + "reviewer": "owner", + }, + "catalysts", + ), + ( + "outcome", + { + "thesis_id": "thesis-syn1", + "original_thesis_entry_id": "entry-existing", + "reviewed_at": "2026-07-22T12:00:00Z", + "observation_start": "2026-07-20T12:00:00Z", + "observation_end": "2026-07-22T11:00:00Z", + "reviewer": "owner", + "outcome_state": "mixed", + "summary": "Reviewed outcome.", + "source": "reviewed_research_record", + "source_ref": "journal://entry-existing", + "source_published_at": "2026-07-22T11:00:00Z", + "learning": "Separate the evidence lanes.", + }, + "outcomes", + ), + ), +) +def test_confirmation_dispatches_selected_nonjournal_ledger_only(tmp_path, kind, fields, ledger_name): + paths = _paths(tmp_path) + append_journal_entry(paths.journal, _thesis_entry()) + draft = build_authoring_draft(kind, profile_key="demo", ticker="SYN1", fields=fields) + preview = preview_authoring_record( + draft, + paths=paths, + previewed_at="2026-07-22T12:30:00Z", + generated_id=f"{kind}-generated", + ) + + saved = confirm_authoring_preview( + preview, + current_draft=draft, + paths=paths, + active_profile_key="demo", + active_ticker="SYN1", + active_kind=kind, + confirm_reviewed=True, + ) + + assert saved.state == "saved" + assert getattr(paths, ledger_name).exists() + assert paths.journal.exists() + assert (paths.outcomes if ledger_name != "outcomes" else paths.catalysts).exists() is False + + +@pytest.mark.parametrize("active_profile_key, active_ticker, active_kind", (("other", "SYN1", "thesis"), ("demo", "OTHER", "thesis"), ("demo", "SYN1", "evidence"))) +def test_confirmation_rejects_changed_context_without_writing(tmp_path, active_profile_key, active_ticker, active_kind): + paths = _paths(tmp_path) + draft = build_authoring_draft("thesis", profile_key="demo", ticker="SYN1", fields={"thesis_id": "thesis-new", "summary": "Reviewed hypothesis.", "effective_at": "2026-07-22T10:00:00Z", "reviewer": "owner", "confidence": "0.60", "review_due_date": "2026-08-22"}) + preview = preview_authoring_record(draft, paths=paths, previewed_at="2026-07-22T12:30:00Z", generated_id="thesis-generated") + + stale = confirm_authoring_preview(preview, current_draft=draft, paths=paths, active_profile_key=active_profile_key, active_ticker=active_ticker, active_kind=active_kind, confirm_reviewed=True) + + assert stale.state == "preview_stale" + assert not any(path.exists() for path in paths.all()) + + +def test_confirmation_rejects_tampered_receipt_without_writing(tmp_path): + paths = _paths(tmp_path) + draft = build_authoring_draft("thesis", profile_key="demo", ticker="SYN1", fields={"thesis_id": "thesis-new", "summary": "Reviewed hypothesis.", "effective_at": "2026-07-22T10:00:00Z", "reviewer": "owner", "confidence": "0.60", "review_due_date": "2026-08-22"}) + preview = preview_authoring_record(draft, paths=paths, previewed_at="2026-07-22T12:30:00Z", generated_id="thesis-generated") + + stale = confirm_authoring_preview(replace(preview, receipt="tampered"), current_draft=draft, paths=paths, active_profile_key="demo", active_ticker="SYN1", active_kind="thesis", confirm_reviewed=True) + + assert stale.state == "preview_stale" + assert not any(path.exists() for path in paths.all()) + + +@pytest.mark.parametrize( + "tamper", + ( + lambda preview: replace( + preview, + persisted_fields=( + *preview.persisted_fields[:-1], + ("supersedes_entry_id", "unpreviewed-entry"), + ), + ), + lambda preview: replace(preview, destination_label="unpreviewed-ledger.csv"), + ), +) +def test_confirmation_rejects_tampered_displayed_preview_metadata_without_writing( + tmp_path, tamper +): + paths = _paths(tmp_path) + draft = build_authoring_draft( + "thesis", + profile_key="demo", + ticker="SYN1", + fields={ + "thesis_id": "thesis-new", + "summary": "Exact reviewed hypothesis.", + "effective_at": "2026-07-22T10:00:00Z", + "reviewer": "owner", + "confidence": "0.60", + "review_due_date": "2026-08-22", + }, + ) + preview = preview_authoring_record( + draft, + paths=paths, + previewed_at="2026-07-22T12:30:00Z", + generated_id="thesis-generated", + ) + + result = confirm_authoring_preview( + tamper(preview), + current_draft=draft, + paths=paths, + active_profile_key="demo", + active_ticker="SYN1", + active_kind="thesis", + confirm_reviewed=True, + ) + + assert result.state == "preview_stale" + assert not any(path.exists() for path in paths.all()) + + +@pytest.mark.parametrize( + ("kind", "fields"), + ( + ( + "catalyst", + { + "event_type": "earnings", + "title": "Scheduled results", + "summary": "Reviewed event context.", + "effective_at": "2026-08-20T21:00:00Z", + "published_at": "2026-07-22T09:00:00Z", + "retrieved_at": "2026-07-22T10:00:00Z", + "source": "company_ir", + "source_ref": "https://example.invalid/event", + "evidence_state": "candidate_context_only", + "reviewer": "owner", + }, + ), + ( + "outcome", + { + "thesis_id": "thesis-syn1", + "original_thesis_entry_id": "entry-existing", + "reviewed_at": "2026-07-22T12:00:00Z", + "observation_start": "2026-07-20T12:00:00Z", + "observation_end": "2026-07-22T11:00:00Z", + "reviewer": "owner", + "outcome_state": "mixed", + "summary": "Reviewed outcome.", + "source": "reviewed_research_record", + "source_ref": "journal://entry-existing", + "source_published_at": "2026-07-22T11:00:00Z", + "learning": "Separate the evidence lanes.", + }, + ), + ), +) +def test_confirmation_rejects_tampered_displayed_preview_time_without_writing( + tmp_path, kind, fields +): + paths = _paths(tmp_path) + if kind == "outcome": + append_journal_entry(paths.journal, _thesis_entry()) + draft = build_authoring_draft(kind, profile_key="demo", ticker="SYN1", fields=fields) + preview = preview_authoring_record( + draft, + paths=paths, + previewed_at="2026-07-22T12:30:00Z", + generated_id=f"{kind}-generated", + ) + before = {path: path.read_bytes() if path.exists() else None for path in paths.all()} + + result = confirm_authoring_preview( + replace(preview, previewed_at="2026-07-22T12:31:00Z"), + current_draft=draft, + paths=paths, + active_profile_key="demo", + active_ticker="SYN1", + active_kind=kind, + confirm_reviewed=True, + ) + + assert result.state == "preview_stale" + assert {path: path.read_bytes() if path.exists() else None for path in paths.all()} == before + + +def test_confirmation_propagates_programmer_errors(tmp_path, monkeypatch): + paths = _paths(tmp_path) + draft = build_authoring_draft("thesis", profile_key="demo", ticker="SYN1", fields={"thesis_id": "thesis-new", "summary": "Reviewed hypothesis.", "effective_at": "2026-07-22T10:00:00Z", "reviewer": "owner", "confidence": "0.60", "review_due_date": "2026-08-22"}) + preview = preview_authoring_record(draft, paths=paths, previewed_at="2026-07-22T12:30:00Z", generated_id="thesis-generated") + + def raise_programmer_error(*_args, **_kwargs): + raise TypeError("unexpected append contract") + + monkeypatch.setattr(research_record_authoring, "append_journal_entry", raise_programmer_error) + + with pytest.raises(TypeError, match="unexpected append contract"): + confirm_authoring_preview(preview, current_draft=draft, paths=paths, active_profile_key="demo", active_ticker="SYN1", active_kind="thesis", confirm_reviewed=True) + + +def test_confirmation_rejects_a_same_name_ledger_at_a_different_resolved_path(tmp_path): + paths = _paths(tmp_path) + draft = build_authoring_draft( + "thesis", profile_key="demo", ticker="SYN1", fields={ + "thesis_id": "thesis-new", "summary": "Reviewed hypothesis.", + "effective_at": "2026-07-22T10:00:00Z", "reviewer": "owner", + "confidence": "0.60", "review_due_date": "2026-08-22", + }, + ) + preview = preview_authoring_record( + draft, paths=paths, previewed_at="2026-07-22T12:30:00Z", generated_id="thesis-generated" + ) + redirected = replace(paths, journal=tmp_path / "redirected" / paths.journal.name) + + result = confirm_authoring_preview( + preview, current_draft=draft, paths=redirected, active_profile_key="demo", + active_ticker="SYN1", active_kind="thesis", confirm_reviewed=True, + ) + + assert result.state == "preview_stale" + assert not redirected.journal.exists() + + +def test_confirmation_rejects_a_write_injected_after_preview_recomputation(tmp_path, monkeypatch): + paths = _paths(tmp_path) + append_journal_entry(paths.journal, _thesis_entry()) + draft = build_authoring_draft( + "evidence", profile_key="demo", ticker="SYN1", fields={ + "thesis_id": "thesis-syn1", "summary": "Evidence.", + "effective_at": "2026-07-22T10:00:00Z", "reviewer": "owner", + "evidence_direction": "supporting", "source": "company_ir", "source_ref": "ref", + "source_published_at": "2026-07-22T09:00:00Z", + }, + ) + preview = preview_authoring_record( + draft, paths=paths, previewed_at="2026-07-22T12:30:00Z", generated_id="evidence-generated" + ) + preview_original = research_record_authoring.preview_authoring_record + + def preview_then_append(*args, **kwargs): + refreshed = preview_original(*args, **kwargs) + append_journal_entry( + paths.journal, + replace( + _thesis_entry(), + entry_id="entry-injected", + entry_type="evidence", + summary="Injected concurrent evidence.", + evidence_direction="context", + source="company_ir", + source_ref="injected-ref", + source_published_at="2026-07-20T11:00:00Z", + confidence="", + review_due_date="", + ), + ) + return refreshed + + monkeypatch.setattr(research_record_authoring, "preview_authoring_record", preview_then_append) + result = confirm_authoring_preview( + preview, current_draft=draft, paths=paths, active_profile_key="demo", + active_ticker="SYN1", active_kind="evidence", confirm_reviewed=True, + ) + + assert result.state == "preview_stale" + assert [entry.entry_id for entry in load_journal_entries(paths.journal)] == [ + "entry-existing", "entry-injected" + ] + + +def test_confirmation_rejects_a_non_reviewable_preview_without_writing(tmp_path): + paths = _paths(tmp_path) + draft = build_authoring_draft("thesis", profile_key="demo", ticker="SYN1", fields={"thesis_id": "thesis-new"}) + preview = preview_authoring_record( + draft, paths=paths, previewed_at="2026-07-22T12:30:00Z", generated_id="thesis-generated" + ) + + result = confirm_authoring_preview( + preview, current_draft=draft, paths=paths, active_profile_key="demo", + active_ticker="SYN1", active_kind="thesis", confirm_reviewed=True, + ) + + assert result.state == "rejected" + assert not any(path.exists() for path in paths.all()) + + +def test_confirmation_reports_save_failed_when_the_append_engine_fails(tmp_path, monkeypatch): + paths = _paths(tmp_path) + draft = build_authoring_draft( + "thesis", profile_key="demo", ticker="SYN1", fields={ + "thesis_id": "thesis-new", "summary": "Reviewed hypothesis.", + "effective_at": "2026-07-22T10:00:00Z", "reviewer": "owner", + "confidence": "0.60", "review_due_date": "2026-08-22", + }, + ) + preview = preview_authoring_record( + draft, paths=paths, previewed_at="2026-07-22T12:30:00Z", generated_id="thesis-generated" + ) + monkeypatch.setattr(research_record_authoring, "append_journal_entry", lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("disk unavailable"))) + + result = confirm_authoring_preview( + preview, current_draft=draft, paths=paths, active_profile_key="demo", + active_ticker="SYN1", active_kind="thesis", confirm_reviewed=True, + ) + + assert result.state == "save_failed" + assert result.write_performed is False + assert not paths.journal.exists() + + +@pytest.mark.parametrize( + ("kind", "fields", "append_name", "expected_id"), + ( + ( + "thesis", + { + "thesis_id": "thesis-new", + "summary": "Reviewed hypothesis.", + "effective_at": "2026-07-22T10:00:00Z", + "reviewer": "owner", + "confidence": "0.60", + "review_due_date": "2026-08-22", + }, + "append_journal_entry", + "thesis-generated", + ), + ( + "catalyst", + { + "event_type": "earnings", + "title": "Scheduled results", + "summary": "Reviewed event context.", + "effective_at": "2026-08-20T21:00:00Z", + "published_at": "2026-07-22T09:00:00Z", + "retrieved_at": "2026-07-22T10:00:00Z", + "source": "company_ir", + "source_ref": "https://example.invalid/event", + "evidence_state": "candidate_context_only", + "reviewer": "owner", + }, + "append_reviewed_event", + "catalyst-generated", + ), + ( + "outcome", + { + "thesis_id": "thesis-syn1", + "original_thesis_entry_id": "entry-existing", + "reviewed_at": "2026-07-22T12:00:00Z", + "observation_start": "2026-07-20T12:00:00Z", + "observation_end": "2026-07-22T11:00:00Z", + "reviewer": "owner", + "outcome_state": "mixed", + "summary": "Reviewed outcome.", + "source": "reviewed_research_record", + "source_ref": "journal://entry-existing", + "source_published_at": "2026-07-22T11:00:00Z", + "learning": "Separate the evidence lanes.", + }, + "append_reviewed_outcome", + "outcome-generated", + ), + ), +) +def test_confirmation_requires_reload_when_append_writes_then_raises( + tmp_path, monkeypatch, kind, fields, append_name, expected_id +): + paths = _paths(tmp_path) + if kind == "outcome": + append_journal_entry(paths.journal, _thesis_entry()) + draft = build_authoring_draft(kind, profile_key="demo", ticker="SYN1", fields=fields) + preview = preview_authoring_record( + draft, + paths=paths, + previewed_at="2026-07-22T12:30:00Z", + generated_id=expected_id, + ) + original_append = getattr(research_record_authoring, append_name) + + def write_then_raise(*args, **kwargs): + original_append(*args, **kwargs) + raise OSError("completion acknowledgement unavailable") + + monkeypatch.setattr(research_record_authoring, append_name, write_then_raise) + + result = confirm_authoring_preview( + preview, + current_draft=draft, + paths=paths, + active_profile_key="demo", + active_ticker="SYN1", + active_kind=kind, + confirm_reviewed=True, + ) + + assert result.state == "save_pending_reload" + assert result.record_id == expected_id + assert result.write_performed is False + if kind == "thesis": + assert [row.entry_id for row in load_journal_entries(paths.journal)] == [expected_id] + elif kind == "catalyst": + assert [row.event_id for row in load_catalyst_events(paths.catalysts)] == [expected_id] + else: + assert [row.outcome_id for row in load_outcomes(paths.outcomes)] == [expected_id] + + +@pytest.mark.parametrize( + ("module", "append", "path_name", "row"), + ( + ( + "research_thesis_journal", append_journal_entry, "journal.csv", _thesis_entry(), + ), + ( + "catalyst_evidence_timeline", append_reviewed_event, "catalysts.csv", + CatalystEvent("catalyst-evidence-v1", "event-lock", "demo", "SYN1", "earnings", "Results", "2026-08-20T21:00:00Z", "2026-07-22T09:00:00Z", "2026-07-22T10:00:00Z", "company_ir", "https://example.invalid/event", "candidate_context_only", "owner", "Context."), + ), + ( + "research_outcome_review", append_reviewed_outcome, "outcomes.csv", + ResearchOutcome("research-outcome-review-v1", "outcome-lock", "demo", "SYN1", "thesis-syn1", "entry-existing", "2026-07-22T12:00:00Z", "2026-07-20T12:00:00Z", "2026-07-22T11:00:00Z", "owner", "mixed", "Outcome.", "reviewed_research_record", "journal://entry-existing", "2026-07-22T11:00:00Z", "Learning."), + ), + ), +) +def test_direct_append_engines_participate_in_the_shared_ledger_lock(tmp_path, monkeypatch, module, append, path_name, row): + imported = __import__(f"src.{module}", fromlist=["ledger_write_lock"]) + locked: list[Path] = [] + + @contextmanager + def probe(path): + locked.append(Path(path)) + yield + + monkeypatch.setattr(imported, "ledger_write_lock", probe) + kwargs = {} if module == "research_thesis_journal" else {"confirm_reviewed": True} + append(tmp_path / path_name, row, **kwargs) + + assert locked == [tmp_path / path_name] + + +def test_shared_ledger_lock_releases_its_thread_state_when_lock_acquisition_fails(tmp_path, monkeypatch): + destination = tmp_path / "journal.csv" + lock_path = tmp_path / "lock-artifact" + monkeypatch.setattr(research_ledger_lock, "_lock_artifact", lambda _path: lock_path) + monkeypatch.setattr(Path, "open", lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("lock unavailable"))) + + with pytest.raises(OSError, match="lock unavailable"): + with research_ledger_lock.ledger_write_lock(destination): + pass + + state = research_ledger_lock._states[str(research_ledger_lock.resolve_ledger_path(destination))] + assert state.depth == 0 + assert state.handle is None + + +def test_confirmation_fails_closed_when_the_shared_lock_cannot_be_acquired(tmp_path, monkeypatch): + paths = _paths(tmp_path) + draft = build_authoring_draft( + "thesis", profile_key="demo", ticker="SYN1", fields={ + "thesis_id": "thesis-new", "summary": "Reviewed hypothesis.", + "effective_at": "2026-07-22T10:00:00Z", "reviewer": "owner", + "confidence": "0.60", "review_due_date": "2026-08-22", + }, + ) + preview = preview_authoring_record( + draft, paths=paths, previewed_at="2026-07-22T12:30:00Z", generated_id="thesis-generated" + ) + + @contextmanager + def unavailable_lock(_path): + raise OSError("lock unavailable") + yield + + monkeypatch.setattr(research_record_authoring, "ledger_write_lock", unavailable_lock) + result = confirm_authoring_preview( + preview, current_draft=draft, paths=paths, active_profile_key="demo", + active_ticker="SYN1", active_kind="thesis", confirm_reviewed=True, + ) + + assert result.state == "save_failed" + assert result.record_id == "" + assert not paths.journal.exists() + + +def test_confirmation_requires_read_side_reload_when_lock_teardown_fails_after_append(tmp_path, monkeypatch): + paths = _paths(tmp_path) + draft = build_authoring_draft( + "thesis", profile_key="demo", ticker="SYN1", fields={ + "thesis_id": "thesis-new", "summary": "Reviewed hypothesis.", + "effective_at": "2026-07-22T10:00:00Z", "reviewer": "owner", + "confidence": "0.60", "review_due_date": "2026-08-22", + }, + ) + preview = preview_authoring_record( + draft, paths=paths, previewed_at="2026-07-22T12:30:00Z", generated_id="thesis-generated" + ) + + @contextmanager + def teardown_failure(path): + yield Path(path) + raise OSError("unlock unavailable") + + monkeypatch.setattr(research_record_authoring, "ledger_write_lock", teardown_failure) + result = confirm_authoring_preview( + preview, current_draft=draft, paths=paths, active_profile_key="demo", + active_ticker="SYN1", active_kind="thesis", confirm_reviewed=True, + ) + + assert result.state == "save_pending_reload" + assert result.record_id == "thesis-generated" + assert result.write_performed is False + assert [entry.entry_id for entry in load_journal_entries(paths.journal)] == ["thesis-generated"] + + +def test_confirmation_preserves_ambiguous_append_result_when_lock_teardown_also_fails( + tmp_path, monkeypatch +): + paths = _paths(tmp_path) + draft = build_authoring_draft( + "thesis", + profile_key="demo", + ticker="SYN1", + fields={ + "thesis_id": "thesis-new", + "summary": "Reviewed hypothesis.", + "effective_at": "2026-07-22T10:00:00Z", + "reviewer": "owner", + "confidence": "0.60", + "review_due_date": "2026-08-22", + }, + ) + preview = preview_authoring_record( + draft, + paths=paths, + previewed_at="2026-07-22T12:30:00Z", + generated_id="thesis-generated", + ) + original_append = research_record_authoring.append_journal_entry + + def write_then_raise(*args, **kwargs): + original_append(*args, **kwargs) + raise OSError("completion acknowledgement unavailable") + + @contextmanager + def teardown_failure(path): + yield Path(path) + raise OSError("unlock unavailable") + + monkeypatch.setattr(research_record_authoring, "append_journal_entry", write_then_raise) + monkeypatch.setattr(research_record_authoring, "ledger_write_lock", teardown_failure) + + result = confirm_authoring_preview( + preview, + current_draft=draft, + paths=paths, + active_profile_key="demo", + active_ticker="SYN1", + active_kind="thesis", + confirm_reviewed=True, + ) + + assert result.state == "save_pending_reload" + assert result.record_id == "thesis-generated" + assert result.write_performed is False + assert [entry.entry_id for entry in load_journal_entries(paths.journal)] == [ + "thesis-generated" + ] diff --git a/tests/test_research_record_authoring_ui.py b/tests/test_research_record_authoring_ui.py new file mode 100644 index 00000000..0b379851 --- /dev/null +++ b/tests/test_research_record_authoring_ui.py @@ -0,0 +1,545 @@ +import csv +from contextlib import contextmanager +from pathlib import Path + +import pytest +import streamlit as st +from streamlit.testing.v1 import AppTest + +from src.research_record_authoring import AuthoringPaths +from src import research_record_authoring +from src import research_record_authoring_ui +from src.research_record_authoring_ui import authoring_field_contract, authoring_session_key +from src.catalyst_evidence_timeline import CatalystEvent, append_reviewed_event +from src.catalyst_evidence_timeline import load_catalyst_events +from src.research_outcome_review import ( + ResearchOutcome, + append_reviewed_outcome, + load_outcomes, +) +from src.research_thesis_journal import ( + JournalEntry, + append_journal_entry, + derive_journal_state, + load_journal_entries, +) + + +def _paths(tmp_path: Path) -> AuthoringPaths: + return AuthoringPaths(tmp_path / "journal.csv", tmp_path / "catalysts.csv", tmp_path / "outcomes.csv") + + +def _thesis_entry() -> JournalEntry: + return JournalEntry( + "research-thesis-journal-v1", "entry-existing", "demo", "SYN1", "thesis-syn1", "thesis", + "2026-07-20T12:00:00Z", "2026-07-20T11:00:00Z", "fixture-reviewer", + "Existing synthetic thesis.", "", "", "", "", "0.50", "2026-08-20", "", + ) + + +def _app(tmp_path: Path, monkeypatch) -> AppTest: + monkeypatch.setenv("RESEARCH_AUTHORING_FIXTURE_DIR", str(tmp_path)) + return AppTest.from_file("tests/fixtures/research_record_authoring_app.py").run(timeout=20) + + +def _field_key(kind: str, name: str) -> str: + return authoring_session_key("demo", "SYN1", f"field:{kind}:{name}") + + +def _enter_valid_thesis(app: AppTest) -> AppTest: + app.text_input(key=_field_key("thesis", "thesis_id")).set_value("thesis-new") + app.text_area(key=_field_key("thesis", "summary")).set_value("Reviewed synthetic thesis.") + app.text_input(key=_field_key("thesis", "effective_at")).set_value("2026-07-22T10:00:00Z") + app.text_input(key=_field_key("thesis", "reviewer")).set_value("fixture-reviewer") + app.text_input(key=_field_key("thesis", "confidence")).set_value("0.60") + return app.text_input(key=_field_key("thesis", "review_due_date")).set_value("2026-08-22").run() + + +def _enter_valid_non_thesis_record(app: AppTest, kind: str) -> AppTest: + app.selectbox(key=authoring_session_key("demo", "SYN1", "kind")).set_value(kind).run() + fields = { + "evidence": { + "summary": "Reviewed synthetic evidence.", + "effective_at": "2026-07-22T11:00:00Z", + "reviewer": "fixture-reviewer", + "source": "company_ir", + "source_ref": "https://example.invalid/evidence", + "source_published_at": "2026-07-22T10:00:00Z", + }, + "catalyst": { + "title": "Scheduled synthetic results", + "summary": "Reviewed synthetic catalyst context.", + "effective_at": "2026-08-20T21:00:00Z", + "published_at": "2026-07-22T09:00:00Z", + "retrieved_at": "2026-07-22T10:00:00Z", + "source": "company_ir", + "source_ref": "https://example.invalid/event", + "reviewer": "fixture-reviewer", + }, + "outcome": { + "reviewed_at": "2026-07-22T12:00:00Z", + "observation_start": "2026-07-20T12:00:00Z", + "observation_end": "2026-07-22T11:00:00Z", + "reviewer": "fixture-reviewer", + "summary": "Reviewed synthetic outcome.", + "source": "reviewed_research_record", + "source_ref": "journal://entry-existing", + "source_published_at": "2026-07-22T10:00:00Z", + "learning": "Preserve explicit evidence boundaries.", + }, + }[kind] + for name, value in fields.items(): + widget = app.text_area if name in {"summary", "learning"} else app.text_input + widget(key=_field_key(kind, name)).set_value(value) + return app.run() + + +def test_field_contract_is_kind_specific_and_never_exposes_scope_for_editing(): + thesis = authoring_field_contract("thesis") + evidence = authoring_field_contract("evidence") + catalyst = authoring_field_contract("catalyst") + outcome = authoring_field_contract("outcome") + + assert "profile_key" not in thesis and "ticker" not in thesis + assert tuple(thesis) == ("thesis_id", "summary", "effective_at", "reviewer", "confidence", "review_due_date", "supersedes_entry_id") + assert {"evidence_direction", "source", "source_ref", "source_published_at"} <= set(evidence) + assert {"event_type", "evidence_state", "retrieved_at"} <= set(catalyst) + assert {"original_thesis_entry_id", "observation_start", "observation_end", "learning"} <= set(outcome) + assert all("return" not in field and "skill" not in field and "rank" not in field for field in outcome) + + +def test_session_key_is_profile_ticker_and_kind_scoped(): + assert authoring_session_key("demo", "nvda", "preview") == "research-authoring:demo:NVDA:preview" + + +def test_fixture_renders_locked_scope_and_no_confirmation_before_preview(tmp_path, monkeypatch): + app = _app(tmp_path, monkeypatch) + + assert not app.exception + assert "Profile: demo | Ticker: SYN1" in "\n".join(item.value for item in app.markdown) + assert any(item.label == "Validate and preview" for item in app.button) + assert not any(item.label == "Confirm and save" for item in app.button) + assert not any(tmp_path.iterdir()) + + +def test_fixture_uses_controlled_choices_and_scoped_thesis_references(tmp_path, monkeypatch): + paths = _paths(tmp_path) + append_journal_entry(paths.journal, _thesis_entry()) + app = _app(tmp_path, monkeypatch) + app.selectbox(key=authoring_session_key("demo", "SYN1", "kind")).set_value("evidence").run() + + assert app.selectbox(key=_field_key("evidence", "thesis_id")).options == ["thesis-syn1"] + assert app.selectbox(key=_field_key("evidence", "evidence_direction")).options == ["supporting", "conflicting", "context"] + + +def test_existing_active_thesis_is_presented_as_a_locked_revision_lineage(tmp_path, monkeypatch): + paths = _paths(tmp_path) + active = _thesis_entry() + append_journal_entry(paths.journal, active) + + app = _app(tmp_path, monkeypatch) + + assert not app.exception + thesis_id = app.text_input(key=_field_key("thesis", "thesis_id")) + supersedes = app.selectbox(key=_field_key("thesis", "supersedes_entry_id")) + assert thesis_id.value == active.thesis_id + assert thesis_id.disabled is True + assert supersedes.options == [active.entry_id] + assert supersedes.value == active.entry_id + assert "revision of the active thesis" in "\n".join(item.value for item in app.caption) + + +def test_empty_scoped_thesis_options_fail_closed_with_a_readable_message(tmp_path, monkeypatch): + app = _app(tmp_path, monkeypatch) + app.selectbox(key=authoring_session_key("demo", "SYN1", "kind")).set_value("evidence").run() + + assert not app.exception + assert "requires an existing thesis in this locked profile and ticker" in "\n".join(item.value for item in app.warning) + assert not any(item.label == "Validate and preview" for item in app.button) + assert not any(tmp_path.iterdir()) + + +def test_invalid_thesis_preview_shows_error_without_confirmation_or_writing(tmp_path, monkeypatch): + app = _enter_valid_thesis(_app(tmp_path, monkeypatch)) + app.text_input(key=_field_key("thesis", "effective_at")).set_value("not-a-timestamp").run() + app.button(key=authoring_session_key("demo", "SYN1", "validate")).click().run() + + assert not app.exception + assert "effective_at must be an ISO-8601 timestamp" in "\n".join(item.value for item in app.error) + assert not any(item.label == "Confirm and save" for item in app.button) + assert not any(tmp_path.iterdir()) + + +def test_preview_then_confirm_saves_only_the_temporary_journal(tmp_path, monkeypatch): + app = _enter_valid_thesis(_app(tmp_path, monkeypatch)) + app.button(key=authoring_session_key("demo", "SYN1", "validate")).click().run() + + assert not app.exception + assert any(item.value == "#### Exact append-only preview" for item in app.markdown) + assert any(item.label == "Confirm and save" for item in app.button) + assert not any(tmp_path.iterdir()) + + app.checkbox(key=authoring_session_key("demo", "SYN1", "confirmed")).check() + app.button(key=authoring_session_key("demo", "SYN1", "save")).click().run() + + paths = _paths(tmp_path) + assert [entry.thesis_id for entry in load_journal_entries(paths.journal)] == ["thesis-new"] + assert not paths.catalysts.exists() + assert not paths.outcomes.exists() + + +def test_confirmed_record_receipt_is_shown_once_after_the_correct_temporary_ledger_reloads(tmp_path, monkeypatch): + paths = _paths(tmp_path) + app = _enter_valid_thesis(_app(tmp_path, monkeypatch)) + app.button(key=authoring_session_key("demo", "SYN1", "validate")).click().run() + app.checkbox(key=authoring_session_key("demo", "SYN1", "confirmed")).check() + app.button(key=authoring_session_key("demo", "SYN1", "save")).click().run() + + assert not app.exception + saved_id = load_journal_entries(paths.journal)[0].entry_id + assert f"Saved {saved_id}." in "\n".join(item.value for item in app.success) + + app.run() + + assert not app.exception + assert not app.success + + +@pytest.mark.parametrize("kind", ("evidence", "catalyst", "outcome")) +def test_non_thesis_composer_saves_exactly_once_only_after_preview_confirmation_and_reload( + tmp_path, monkeypatch, kind +): + paths = _paths(tmp_path) + thesis = _thesis_entry() + append_journal_entry(paths.journal, thesis) + app = _enter_valid_non_thesis_record(_app(tmp_path, monkeypatch), kind) + + assert not app.exception + assert not any(item.label == "Confirm and save" for item in app.button) + assert not app.success + assert load_journal_entries(paths.journal) == (thesis,) + assert not paths.catalysts.exists() + assert not paths.outcomes.exists() + + app.button(key=authoring_session_key("demo", "SYN1", "validate")).click().run() + + assert not app.exception + assert any(item.value == "#### Exact append-only preview" for item in app.markdown) + assert any(item.label == "Confirm and save" for item in app.button) + assert not app.success + assert load_journal_entries(paths.journal) == (thesis,) + assert not paths.catalysts.exists() + assert not paths.outcomes.exists() + + app.checkbox(key=authoring_session_key("demo", "SYN1", "confirmed")).check() + app.button(key=authoring_session_key("demo", "SYN1", "save")).click().run() + + assert not app.exception + assert len(app.success) == 1 + if kind == "evidence": + rows = load_journal_entries(paths.journal) + assert len(rows) == 2 + saved = rows[-1] + assert saved.entry_type == "evidence" + assert saved.profile_key == "demo" + assert saved.ticker == "SYN1" + assert saved.thesis_id == thesis.thesis_id + assert saved.summary == "Reviewed synthetic evidence." + assert saved.effective_at == "2026-07-22T11:00:00Z" + assert saved.reviewer == "fixture-reviewer" + assert saved.evidence_direction == "supporting" + assert saved.source == "company_ir" + assert saved.source_ref == "https://example.invalid/evidence" + assert saved.source_published_at == "2026-07-22T10:00:00Z" + assert not paths.catalysts.exists() + assert not paths.outcomes.exists() + elif kind == "catalyst": + assert load_journal_entries(paths.journal) == (thesis,) + rows = load_catalyst_events(paths.catalysts) + assert len(rows) == 1 + saved = rows[0] + assert saved.profile_key == "demo" + assert saved.ticker == "SYN1" + assert saved.event_type == "earnings" + assert saved.title == "Scheduled synthetic results" + assert saved.summary == "Reviewed synthetic catalyst context." + assert saved.effective_at == "2026-08-20T21:00:00Z" + assert saved.published_at == "2026-07-22T09:00:00Z" + assert saved.retrieved_at == "2026-07-22T10:00:00Z" + assert saved.source == "company_ir" + assert saved.source_ref == "https://example.invalid/event" + assert saved.evidence_state == "candidate_context_only" + assert saved.reviewer == "fixture-reviewer" + assert not paths.outcomes.exists() + else: + assert load_journal_entries(paths.journal) == (thesis,) + rows = load_outcomes(paths.outcomes) + assert len(rows) == 1 + saved = rows[0] + assert saved.profile_key == "demo" + assert saved.ticker == "SYN1" + assert saved.thesis_id == thesis.thesis_id + assert saved.original_thesis_entry_id == thesis.entry_id + assert saved.reviewed_at == "2026-07-22T12:00:00Z" + assert saved.observation_start == "2026-07-20T12:00:00Z" + assert saved.observation_end == "2026-07-22T11:00:00Z" + assert saved.reviewer == "fixture-reviewer" + assert saved.outcome_state == "supported" + assert saved.summary == "Reviewed synthetic outcome." + assert saved.source == "reviewed_research_record" + assert saved.source_ref == "journal://entry-existing" + assert saved.source_published_at == "2026-07-22T10:00:00Z" + assert saved.learning == "Preserve explicit evidence boundaries." + assert not paths.catalysts.exists() + + saved_message = app.success[0].value + assert "Corrections require a new append-only record" in saved_message + app.run() + + assert not app.exception + assert not app.success + if kind == "evidence": + assert len(load_journal_entries(paths.journal)) == 2 + elif kind == "catalyst": + assert len(load_catalyst_events(paths.catalysts)) == 1 + else: + assert len(load_outcomes(paths.outcomes)) == 1 + + +def test_deleted_confirmed_record_warns_after_temporary_ledger_reload(tmp_path, monkeypatch): + paths = _paths(tmp_path) + app = _enter_valid_thesis(_app(tmp_path, monkeypatch)) + app.button(key=authoring_session_key("demo", "SYN1", "validate")).click().run() + app.checkbox(key=authoring_session_key("demo", "SYN1", "confirmed")).check() + monkeypatch.setattr(st, "rerun", lambda: None) + app.button(key=authoring_session_key("demo", "SYN1", "save")).click().run() + + assert [entry.thesis_id for entry in load_journal_entries(paths.journal)] == ["thesis-new"] + paths.journal.unlink() + app.run() + + assert not app.exception + assert not app.success + assert "Saved record could not be reloaded; review the ledger" in "\n".join( + item.value for item in app.warning + ) + + +def test_teardown_uncertainty_reloads_once_and_never_retries_the_append(tmp_path, monkeypatch): + paths = _paths(tmp_path) + + @contextmanager + def teardown_failure(path): + yield Path(path) + raise OSError("unlock unavailable") + + monkeypatch.setattr(research_record_authoring, "ledger_write_lock", teardown_failure) + app = _enter_valid_thesis(_app(tmp_path, monkeypatch)) + app.button(key=authoring_session_key("demo", "SYN1", "validate")).click().run() + app.checkbox(key=authoring_session_key("demo", "SYN1", "confirmed")).check() + app.button(key=authoring_session_key("demo", "SYN1", "save")).click().run() + + assert [entry.entry_id for entry in load_journal_entries(paths.journal)] + assert len(load_journal_entries(paths.journal)) == 1 + assert len(app.success) == 1 + + app.run() + + assert len(load_journal_entries(paths.journal)) == 1 + assert not app.success + + +def test_teardown_uncertainty_warns_once_when_the_follow_up_reload_is_missing(tmp_path, monkeypatch): + paths = _paths(tmp_path) + + @contextmanager + def teardown_failure(path): + yield Path(path) + raise OSError("unlock unavailable") + + monkeypatch.setattr(research_record_authoring, "ledger_write_lock", teardown_failure) + monkeypatch.setattr(st, "rerun", lambda: None) + app = _enter_valid_thesis(_app(tmp_path, monkeypatch)) + app.button(key=authoring_session_key("demo", "SYN1", "validate")).click().run() + app.checkbox(key=authoring_session_key("demo", "SYN1", "confirmed")).check() + app.button(key=authoring_session_key("demo", "SYN1", "save")).click().run() + paths.journal.unlink() + + app.run() + + assert "Saved record could not be reloaded; review the ledger" in "\n".join( + item.value for item in app.warning + ) + assert len(load_journal_entries(paths.journal)) == 0 + app.run() + assert not app.warning + + +def test_edit_after_preview_hides_save_until_a_fresh_preview(tmp_path, monkeypatch): + app = _enter_valid_thesis(_app(tmp_path, monkeypatch)) + app.button(key=authoring_session_key("demo", "SYN1", "validate")).click().run() + assert any(item.label == "Confirm and save" for item in app.button) + + app.text_area(key=_field_key("thesis", "summary")).set_value("Edited after preview.").run() + + assert "Draft changed after preview" in "\n".join(item.value for item in app.warning) + assert not any(item.label == "Confirm and save" for item in app.button) + assert not any(tmp_path.iterdir()) + + +def test_repreview_of_the_same_draft_resets_confirmation(tmp_path, monkeypatch): + app = _enter_valid_thesis(_app(tmp_path, monkeypatch)) + app.button(key=authoring_session_key("demo", "SYN1", "validate")).click().run() + app.checkbox(key=authoring_session_key("demo", "SYN1", "confirmed")).check() + + app.button(key=authoring_session_key("demo", "SYN1", "validate")).click().run() + + assert app.checkbox(key=authoring_session_key("demo", "SYN1", "confirmed")).value is False + + +def test_preview_of_a_changed_draft_resets_confirmation(tmp_path, monkeypatch): + app = _enter_valid_thesis(_app(tmp_path, monkeypatch)) + app.button(key=authoring_session_key("demo", "SYN1", "validate")).click().run() + app.checkbox(key=authoring_session_key("demo", "SYN1", "confirmed")).check() + app.text_area(key=_field_key("thesis", "summary")).set_value("Changed before re-preview.").run() + + app.button(key=authoring_session_key("demo", "SYN1", "validate")).click().run() + + assert app.checkbox(key=authoring_session_key("demo", "SYN1", "confirmed")).value is False + + +def test_preview_of_a_different_kind_resets_confirmation(tmp_path, monkeypatch): + app = _enter_valid_thesis(_app(tmp_path, monkeypatch)) + app.button(key=authoring_session_key("demo", "SYN1", "validate")).click().run() + app.checkbox(key=authoring_session_key("demo", "SYN1", "confirmed")).check() + app.selectbox(key=authoring_session_key("demo", "SYN1", "kind")).set_value("catalyst").run() + app.selectbox(key=_field_key("catalyst", "event_type")).set_value("earnings") + for name, value in { + "title": "Scheduled results", "summary": "Reviewed context.", + "effective_at": "2026-08-20T21:00:00Z", "published_at": "2026-07-22T09:00:00Z", + "retrieved_at": "2026-07-22T10:00:00Z", "source": "company_ir", + "source_ref": "https://example.invalid/event", "reviewer": "owner", + }.items(): + widget = app.text_area if name == "summary" else app.text_input + widget(key=_field_key("catalyst", name)).set_value(value) + app.selectbox(key=_field_key("catalyst", "evidence_state")).set_value("candidate_context_only").run() + + app.button(key=authoring_session_key("demo", "SYN1", "validate")).click().run() + + assert app.checkbox(key=authoring_session_key("demo", "SYN1", "confirmed")).value is False + + +def test_post_save_repreview_resets_confirmation(tmp_path, monkeypatch): + app = _enter_valid_thesis(_app(tmp_path, monkeypatch)) + app.button(key=authoring_session_key("demo", "SYN1", "validate")).click().run() + app.checkbox(key=authoring_session_key("demo", "SYN1", "confirmed")).check() + app.button(key=authoring_session_key("demo", "SYN1", "save")).click().run() + + app = _enter_valid_thesis(app) + app.text_area(key=_field_key("thesis", "summary")).set_value( + "Reviewed synthetic thesis revision." + ).run() + app.button(key=authoring_session_key("demo", "SYN1", "validate")).click().run() + + assert app.checkbox(key=authoring_session_key("demo", "SYN1", "confirmed")).value is False + + +def test_initial_thesis_then_ui_revision_reloads_as_one_active_lineage(tmp_path, monkeypatch): + paths = _paths(tmp_path) + app = _enter_valid_thesis(_app(tmp_path, monkeypatch)) + app.button(key=authoring_session_key("demo", "SYN1", "validate")).click().run() + app.checkbox(key=authoring_session_key("demo", "SYN1", "confirmed")).check() + app.button(key=authoring_session_key("demo", "SYN1", "save")).click().run() + + original = load_journal_entries(paths.journal)[0] + assert app.text_input(key=_field_key("thesis", "thesis_id")).value == original.thesis_id + assert app.text_input(key=_field_key("thesis", "thesis_id")).disabled is True + assert app.selectbox(key=_field_key("thesis", "supersedes_entry_id")).value == original.entry_id + + app.text_area(key=_field_key("thesis", "summary")).set_value( + "Reviewed synthetic thesis revision." + ).run() + app.button(key=authoring_session_key("demo", "SYN1", "validate")).click().run() + app.checkbox(key=authoring_session_key("demo", "SYN1", "confirmed")).check() + app.button(key=authoring_session_key("demo", "SYN1", "save")).click().run() + + reloaded = load_journal_entries(paths.journal) + state = derive_journal_state( + reloaded, + profile_key="demo", + ticker="SYN1", + as_of="2026-07-23T00:00:00Z", + ) + assert len(reloaded) == 2 + assert state.current_thesis == reloaded[-1] + assert state.current_thesis.thesis_id == original.thesis_id + assert state.current_thesis.supersedes_entry_id == original.entry_id + + +class _ReceiptUI: + def __init__(self, receipt): + self.session_state = {authoring_session_key("demo", "SYN1", "pending-reload-receipt"): receipt} + self.successes: list[str] = [] + self.warnings: list[str] = [] + + def success(self, message: str) -> None: + self.successes.append(message) + + def warning(self, message: str) -> None: + self.warnings.append(message) + + +def _receipt_record(kind: str): + if kind == "thesis": + return _thesis_entry(), "journal" + if kind == "evidence": + return JournalEntry( + "research-thesis-journal-v1", "evidence-receipt", "demo", "SYN1", "thesis-syn1", "evidence", + "2026-07-22T12:00:00Z", "2026-07-22T11:00:00Z", "owner", "Evidence.", "supporting", + "company_ir", "https://example.invalid/evidence", "2026-07-22T10:00:00Z", "", "", "", + ), "journal" + if kind == "catalyst": + return CatalystEvent( + "catalyst-evidence-v1", "catalyst-receipt", "demo", "SYN1", "earnings", "Results", + "2026-08-20T21:00:00Z", "2026-07-22T09:00:00Z", "2026-07-22T10:00:00Z", "company_ir", + "https://example.invalid/event", "candidate_context_only", "owner", "Context.", + ), "catalysts" + return ResearchOutcome( + "research-outcome-review-v1", "outcome-receipt", "demo", "SYN1", "thesis-syn1", "entry-existing", + "2026-07-22T12:00:00Z", "2026-07-20T12:00:00Z", "2026-07-22T11:00:00Z", "owner", "mixed", + "Outcome.", "reviewed_research_record", "journal://entry-existing", "2026-07-22T11:00:00Z", "Learning.", + ), "outcomes" + + +@pytest.mark.parametrize("kind", ("thesis", "evidence", "catalyst", "outcome")) +@pytest.mark.parametrize("reload_state", ("success", "missing", "invalid_utf8", "malformed_csv", "unreadable")) +def test_pending_receipt_is_consumed_once_and_fails_closed_for_every_temporary_ledger( + tmp_path, monkeypatch, kind, reload_state +): + paths = _paths(tmp_path) + record, path_name = _receipt_record(kind) + destination = getattr(paths, path_name) + loader_names = {"journal": "load_journal_entries", "catalysts": "load_catalyst_events", "outcomes": "load_outcomes"} + if reload_state == "success": + if kind in {"thesis", "evidence"}: + append_journal_entry(destination, record) + elif kind == "catalyst": + append_reviewed_event(destination, record, confirm_reviewed=True) + else: + append_reviewed_outcome(destination, record, confirm_reviewed=True) + elif reload_state == "invalid_utf8": + destination.write_bytes(b"\xff\xfe") + elif reload_state in {"malformed_csv", "unreadable"}: + destination.write_text("not,the,expected,header\n", encoding="utf-8") + error = csv.Error("malformed csv") if reload_state == "malformed_csv" else OSError("unreadable") + monkeypatch.setattr(research_record_authoring_ui, loader_names[path_name], lambda _path: (_ for _ in ()).throw(error)) + receipt = _ReceiptUI({"record_kind": kind, "record_id": getattr(record, "entry_id", getattr(record, "event_id", getattr(record, "outcome_id", "")))}) + + research_record_authoring_ui._show_reloaded_save_receipt( + receipt, profile_key="demo", ticker="SYN1", paths=paths + ) + + receipt_key = authoring_session_key("demo", "SYN1", "pending-reload-receipt") + assert receipt_key not in receipt.session_state + assert bool(receipt.successes) is (reload_state == "success") + assert bool(receipt.warnings) is (reload_state != "success") diff --git a/tests/test_research_thesis_journal.py b/tests/test_research_thesis_journal.py index 3ee6ab2c..d8c70d4e 100644 --- a/tests/test_research_thesis_journal.py +++ b/tests/test_research_thesis_journal.py @@ -134,6 +134,55 @@ def test_thesis_revision_requires_same_profile_ticker_and_thesis(tmp_path): assert state.confidence_history == ((original.recorded_at, 0.55), (revision.recorded_at, 0.60)) +def test_second_active_thesis_must_revise_the_exact_active_lineage(tmp_path): + ledger = tmp_path / "research_thesis_journal.csv" + original = _entry() + append_journal_entry(ledger, original) + + unrelated = _entry( + entry_id="entry-unrelated", + thesis_id="thesis-unrelated", + recorded_at="2026-07-16T20:00:00Z", + effective_at="2026-07-16T19:00:00Z", + source_published_at="2026-07-16T19:00:00Z", + supersedes_entry_id="", + ) + with pytest.raises(ValueError, match="must supersede the active thesis entry"): + append_journal_entry(ledger, unrelated) + + wrong_lineage = _entry( + entry_id="entry-wrong-lineage", + thesis_id="thesis-unrelated", + recorded_at="2026-07-16T20:00:00Z", + effective_at="2026-07-16T19:00:00Z", + source_published_at="2026-07-16T19:00:00Z", + supersedes_entry_id=original.entry_id, + ) + with pytest.raises(ValueError, match="must preserve the active thesis_id"): + append_journal_entry(ledger, wrong_lineage) + + revision = _entry( + entry_id="entry-revision", + recorded_at="2026-07-16T20:00:00Z", + effective_at="2026-07-16T19:00:00Z", + source_published_at="2026-07-16T19:00:00Z", + summary="Reviewed revision on the preserved thesis lineage.", + supersedes_entry_id=original.entry_id, + ) + append_journal_entry(ledger, revision) + + reloaded = load_journal_entries(ledger) + state = derive_journal_state( + reloaded, + profile_key="demo", + ticker="SYN1", + as_of="2026-07-17T00:00:00Z", + ) + assert state.current_thesis == revision + assert state.current_thesis.thesis_id == original.thesis_id + assert state.thesis_revision_count == 1 + + def test_derived_state_is_strictly_profile_and_ticker_scoped(): entries = ( _entry(entry_id="demo-syn1"), diff --git a/tests/test_research_workspace.py b/tests/test_research_workspace.py new file mode 100644 index 00000000..6b66e538 --- /dev/null +++ b/tests/test_research_workspace.py @@ -0,0 +1,720 @@ +from types import SimpleNamespace + +import pytest + +from src.research_workspace import ( + RESEARCH_ROUTING_STATES, + advanced_evidence_links, + advanced_evidence_links_html, + cash_generation_preview_cards, + cash_generation_preview_rows, + company_workbench_section_contract, + company_change_answer, + company_next_research_task, + focused_cohort_cards, + focused_cohort_coverage_cards, + focused_ticker_coverage_cards, + quarterly_trend_cards, + research_desk_cards, + research_desk_cards_html, + research_evidence_return_link, + research_monitor_frame, + research_workspace_header_html, + weekly_summary_cards, +) +from src.company_workbench_cash_generation_preview import ( + CashGenerationPreviewComponent, + CashGenerationPreviewMetric, + CompanyWorkbenchCashGenerationPreview, + blocked_company_workbench_cash_generation_preview, +) +from src.focused_cohort_coverage import FocusedCohortCoverage, FocusedCohortCoverageRow +from src.focused_research_cohort import FocusedCohort, FocusedCohortMember +from src.earnings_nowcast_contract import QuarterlyActual +from src.quarterly_business_trend import build_quarterly_trend_packet +from src.quarterly_cash_generation import QuarterlyBusinessObservation +from src.weekly_research_summary import WeeklyResearchSummary + + +def _quarterly_actual(period: str, revenue: float, eps: float) -> QuarterlyActual: + year = int(period[:4]) + quarter = int(period[-1]) + period_end = {1: "03-31", 2: "06-30", 3: "09-30", 4: "12-31"}[quarter] + return QuarterlyActual( + ticker="SYN1", + fiscal_period=period, + period_end_date=f"{year}-{period_end}", + reported_at=f"{year + (quarter == 4)}-05-15T12:00:00+00:00", + revenue_actual=revenue, + eps_actual=eps, + source="synthetic_test_fixture", + source_ref=f"fixture:{period}:actuals", + retrieved_at="2026-07-18T12:00:00+00:00", + revenue_currency="USD", + revenue_unit_scale=1.0, + revenue_basis="reported", + eps_currency="USD", + eps_basis="gaap", + eps_share_basis="diluted", + eps_operations_basis="reported", + split_adjustment_basis="as_reported", + ) + + +def _quarterly_business_observation( + period: str, + metric: str, + value: float, +) -> QuarterlyBusinessObservation: + year = int(period[:4]) + quarter = int(period[-1]) + period_end = {1: "03-31", 2: "06-30", 3: "09-30", 4: "12-31"}[quarter] + return QuarterlyBusinessObservation( + ticker="SYN1", + fiscal_period=period, + period_end_date=f"{year}-{period_end}", + metric=metric, + value=value, + currency="USD", + unit_scale=1.0, + accounting_basis="reported", + duration_basis="three_months", + source="synthetic_test_fixture", + source_ref=f"fixture:{period}:{metric}", + published_at=f"{year + (quarter == 4)}-05-15T12:00:00+00:00", + retrieved_at="2026-07-18T12:00:00+00:00", + q4_evidence_state="explicit_filed_quarter" if quarter == 4 else "not_q4", + ) + + +def test_company_next_research_task_prioritizes_unresolved_source_change(): + task = company_next_research_task( + { + "state": "review_now", + "next_task": "Review the filed evidence.", + "source_backed_eligible": True, + }, + [{"title": "Add peer mappings", "body": "Peer context is partial.", "state": "wait_for_evidence", "badges": ["peers"]}], + ) + assert task == { + "title": "Review the filed evidence.", + "body": "Complete this source-backed evidence review before starting another research task.", + "state": "review_now", + "badges": ["source-backed change", "research-only"], + } + + +def _review_change_item( + *, + evidence_status: str, + review_status: str, + wait_condition: str = "", +): + event = SimpleNamespace( + event_id=f"evt-{evidence_status}-{review_status}", + ticker="NVDA", + source_ref="sec:accession" if evidence_status == "source_backed" else "snapshot:readiness", + evidence_status=evidence_status, + suggested_research_task="NVDA: Review the changed evidence.", + ) + return SimpleNamespace( + event=event, + review_status=review_status, + wait_condition=wait_condition, + ) + + +def test_company_change_answer_maps_source_backed_open_change_to_eligible_review(): + answer = company_change_answer( + "NVDA", + [_review_change_item(evidence_status="source_backed", review_status="open")], + ) + + assert answer["state"] == "review_now" + assert answer["next_task"] == "NVDA: Review the changed evidence." + assert answer["source_backed_eligible"] is True + assert answer["change_context_kind"] == "source_backed" + + +def test_snapshot_only_open_change_never_outranks_conclusion_priority(): + answer = company_change_answer( + "NVDA", + [_review_change_item(evidence_status="snapshot_evidence", review_status="open")], + ) + task = company_next_research_task( + answer, + [{"title": "Add peer mappings", "body": "Peer context is partial.", "badges": ["peers"]}], + ) + + assert answer["source_backed_eligible"] is False + assert answer["change_context_kind"] == "snapshot_only" + assert task["title"] == "Add peer mappings" + assert "source-backed change" not in task["badges"] + + +def test_source_backed_still_blocked_change_preserves_wait_routing(): + wait_condition = "Wait for the amended filing to become available." + answer = company_change_answer( + "NVDA", + [ + _review_change_item( + evidence_status="source_backed", + review_status="still_blocked", + wait_condition=wait_condition, + ) + ], + ) + task = company_next_research_task(answer, [{"title": "Add peer mappings", "badges": ["peers"]}]) + + assert answer["source_backed_eligible"] is True + assert answer["state"] == "wait_for_evidence" + assert answer["next_task"] == wait_condition + assert task["title"] == wait_condition + assert task["state"] == "wait_for_evidence" + + +def test_source_backed_intentionally_deferred_change_preserves_wait_routing(): + wait_condition = "Resume after the quarterly review window opens." + answer = company_change_answer( + "NVDA", + [ + _review_change_item( + evidence_status="source_backed", + review_status="intentionally_deferred", + wait_condition=wait_condition, + ) + ], + ) + task = company_next_research_task(answer, [{"title": "Add peer mappings", "badges": ["peers"]}]) + + assert answer["source_backed_eligible"] is True + assert answer["state"] == "monitor" + assert answer["next_task"] == wait_condition + assert task["title"] == wait_condition + assert task["state"] == "monitor" + + +@pytest.mark.parametrize( + ("change_answer", "conclusion_cards"), + [ + ("not-a-mapping", []), + ({}, "not-a-card-collection"), + ({}, 7), + ({}, ["not-a-card", {"title": "Do not select this later card", "badges": []}]), + ({}, [{"title": "Malformed badges", "badges": "peers"}]), + ({}, [{"title": "Malformed badges", "badges": 7}]), + ], +) +def test_company_next_research_task_fails_closed_on_malformed_input( + change_answer, + conclusion_cards, +): + neutral_task = { + "title": "Wait for reviewed evidence or choose another company", + "body": "No source-backed change or executable company task is available. Do not infer one from missing data.", + "state": "wait_for_evidence", + "badges": ["monitor", "research-only"], + } + + assert company_next_research_task(change_answer, conclusion_cards) == neutral_task + + +def test_company_next_research_task_uses_ordered_conclusion_priority_without_change(): + task = company_next_research_task( + {"state": "monitor", "next_task": "Continue the current review or wait."}, + [{"title": "Add peer mappings", "body": "Peer context is partial.", "badges": ["peers"]}], + ) + assert task["title"] == "Add peer mappings" + assert task["body"] == "Peer context is partial." + assert task["state"] == "wait_for_evidence" + assert task["badges"] == ["peers", "research-only"] + + +def test_company_next_research_task_fails_closed_to_neutral_wait(): + task = company_next_research_task({}, []) + assert task["title"] == "Wait for reviewed evidence or choose another company" + assert task["state"] == "wait_for_evidence" + assert task["badges"] == ["monitor", "research-only"] + + +def _cash_preview() -> CompanyWorkbenchCashGenerationPreview: + source_url = "https://www.sec.gov/Archives/edgar/data/1045810/filing.htm" + return CompanyWorkbenchCashGenerationPreview( + ticker="NVDA", + fiscal_period="2027-Q1", + status="accepted_for_review", + message="Accepted SEC evidence supports a cash-generation review preview.", + operating_margin=CashGenerationPreviewMetric( + "operating_margin", + "preview_available", + 53_536_000_000 / 81_615_000_000, + "2027-Q1", + (f"{source_url}#operating", f"{source_url}#revenue"), + "", + ), + free_cash_flow=CashGenerationPreviewMetric( + "free_cash_flow", + "preview_available", + 48_587_000_000, + "2027-Q1", + (f"{source_url}#cfo", f"{source_url}#capex"), + "", + ), + fcf_margin=CashGenerationPreviewMetric( + "fcf_margin", + "preview_available", + 48_587_000_000 / 81_615_000_000, + "2027-Q1", + (f"{source_url}#cfo", f"{source_url}#capex", f"{source_url}#revenue"), + "", + ), + blockers=(), + withheld_metrics=(), + accession="0001045810-26-000052", + source_url=source_url, + accepted_at="2026-05-20T20:35:52+00:00", + cutoff="2026-07-21T03:59:59+00:00", + capex_sign_evidence="explicit_filed_table_outflow", + components=( + CashGenerationPreviewComponent( + metric="capital_expenditures", + value=-1_757_000_000, + currency="USD", + fiscal_period="2027-Q1", + source_ref=f"{source_url}#capex", + published_at="2026-05-20T20:35:52+00:00", + retrieved_at="2026-07-20T23:00:00+00:00", + accounting_basis="reported", + duration_basis="three_months", + q4_evidence_state="not_q4", + ), + ), + ) + + +def test_cash_generation_preview_cards_are_answer_first_and_non_production(): + cards = cash_generation_preview_cards(_cash_preview()) + + assert [card["kicker"] for card in cards] == [ + "CASH-GENERATION REVIEW PREVIEW", + "OPERATING MARGIN", + "FREE CASH FLOW", + "FCF MARGIN", + ] + assert cards[0]["title"] == ( + "Cash-generation review preview — not production evidence" + ) + assert cards[1]["title"] == "65.6%" + assert cards[2]["title"] == "48,587,000,000" + assert cards[3]["title"] == "59.5%" + assert all(card["command"] == "" for card in cards) + assert all("preview" in " ".join(card["badges"]).lower() for card in cards) + rendered = str(cards) + assert "0001045810-26-000052" not in rendered + assert "sec.gov" not in rendered + + +def test_cash_generation_preview_rows_keep_lineage_under_advanced(): + rows = cash_generation_preview_rows(_cash_preview()) + + assert any( + row["Evidence"] == "Accession" + and row["Value"] == "0001045810-26-000052" + for row in rows + ) + assert any( + row["Evidence"] == "Capex sign" + and row["Value"] == "explicit_filed_table_outflow" + for row in rows + ) + assert any( + row["Evidence"] == "Component" + and "capital_expenditures" in row["Value"] + and row["Source Reference"].endswith("#capex") + for row in rows + ) + assert any( + row["Evidence"] == "Boundary" + and "production activation false" in row["Value"] + and "readiness promotions none" in row["Value"] + and "no persistence" in row["Value"] + for row in rows + ) + + +def test_withheld_cash_preview_shows_no_numeric_or_component_evidence(): + preview = blocked_company_workbench_cash_generation_preview( + "NVDA", + fiscal_period="2027-Q1", + as_of="2026-07-21T03:59:59+00:00", + blockers=("complete_cash_generation_preview_required",), + ) + + cards = cash_generation_preview_cards(preview) + rows = cash_generation_preview_rows(preview) + + assert [card["title"] for card in cards[1:]] == [ + "Withheld", + "Withheld", + "Withheld", + ] + assert all(card["state"] == "withheld" for card in cards[1:]) + assert "complete_cash_generation_preview_required" in str(cards) + assert not any(row["Evidence"] == "Component" for row in rows) + assert any( + row["Evidence"] == "Blocker" + and row["Value"] == "complete_cash_generation_preview_required" + for row in rows + ) + + +def test_research_desk_answers_changes_attention_blockers_and_next_action_without_recommendations(): + cards = research_desk_cards( + change_status="changes_detected", + review_items=[object(), object()], + readiness_summary={ + "master_universe": 25, + "price_ready": 24, + "dcf_ready": 12, + "peer_ready": 5, + }, + ) + + assert [card["question"] for card in cards] == [ + "What changed?", + "Which companies need attention?", + "What is blocked or stale?", + "What should I review next?", + ] + assert all(card["routing_state"] in RESEARCH_ROUTING_STATES for card in cards) + rendered = " ".join(str(value) for card in cards for value in card.values()).lower() + assert "2 unresolved evidence changes" in rendered + assert "discover" in rendered + assert "buy" not in rendered + assert "sell" not in rendered + assert "recommend" not in rendered + + +def test_company_workbench_contract_keeps_evidence_last(): + sections = company_workbench_section_contract() + + assert [section["title"] for section in sections] == [ + "Selected Company", + "What Changed", + "Business Trend", + "Valuation", + "Forward View", + "What Remains Withheld", + "Research Conclusion", + "Next Research Task", + "Advanced Evidence", + ] + assert sections[-1]["expanded"] is False + assert "Data Health" in sections[-1]["contents"] + assert "Proof History" in sections[-1]["contents"] + + +def test_company_change_answer_is_ticker_scoped_and_does_not_invent_change(): + event = SimpleNamespace( + event_id="evt-nvda", + ticker="NVDA", + subtype="sec_filing_arrived", + source_ref="sec:accession", + evidence_status="source_backed", + suggested_research_task="NVDA: Review the filing.", + ) + item = SimpleNamespace(event=event, review_status="open", wait_condition="") + + changed = company_change_answer("NVDA", [item]) + unchanged = company_change_answer("MSFT", [item]) + + assert changed["state"] == "review_now" + assert changed["answer"] == "1 unresolved source-backed change needs review." + assert changed["source_refs"] == ("sec:accession",) + assert changed["source_backed_eligible"] is True + assert unchanged["state"] == "monitor" + assert unchanged["answer"] == "No unresolved source-backed change is queued for this company." + assert unchanged["source_backed_eligible"] is False + assert unchanged["change_context_kind"] == "none" + + +def test_cohort_trend_and_weekly_cards_keep_truthful_boundaries(): + member = FocusedCohortMember( + "AAA", "A Co", "Technology", "Software", "Source-backed evidence.", + ("price", "dcf"), ("peers",), "stale", "", "Review peer evidence.", + ) + cohort = FocusedCohort("awaiting_reviewed_source", 25, 25, 1, (member,), "Only one eligible company.") + trend = build_quarterly_trend_packet("AAA", []) + weekly = WeeklyResearchSummary( + "no_changes", "2026-07-17T00:00:00+00:00", 1, 0, (), + "No traceable cohort evidence change requires review this week.", + ) + + cohort_cards = focused_cohort_cards(cohort) + trend_cards = quarterly_trend_cards(trend) + summary_cards = weekly_summary_cards(weekly) + + assert "1 of 25" in cohort_cards[0]["title"] + assert cohort_cards[0]["state"] == "awaiting_reviewed_source" + assert trend_cards[0]["state"] == "blocked" + assert "No source-backed quarterly actual" in trend_cards[0]["body"] + assert summary_cards[0]["state"] == "monitor" + rendered = str(cohort_cards + trend_cards + summary_cards).lower() + assert "buy" not in rendered + assert "sell" not in rendered + + +def test_quarterly_trend_cards_keep_cash_generation_withheld_without_reviewed_observations(): + packet = build_quarterly_trend_packet( + "SYN1", + [_quarterly_actual("2025-Q1", 120.0, 1.2)], + ) + + cards = quarterly_trend_cards(packet) + by_kicker = {card["kicker"]: card for card in cards} + + assert by_kicker["OPERATING MARGIN"]["title"] == "Withheld" + assert by_kicker["FREE CASH FLOW"]["title"] == "Withheld" + assert by_kicker["FCF MARGIN"]["title"] == "Withheld" + assert all( + "reviewed" in by_kicker[kicker]["body"].lower() + and "source adapter" in by_kicker[kicker]["body"].lower() + for kicker in ("OPERATING MARGIN", "FREE CASH FLOW", "FCF MARGIN") + ) + + +def test_quarterly_trend_cards_show_cash_conversion_answer_without_raw_formula_or_sources(): + actuals = [ + _quarterly_actual("2024-Q1", 80.0, 0.8), + _quarterly_actual("2024-Q4", 100.0, 1.0), + _quarterly_actual("2025-Q1", 120.0, 1.2), + ] + values = { + "2024-Q1": {"operating_income": 20.0, "cash_from_operations": 24.0, "capital_expenditures": -8.0}, + "2024-Q4": {"operating_income": 20.0, "cash_from_operations": 30.0, "capital_expenditures": -10.0}, + "2025-Q1": {"operating_income": 30.0, "cash_from_operations": 36.0, "capital_expenditures": -12.0}, + } + observations = [ + _quarterly_business_observation(period, metric, value) + for period, metrics in values.items() + for metric, value in metrics.items() + ] + + cards = quarterly_trend_cards( + build_quarterly_trend_packet( + "SYN1", + actuals, + business_observations=observations, + ) + ) + by_kicker = {card["kicker"]: card for card in cards} + + assert by_kicker["OPERATING MARGIN"]["title"] == "25.0%" + assert by_kicker["FREE CASH FLOW"]["title"] == "24" + assert by_kicker["FCF MARGIN"]["title"] == "20.0%" + assert "sequential +25.0%" in by_kicker["OPERATING MARGIN"]["body"] + primary_text = str(cards).lower() + assert "fixture:" not in primary_text + assert "cash_from_operations" not in primary_text + assert "capital_expenditures" not in primary_text + assert "cfo +" not in primary_text + + +def test_focused_cohort_coverage_cards_answer_what_is_usable_without_overclaiming(): + coverage = FocusedCohortCoverage( + status="partial", + company_count=1, + rows=( + FocusedCohortCoverageRow("AAA", "Alpha", "adjusted_daily_price_history", "usable_now", "price", "boundary"), + FocusedCohortCoverageRow("AAA", "Alpha", "quarterly_revenue", "blocked", "missing", "boundary"), + FocusedCohortCoverageRow("AAA", "Alpha", "trusted_peers", "candidate_context_only", "candidate", "not trusted"), + ), + message="Mixed coverage.", + ) + + cards = focused_cohort_coverage_cards(coverage) + + assert cards[0]["title"] == "1 usable lane" + assert cards[1]["title"] == "2 gated lanes" + rendered = str(cards).lower() + assert "candidate context" in rendered + assert "research-only" in rendered + + +def test_focused_ticker_coverage_cards_keep_one_company_answer_concise(): + coverage = FocusedCohortCoverage( + status="partial", + company_count=1, + rows=( + FocusedCohortCoverageRow("AAA", "Alpha", "adjusted_daily_price_history", "usable_now", "price", "boundary"), + FocusedCohortCoverageRow("AAA", "Alpha", "quarterly_revenue", "blocked", "missing", "boundary"), + FocusedCohortCoverageRow("BBB", "Beta", "adjusted_daily_price_history", "usable_now", "price", "boundary"), + ), + message="Mixed coverage.", + ) + + cards = focused_ticker_coverage_cards(coverage, "AAA") + + assert cards[0]["title"] == "1 usable lane" + assert cards[1]["title"] == "1 blocked lane" + assert "BBB" not in str(cards) + + +def test_research_monitor_uses_review_queue_without_ranking_or_inventing_changes(): + event = SimpleNamespace( + event_id="evt-1", + ticker="NVDA", + family="filing", + subtype="sec_filing_arrived", + materiality="medium", + prior_value="000-old", + current_value="000-new", + source_published_at="2026-07-16T12:00:00Z", + detected_at="2026-07-17T12:00:00Z", + suggested_research_task="NVDA: Review the new SEC filing.", + evidence_status="source_backed", + ) + item = SimpleNamespace( + event=event, + priority=20, + review_status="open", + wait_condition="", + ) + + frame = research_monitor_frame([item]) + + assert frame.to_dict("records") == [ + { + "Ticker": "NVDA", + "Change": "Sec Filing Arrived", + "Previous state": "000-old", + "Current state": "000-new", + "Evidence": "source backed", + "Affected section": "Filing", + "Review state": "review_now", + "Effective date": "2026-07-16T12:00:00Z", + "Detected": "2026-07-17T12:00:00Z", + "Next research task": "NVDA: Review the new SEC filing.", + "Wait condition": "", + } + ] + assert "Score" not in frame.columns + assert "Rank" not in frame.columns + + +def test_research_monitor_deduplicates_identical_event_identity_and_preserves_wait_condition(): + event = SimpleNamespace( + event_id="same-event", + ticker="BLOCK", + family="readiness", + subtype="dcf_readiness_changed", + materiality="high", + prior_value="true", + current_value="false", + source_published_at="2026-07-16T00:00:00Z", + detected_at="2026-07-17T00:00:00Z", + suggested_research_task="BLOCK: Review DCF evidence.", + evidence_status="source_backed", + ) + item = SimpleNamespace( + event=event, + priority=10, + review_status="still_blocked", + wait_condition="Wait for a new source-backed filing.", + ) + + frame = research_monitor_frame([item, item]) + + assert len(frame) == 1 + assert frame.iloc[0]["Review state"] == "wait_for_evidence" + assert frame.iloc[0]["Wait condition"] == "Wait for a new source-backed filing." + + +def test_advanced_evidence_links_preserve_personal_research_mode_and_ticker(): + links = advanced_evidence_links("NVDA") + + assert links == [ + { + "label": "Open Data Health", + "href": "?mode=research&page=data-health&ticker=NVDA", + "purpose": "Inspect blocked inputs and source-proof paths.", + }, + { + "label": "Open Proof History", + "href": "?mode=research&page=proof-history&ticker=NVDA", + "purpose": "Review evidence that changed a readiness state.", + }, + ] + + +def test_research_evidence_links_encode_ticker_and_return_to_workbench_or_desk(): + assert advanced_evidence_links("BRK/B")[0]["href"].endswith("ticker=BRK%2FB") + assert research_evidence_return_link("BRK/B") == { + "label": "Return to Company Workbench", + "href": "?mode=research&page=company-workbench&ticker=BRK%2FB&open=1", + "purpose": "Continue the selected-company review without changing evidence state.", + } + assert research_evidence_return_link("") == { + "label": "Return to Research Desk", + "href": "?mode=research&page=research-desk", + "purpose": "Return to the primary research workflow without changing evidence state.", + } + + +def test_research_workspace_header_keeps_scope_freshness_action_and_boundary_visible(): + rendered = research_workspace_header_html( + "Company Workbench", + ticker="NVDA", + profile_label="Local Research", + freshness="Current through 2026-07-16", + primary_action="Review source-backed sections", + ) + + assert "Company Workbench" in rendered + assert "NVDA" in rendered + assert "Local Research" in rendered + assert "Current through 2026-07-16" in rendered + assert "Review source-backed sections" in rendered + assert "class='research-workspace-meta-item research-workspace-freshness'" in rendered + assert "class='research-workspace-meta-item research-workspace-action'" in rendered + assert "Research-only" in rendered + assert "investment advice" in rendered + + +def test_compact_research_workspace_header_keeps_identity_scope_and_boundary_without_duplicate_meta(): + rendered = research_workspace_header_html( + "Company Workbench", + ticker="NVDA", + profile_label="Local Research", + freshness="Current through 2026-07-16", + primary_action="Review source-backed sections", + compact=True, + ) + + assert "research-workspace-header compact" in rendered + assert "

Company Workbench

" in rendered + assert "NVDA" in rendered + assert "Local Research" in rendered + assert "Research-only" in rendered + assert "investment advice" in rendered + assert "Current through 2026-07-16" not in rendered + assert "Review source-backed sections" not in rendered + assert "research-workspace-meta" not in rendered + + +def test_research_desk_and_advanced_evidence_html_stay_answer_first_and_command_free(): + cards = research_desk_cards( + change_status="no_changes", + review_items=[], + readiness_summary={"master_universe": 25, "price_ready": 24, "dcf_ready": 12, "peer_ready": 5}, + ) + desk_html = research_desk_cards_html(cards) + evidence_html = advanced_evidence_links_html("NVDA") + + assert desk_html.count("research-desk-answer") == 4 + assert "What changed?" in desk_html + assert "Open Discover" in desk_html + assert "Open Data Health" in evidence_html + assert "Open Proof History" in evidence_html + assert "make " not in (desk_html + evidence_html).lower() + assert "provider" not in (desk_html + evidence_html).lower() diff --git a/tests/test_reviewed_batch.py b/tests/test_reviewed_batch.py index e8f6d49e..de6836a9 100644 --- a/tests/test_reviewed_batch.py +++ b/tests/test_reviewed_batch.py @@ -160,6 +160,38 @@ def test_readiness_freshness_uses_explicit_selected_profile_paths(tmp_path: Path assert status.status == "current" +def test_readiness_freshness_blocks_newer_declared_source_date_when_file_mtimes_are_current(tmp_path: Path): + root = _sample_root(tmp_path) + _write( + root / "data" / "fundamentals.csv", + ( + "ticker,source,revenue,free_cash_flow,fcf_margin,shares_outstanding,as_of_date\n" + "AAA,manual,100,20,0.20,,2026-07-16\n" + ), + ) + _write( + root / "data" / "reports" / "ticker_readiness_report.csv", + ( + "ticker,price_ready,fundamentals_ready,dcf_ready,peer_ready,generated_at\n" + "AAA,true,true,false,false,2026-07-15T19:30:00+00:00\n" + ), + ) + _write( + root / "data" / "reports" / "feature_readiness_summary.csv", + ( + "feature,ready_count,blocked_count,total_count,generated_at\n" + "fundamentals,1,0,1,2026-07-15T19:30:00+00:00\n" + ), + ) + _mark_readiness_current(root) + + status = readiness_freshness_status(root) + + assert status.status == "stale" + assert "source dates are newer" in status.message.lower() + assert status.refresh_command == "make readiness" + + def test_reviewed_batch_lane_selection_and_top_n_cap(tmp_path: Path): root = _sample_root(tmp_path) _mark_readiness_current(root) @@ -309,7 +341,7 @@ def test_reviewed_batch_packet_includes_v2_proof_ledger_fields_and_peer_sub_lane assert "record peer_mapping_ready, peer_price_ready, peer_momentum_ready" in lowered assert "peer_valuation_comparison_ready" in rendered assert "sector or industry fallback as context only" in lowered - assert "Peer mapping import schema: ticker, peer_ticker, peer_group, sector, industry, source, as_of_date" in rendered + assert "Peer mapping import schema: ticker, peer_ticker, peer_group, sector, industry, peer_role, relationship_rationale, comparability_basis, valuation_anchor_eligible, source, as_of_date" in rendered assert "source must name the peer relationship or comparable business context" in rendered assert "do not use memory, popularity, or row-count convenience as proof" in rendered assert "make peer-batch-proof TOP_N=" in rendered diff --git a/tests/test_sec_quarterly_cash_generation_pilot.py b/tests/test_sec_quarterly_cash_generation_pilot.py new file mode 100644 index 00000000..883ace31 --- /dev/null +++ b/tests/test_sec_quarterly_cash_generation_pilot.py @@ -0,0 +1,305 @@ +from copy import deepcopy + +import pytest + +from src.commercial_source_rights import build_source_rights_registry +from src.sec_quarterly_cash_generation_pilot import ( + extract_sec_quarterly_cash_generation, + preview_sec_quarterly_cash_generation, +) + + +ACCESSION = "0001045810-26-000052" +START = "2026-01-26" +END = "2026-04-26" + + +def _fact(value: float, *, filed: str = "2026-05-20") -> dict[str, object]: + return { + "start": START, + "end": END, + "val": value, + "accn": ACCESSION, + "fy": 2027, + "fp": "Q1", + "form": "10-Q", + "filed": filed, + } + + +def _companyfacts_fixture() -> dict[str, object]: + return { + "cik": 1045810, + "entityName": "NVIDIA CORP", + "facts": { + "us-gaap": { + "RevenueFromContractWithCustomerExcludingAssessedTax": { + "units": {"USD": [_fact(81_615_000_000)]} + }, + "OperatingIncomeLoss": { + "units": {"USD": [_fact(53_536_000_000)]} + }, + "NetCashProvidedByUsedInOperatingActivities": { + "units": {"USD": [_fact(50_344_000_000)]} + }, + "PaymentsToAcquireProductiveAssets": { + "units": {"USD": [_fact(1_757_000_000)]} + }, + } + }, + } + + +def _submissions_fixture() -> dict[str, object]: + return { + "cik": "0001045810", + "filings": { + "recent": { + "accessionNumber": [ACCESSION], + "filingDate": ["2026-05-20"], + "acceptanceDateTime": ["2026-05-20T20:35:52.000Z"], + "form": ["10-Q"], + "primaryDocument": ["nvda-20260426.htm"], + } + }, + } + + +def _filing_fixture() -> str: + return f""" + + + + {START} + {END} + + + + + + + +
Revenue81,615
Operating income53,536
Net cash provided by operating activities50,344
Purchases related to property and equipment and intangible assets(1,757)
+ + """ + + +def _extract( + *, + companyfacts_payload: dict[str, object] | None = None, + submissions_payload: dict[str, object] | None = None, + filing_html: str | None = None, + as_of: str = "2026-07-20T15:00:00+00:00", + fiscal_period: str = "2027-Q1", + period_start_date: str = START, + period_end_date: str = END, +): + return extract_sec_quarterly_cash_generation( + ticker="NVDA", + cik="0001045810", + fiscal_period=fiscal_period, + period_start_date=period_start_date, + period_end_date=period_end_date, + accession=ACCESSION, + companyfacts_payload=companyfacts_payload or _companyfacts_fixture(), + submissions_payload=submissions_payload or _submissions_fixture(), + filing_html=filing_html or _filing_fixture(), + retrieved_at="2026-07-20T15:00:00+00:00", + as_of=as_of, + ) + + +def _rights_registry(*, include_cash_fields: bool): + supported_fields = ["revenue", "filing_dates"] + if include_cash_fields: + supported_fields.extend( + ["operating_income", "cash_from_operations", "capital_expenditures"] + ) + return build_source_rights_registry( + [ + { + "source_id": "sec_companyfacts", + "display_name": "SEC Companyfacts test record", + "permitted_use": "source_backed_company_facts", + "commercial_use": "approved", + "redistribution": "derived_data_only", + "storage_limits": "in_memory_test_only", + "attribution": "SEC EDGAR test fixture", + "rate_limits": "not_applicable_test_only", + "authentication": "SEC_USER_AGENT", + "expected_freshness": "filing_driven", + "supported_fields": supported_fields, + "fallback_priority": 1, + } + ] + ) + + +def test_exact_q1_payload_builds_source_backed_components_and_revenue(): + result = _extract() + + assert result.blockers == () + assert result.accepted_at == "2026-05-20T20:35:52+00:00" + assert [row.metric for row in result.observations] == [ + "operating_income", + "cash_from_operations", + "capital_expenditures", + ] + assert [row.value for row in result.observations] == [ + 53_536_000_000.0, + 50_344_000_000.0, + -1_757_000_000.0, + ] + assert result.revenue_actuals[0].revenue_actual == 81_615_000_000.0 + assert result.capex_sign_evidence == "explicit_filed_table_outflow" + assert result.source_url.endswith("/nvda-20260426.htm") + + +def test_unsigned_capex_is_blocked_instead_of_inferred(): + result = _extract(filing_html=_filing_fixture().replace("(", "").replace(")", "")) + + assert result.blockers == ( + "capital_expenditures:explicit_outflow_evidence_missing", + ) + assert result.observations == () + assert result.revenue_actuals == () + + +def test_wrong_inline_context_is_distinct_from_missing_outflow_presentation(): + filing = _filing_fixture().replace( + 'id="f-capex" name="us-gaap:PaymentsToAcquireProductiveAssets" contextRef="c-q1"', + 'id="f-capex" name="us-gaap:PaymentsToAcquireProductiveAssets" contextRef="c-other"', + ) + + result = _extract(filing_html=filing) + + assert result.blockers == ("capital_expenditures:inline_fact_missing",) + + +def test_conflicting_exact_companyfacts_values_are_ambiguous(): + payload = deepcopy(_companyfacts_fixture()) + rows = payload["facts"]["us-gaap"]["NetCashProvidedByUsedInOperatingActivities"]["units"]["USD"] + rows.append(_fact(50_000_000_000)) + + result = _extract(companyfacts_payload=payload) + + assert result.blockers == ("cash_from_operations:fact_ambiguous",) + + +def test_identical_inline_duplicates_collapse_to_first_document_fact(): + filing = _filing_fixture().replace( + "", + ( + 'Repeated Revenue disclosure81,615' + "" + ), + ) + + result = _extract(filing_html=filing) + + assert result.blockers == () + assert result.revenue_actuals[0].source_ref.endswith("#f-revenue") + + +@pytest.mark.parametrize( + ("acceptance_time", "as_of", "blocker"), + [ + ("", "2026-07-20T15:00:00+00:00", "submissions:acceptance_time_invalid"), + ("2026-05-20T20:35:52", "2026-07-20T15:00:00+00:00", "submissions:acceptance_time_invalid"), + ("2026-05-20T20:35:52.000Z", "2026-05-20T20:35:51+00:00", "acceptance_after_cutoff"), + ], +) +def test_acceptance_timestamp_fails_closed(acceptance_time, as_of, blocker): + submissions = deepcopy(_submissions_fixture()) + submissions["filings"]["recent"]["acceptanceDateTime"] = [acceptance_time] + + result = _extract(submissions_payload=submissions, as_of=as_of) + + assert blocker in result.blockers + assert result.observations == () + + +def test_missing_accession_in_submissions_blocks_all_values(): + submissions = deepcopy(_submissions_fixture()) + submissions["filings"]["recent"]["accessionNumber"] = ["different"] + + result = _extract(submissions_payload=submissions) + + assert result.blockers == ("submissions:accession_missing",) + assert result.observations == () + + +def test_submissions_primary_document_must_match_requested_filing(): + result = extract_sec_quarterly_cash_generation( + ticker="NVDA", + cik="0001045810", + fiscal_period="2027-Q1", + period_start_date=START, + period_end_date=END, + accession=ACCESSION, + primary_document="different-filing.htm", + companyfacts_payload=_companyfacts_fixture(), + submissions_payload=_submissions_fixture(), + filing_html=_filing_fixture(), + retrieved_at="2026-07-20T15:00:00+00:00", + as_of="2026-07-20T15:00:00+00:00", + ) + + assert result.blockers == ("filing:primary_document_mismatch",) + assert result.observations == () + + +def test_payload_cik_must_match_requested_company(): + payload = deepcopy(_companyfacts_fixture()) + payload["cik"] = 1234 + + result = _extract(companyfacts_payload=payload) + + assert result.blockers == ("companyfacts:cik_mismatch",) + + +def test_q4_ytd_facts_cannot_become_a_derived_quarter(): + result = _extract( + fiscal_period="2027-Q4", + period_start_date="2026-01-26", + period_end_date="2027-01-31", + ) + + assert "q4_explicit_three_month_filing_required" in result.blockers + assert result.observations == () + + +def test_preview_composes_existing_acceptance_without_activation(): + preview = preview_sec_quarterly_cash_generation( + extraction=_extract(), + rights_registry=_rights_registry(include_cash_fields=True), + as_of="2026-07-20T15:00:00+00:00", + ) + + assert preview.status == "accepted_for_review" + assert preview.blockers == () + assert preview.acceptance is not None + assert preview.acceptance.accepted_observation_count == 3 + assert preview.production_activation is False + assert preview.readiness_promotions == () + + +def test_extraction_and_rights_blockers_never_activate_preview(): + blocked_extraction = _extract(filing_html="") + extraction_preview = preview_sec_quarterly_cash_generation( + extraction=blocked_extraction, + rights_registry=_rights_registry(include_cash_fields=True), + ) + rights_preview = preview_sec_quarterly_cash_generation( + extraction=_extract(), + rights_registry=_rights_registry(include_cash_fields=False), + ) + + assert extraction_preview.status == "blocked" + assert extraction_preview.acceptance is None + assert rights_preview.status == "blocked" + assert "source_fields_missing:" in " ".join(rights_preview.blockers) + assert rights_preview.production_activation is False diff --git a/tests/test_sec_quarterly_cash_generation_preview.py b/tests/test_sec_quarterly_cash_generation_preview.py new file mode 100644 index 00000000..479e18e2 --- /dev/null +++ b/tests/test_sec_quarterly_cash_generation_preview.py @@ -0,0 +1,234 @@ +from pathlib import Path + +import pytest + +from src.earnings_nowcast_contract import QuarterlyActual +from src.providers.sec_companyfacts import SECUserAgentError +from src.quarterly_cash_generation import QuarterlyBusinessObservation +from src.quarterly_cash_generation_adapter import QuarterlyAdapterAcceptance +from src.sec_quarterly_cash_generation_pilot import ( + SecQuarterlyPilotExtraction, + SecQuarterlyPilotPreview, +) +from src.sec_quarterly_cash_generation_preview import ( + fetch_sec_quarterly_pilot_payloads, + main, + render_sec_quarterly_pilot_preview, +) + + +def _recording_fetcher(seen: list[str]): + def fetch(url: str, _user_agent: str) -> bytes: + seen.append(url) + if "companyfacts" in url: + return b'{"cik": 1045810, "facts": {}}' + if "submissions" in url: + return b'{"cik": "0001045810", "filings": {}}' + return b"filing" + + return fetch + + +def _observation(metric: str, value: float, fact_id: str) -> QuarterlyBusinessObservation: + return QuarterlyBusinessObservation( + ticker="NVDA", + fiscal_period="2027-Q1", + period_end_date="2026-04-26", + metric=metric, + value=value, + currency="USD", + unit_scale=1.0, + accounting_basis="reported", + duration_basis="three_months", + source="sec_companyfacts", + source_ref=( + "https://www.sec.gov/Archives/edgar/data/1045810/" + f"000104581026000052/nvda-20260426.htm#{fact_id}" + ), + published_at="2026-05-20T20:35:52+00:00", + retrieved_at="2026-07-20T15:00:00+00:00", + ) + + +def _accepted_preview() -> SecQuarterlyPilotPreview: + observations = ( + _observation("operating_income", 53_536_000_000.0, "f-operating"), + _observation("cash_from_operations", 50_344_000_000.0, "f-cfo"), + _observation("capital_expenditures", -1_757_000_000.0, "f-capex"), + ) + revenue = QuarterlyActual( + ticker="NVDA", + fiscal_period="2027-Q1", + period_end_date="2026-04-26", + reported_at="2026-05-20T20:35:52+00:00", + revenue_actual=81_615_000_000.0, + eps_actual=None, + source="sec_companyfacts", + source_ref=( + "https://www.sec.gov/Archives/edgar/data/1045810/" + "000104581026000052/nvda-20260426.htm#f-revenue" + ), + retrieved_at="2026-07-20T15:00:00+00:00", + split_adjustment_basis="primary_split_basis_unverified", + ) + extraction = SecQuarterlyPilotExtraction( + ticker="NVDA", + cik="0001045810", + fiscal_period="2027-Q1", + period_start_date="2026-01-26", + period_end_date="2026-04-26", + accession="0001045810-26-000052", + filing_date="2026-05-20", + accepted_at="2026-05-20T20:35:52+00:00", + source_url=( + "https://www.sec.gov/Archives/edgar/data/1045810/" + "000104581026000052/nvda-20260426.htm" + ), + observations=observations, + revenue_actuals=(revenue,), + capex_sign_evidence="explicit_filed_table_outflow", + blockers=(), + ) + acceptance = QuarterlyAdapterAcceptance( + ticker="NVDA", + source_id="sec_companyfacts", + status="accepted_for_review", + blockers=(), + accepted_observation_count=3, + reviewed_metrics=( + "capital_expenditures", + "cash_from_operations", + "operating_income", + ), + derived_point_count=3, + explicit_q4_periods=(), + rights_status="approved", + ) + return SecQuarterlyPilotPreview( + extraction=extraction, + acceptance=acceptance, + status="accepted_for_review", + blockers=(), + ) + + +def test_client_fetches_only_three_exact_sec_endpoints_without_cache(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + seen: list[str] = [] + + payloads = fetch_sec_quarterly_pilot_payloads( + cik="0001045810", + accession="0001045810-26-000052", + primary_document="nvda-20260426.htm", + user_agent="Research Test test@example.com", + fetcher=_recording_fetcher(seen), + ) + + assert seen == [ + "https://data.sec.gov/api/xbrl/companyfacts/CIK0001045810.json", + "https://data.sec.gov/submissions/CIK0001045810.json", + ( + "https://www.sec.gov/Archives/edgar/data/1045810/" + "000104581026000052/nvda-20260426.htm" + ), + ] + assert not list(tmp_path.iterdir()) + assert set(payloads) == {"companyfacts", "submissions", "filing_html"} + + +def test_client_requires_identified_sec_user_agent(monkeypatch): + monkeypatch.delenv("SEC_USER_AGENT", raising=False) + + with pytest.raises(SECUserAgentError): + fetch_sec_quarterly_pilot_payloads( + cik="0001045810", + accession="0001045810-26-000052", + primary_document="nvda-20260426.htm", + ) + + +def test_renderer_is_human_readable_and_keeps_non_activation_visible(): + text = render_sec_quarterly_pilot_preview(_accepted_preview()) + + assert "status: accepted_for_review" in text + assert "NVIDIA Q1 FY2027" in text + assert "capital expenditures: -1757000000.0 USD" in text + assert "capex sign evidence: explicit_filed_table_outflow" in text + assert "production activation: false" in text + assert "readiness promotions: none" in text + assert "generated artifacts: none" in text + assert not text.lstrip().startswith("{") + + +def test_preview_module_and_make_target_expose_no_write_or_apply_surface(): + source = Path("src/sec_quarterly_cash_generation_preview.py").read_text( + encoding="utf-8" + ) + makefile = Path("Makefile").read_text(encoding="utf-8") + + for forbidden in ( + "write_text", + "Path(", + "--output", + "make readiness", + "imports-apply", + "data/cache", + "yfinance", + "fallback", + ): + assert forbidden not in source + target = makefile.split("sec-quarterly-cash-preview:", 1)[1].split("\n\n", 1)[0] + assert "--as-of" in target + assert "--output" not in target + assert "readiness" not in target + + +def test_cli_renders_accepted_preview_and_passes_exact_primary_document( + monkeypatch, capsys +): + captured: dict[str, object] = {} + + def fake_fetch(**kwargs): + captured.update(kwargs) + return {"companyfacts": {}, "submissions": {}, "filing_html": ""} + + monkeypatch.setattr( + "src.sec_quarterly_cash_generation_preview.fetch_sec_quarterly_pilot_payloads", + fake_fetch, + ) + monkeypatch.setattr( + "src.sec_quarterly_cash_generation_preview.extract_sec_quarterly_cash_generation", + lambda **kwargs: (captured.update({"extraction": kwargs}) or _accepted_preview().extraction), + ) + monkeypatch.setattr( + "src.sec_quarterly_cash_generation_preview.preview_sec_quarterly_cash_generation", + lambda **_kwargs: _accepted_preview(), + ) + + exit_code = main( + [ + "--ticker", + "NVDA", + "--cik", + "0001045810", + "--fiscal-period", + "2027-Q1", + "--period-start", + "2026-01-26", + "--period-end", + "2026-04-26", + "--accession", + "0001045810-26-000052", + "--primary-document", + "nvda-20260426.htm", + "--as-of", + "2026-07-20T23:59:59-04:00", + "--sec-user-agent", + "Research Test test@example.com", + ] + ) + + assert exit_code == 0 + assert captured["primary_document"] == "nvda-20260426.htm" + assert captured["extraction"]["primary_document"] == "nvda-20260426.htm" + assert "status: accepted_for_review" in capsys.readouterr().out diff --git a/tests/test_sec_submissions.py b/tests/test_sec_submissions.py index d238ec50..a107cb06 100644 --- a/tests/test_sec_submissions.py +++ b/tests/test_sec_submissions.py @@ -7,9 +7,11 @@ build_sec_filing_share_count_evidence, build_sec_submission_metadata_packet, build_sec_submission_metadata, + extract_filing_exhibits, extract_share_count_from_inline_xbrl, fetch_sec_submission, latest_filing_document, + sec_filing_index_url, sec_filing_document_url, sec_submission_url, ) @@ -40,6 +42,37 @@ def test_sec_submission_url_zero_pads_cik(): assert sec_submission_url("1045810") == SEC_SUBMISSIONS_URL_TEMPLATE.format(cik="0001045810") +def test_sec_filing_index_url_uses_archive_accession_path(): + assert sec_filing_index_url("0001045810", "0001045810-26-000021") == ( + "https://www.sec.gov/Archives/edgar/data/1045810/000104581026000021/0001045810-26-000021-index.html" + ) + + +def test_extract_filing_exhibits_discovers_only_allowlisted_ex_99_links(): + index_html = """ + + + + + + + + +
DocumentDescriptionType
nvda-ex99.htmReleaseEX-99
nvda-ex991.htmEarnings releaseEX-99.1
nvda-ex992.htmSupplementEX-99.2
nvda-ex993.htmOther exhibitEX-99.3
nvda-ex9917.htmOther exhibitEX-99.17
not-an-exhibit.htmPrimary8-K
+ """ + + exhibits = extract_filing_exhibits(index_html, cik="0001045810", accession="0001045810-26-000021") + + assert [(exhibit.document_type, exhibit.document_name) for exhibit in exhibits] == [ + ("EX-99", "nvda-ex99.htm"), + ("EX-99.1", "nvda-ex991.htm"), + ("EX-99.2", "nvda-ex992.htm"), + ] + assert exhibits[1].source_ref == ( + "https://www.sec.gov/Archives/edgar/data/1045810/000104581026000021/nvda-ex991.htm" + ) + + def test_build_sec_submission_metadata_maps_entity_industry_and_latest_filing(): metadata = build_sec_submission_metadata(_sample_submission_payload()) diff --git a/tests/test_session_source_preflight.py b/tests/test_session_source_preflight.py index 25f661e8..502cd31a 100644 --- a/tests/test_session_source_preflight.py +++ b/tests/test_session_source_preflight.py @@ -6,6 +6,8 @@ import pandas as pd +import src.session_source_preflight as session_source_preflight +from src.continuation_gate import ContinuationGate from src.session_source_preflight import ( build_source_actionability, build_session_source_preflight, @@ -19,6 +21,45 @@ ) +def test_session_source_preflight_overlays_stale_routing_without_changing_source_evidence(): + preflight = { + "sources": {"sec": {"status": "available"}}, + "source_activation_console_v2": { + "next_executable_lane": "sec_fundamentals_share_count", + "next_executable_command": "make coverage-frontier TOP_N=10", + "operator_summary": { + "can_run_now": ["sec_fundamentals_share_count"], + "needs_setup": ["fmp"], + "avoid_repeating": ["fundamentals_share_count_source_ladder"], + "next_step": "make coverage-frontier TOP_N=10", + "next_step_reason": "Use the next source lane.", + }, + }, + } + gate = ContinuationGate( + state="inspection_only", + next_safe_command="make readiness-preview TOP_N=20", + reason="Selected-profile source dates are newer than saved readiness.", + rebuild_command="make readiness", + stop_rule="Do not start broad refresh or source-proof work.", + suppress_execution=True, + ) + + overlaid = session_source_preflight.apply_continuation_gate(preflight, gate) + assert preflight["source_activation_console_v2"]["next_executable_command"] == "make coverage-frontier TOP_N=10" + assert overlaid["sources"] == preflight["sources"] + assert overlaid["continuation_gate"]["state"] == "inspection_only" + assert overlaid["source_activation_console_v2"]["next_executable_lane"] == "inspection_only" + assert overlaid["source_activation_console_v2"]["next_executable_command"] == "make readiness-preview TOP_N=20" + assert overlaid["source_activation_console_v2"]["operator_summary"]["needs_setup"] == ["fmp"] + assert overlaid["source_activation_console_v2"]["operator_summary"]["avoid_repeating"] == [ + "fundamentals_share_count_source_ladder", + "broad_refresh", + "source_proof", + "readiness_rebuild", + ] + + def _write_fundamentals(root: Path, rows: list[dict[str, object]]) -> None: data_dir = root / "data" data_dir.mkdir(parents=True, exist_ok=True) @@ -377,6 +418,12 @@ def test_session_source_preflight_reports_ibkr_read_only_price_provider(tmp_path "detail": "dns failed", "next_action": "Do not retry SEC-backed fundamentals in this session.", }, + sec_submissions_probe=lambda _user_agent: { + "status": "available", + "reason_code": "ok", + "detail": "Deterministic SEC submissions fixture.", + "next_action": "", + }, yfinance_import_probe=lambda: { "status": "unavailable", "reason_code": "missing_dependency", diff --git a/tests/test_source_activation_guide.py b/tests/test_source_activation_guide.py index 19acf324..f2385590 100644 --- a/tests/test_source_activation_guide.py +++ b/tests/test_source_activation_guide.py @@ -1,5 +1,7 @@ import json +from types import SimpleNamespace +import src.source_activation_guide as source_activation_guide from src.source_activation_guide import ( build_provider_setup_checklist, build_source_activation_guide, @@ -8,6 +10,44 @@ ) +def test_provider_setup_checklist_routes_stale_readiness_to_inspection_only(monkeypatch, tmp_path): + monkeypatch.setattr( + source_activation_guide, + "build_profile_context", + lambda **_: SimpleNamespace( + freshness_state="stale", + freshness_message="Selected-profile source dates are newer than saved readiness.", + refresh_command="make readiness", + ), + ) + current_preflight = { + "source_activation_console_v2": { + "operator_summary": { + "can_run_now": ["sec_fundamentals_share_count"], + "needs_setup": ["fmp"], + "avoid_repeating": ["fundamentals_share_count_source_ladder"], + "next_step": "make coverage-frontier TOP_N=10", + "next_step_reason": "Review ranked source work.", + } + } + } + + checklist = build_provider_setup_checklist(current_preflight, root=tmp_path) + rendered = render_provider_setup_checklist(checklist) + + assert checklist["current_gate"]["can_run_now"] == "inspection_only" + assert checklist["current_gate"]["needs_setup"] == "fmp" + assert checklist["current_gate"]["avoid_repeating"] == ( + "fundamentals_share_count_source_ladder, broad_refresh, source_proof, readiness_rebuild" + ) + assert checklist["current_gate"]["next_step"] == "make readiness-preview TOP_N=20" + assert checklist["continuation_gate"]["state"] == "inspection_only" + assert "Stale readiness continuation gate: inspection_only" in rendered + assert "Planning context only" in rendered + assert "next_step: make readiness-preview TOP_N=20" in rendered + assert "next_step: make coverage-frontier TOP_N=10" not in rendered + + def test_source_activation_guide_lists_public_sources_without_secrets(monkeypatch): monkeypatch.setenv("FMP_API_KEY", "secret-fmp-key") monkeypatch.setenv("ALPHA_VANTAGE_API_KEY", "") diff --git a/tests/test_trusted_peer_pilot_source_template.py b/tests/test_trusted_peer_pilot_source_template.py index 2380cee2..88c616b9 100644 --- a/tests/test_trusted_peer_pilot_source_template.py +++ b/tests/test_trusted_peer_pilot_source_template.py @@ -20,6 +20,9 @@ def test_trusted_peer_pilot_template_keeps_source_review_separate_from_imports() "source_accessed_date", "as_of_date", "relationship_rationale", + "peer_role", + "comparability_basis", + "valuation_anchor_eligible", "source_evidence_note", "reviewer", "review_date", @@ -32,6 +35,8 @@ def test_trusted_peer_pilot_template_keeps_source_review_separate_from_imports() assert "candidate_context_only" in body assert "do not import until source proof is reviewed" in body assert "copy only import-schema fields into the guard" in body + assert "core_peer / secondary_peer" in body + assert "yes / no" in body def test_peer_pilot_docs_route_template_through_writeback_guard(): @@ -47,7 +52,8 @@ def test_peer_pilot_docs_route_template_through_writeback_guard(): assert "guessed peers or file row counts do not become valuation" in readme assert "candidate context stays out of trusted proof" in readme assert "cp docs/TRUSTED_PEER_PILOT_SOURCE_TEMPLATE.csv /tmp/stock-command-center-trusted-peer-pilot.csv" in runbook - assert "only source-backed relationships that pass `peer-mapping-writeback-guard`" in runbook + assert "only source-backed relationships with a reviewed peer role" in runbook + assert "`valuation_anchor_eligible=yes` may enter peer medians" in runbook assert "source_evidence_note" in runbook - assert "review-only fields, not import columns" in runbook - assert "do not bypass it by pasting the full review sheet into the import file" in runbook + assert "remain review-only" in runbook + assert "Do not bypass the guard by pasting the full review sheet into the import file" in runbook diff --git a/tests/test_universe_model.py b/tests/test_universe_model.py index b70fb202..a962d1e9 100644 --- a/tests/test_universe_model.py +++ b/tests/test_universe_model.py @@ -2,7 +2,44 @@ import pandas as pd -from src.universe_model import ensure_universe_files, infer_asset_type, refresh_universe +from src.universe_model import build_universe_coverage_report, ensure_universe_files, infer_asset_type, refresh_universe + + +def _file_manifest(root: Path) -> dict[str, bytes]: + return { + path.relative_to(root).as_posix(): path.read_bytes() + for path in root.rglob("*") + if path.is_file() + } + + +def test_ensure_universe_files_no_write_builds_frames_without_creating_canonical_files(tmp_path: Path): + data_dir = tmp_path / "data" + data_dir.mkdir() + pd.DataFrame( + [{"Ticker": "NVDA", "CompanyName": "NVIDIA", "DefaultPurpose": "Core Compounder"}] + ).to_csv(data_dir / "universe.csv", index=False) + before = _file_manifest(tmp_path) + + master, active = ensure_universe_files(tmp_path, write_outputs=False) + + assert set(master["ticker"]) == {"NVDA"} + assert set(active["ticker"]) == {"NVDA"} + assert _file_manifest(tmp_path) == before + + +def test_universe_coverage_no_write_creates_no_report_or_directory(tmp_path: Path): + data_dir = tmp_path / "data" + data_dir.mkdir() + pd.DataFrame( + [{"Ticker": "NVDA", "CompanyName": "NVIDIA", "DefaultPurpose": "Core Compounder"}] + ).to_csv(data_dir / "universe.csv", index=False) + before = _file_manifest(tmp_path) + + report = build_universe_coverage_report(tmp_path, write_output=False) + + assert set(report["ticker"]) == {"NVDA"} + assert _file_manifest(tmp_path) == before def test_ensure_universe_files_preserves_legacy_active_universe(tmp_path: Path): diff --git a/tests/test_weekly_research_summary.py b/tests/test_weekly_research_summary.py new file mode 100644 index 00000000..d93450c9 --- /dev/null +++ b/tests/test_weekly_research_summary.py @@ -0,0 +1,147 @@ +from types import SimpleNamespace + +from src.focused_research_cohort import FocusedCohort, FocusedCohortMember +from src.weekly_research_summary import build_weekly_research_summary, weekly_summary_rows + + +def _member(ticker: str) -> FocusedCohortMember: + return FocusedCohortMember( + ticker=ticker, + company_name=f"{ticker} Company", + sector="Technology", + industry="Software", + cohort_rationale="Operating company with source-backed evidence.", + usable_lanes=("price",), + blocked_lanes=("dcf",), + freshness_state="current", + last_review_date="", + next_review_reason="Review source evidence for dcf.", + ) + + +def _cohort(*tickers: str) -> FocusedCohort: + members = tuple(_member(ticker) for ticker in tickers) + return FocusedCohort("ready", len(members), 1, len(members), members, "ready") + + +def _item( + event_id: str, + ticker: str, + *, + subtype: str = "sec_filing_arrived", + family: str = "filing", + prior: str = "old", + current: str = "new", + status: str = "open", +): + event = SimpleNamespace( + event_id=event_id, + ticker=ticker, + family=family, + subtype=subtype, + prior_value=prior, + current_value=current, + evidence_status="source_backed", + source_ref=f"source:{event_id}", + source_published_at="2026-07-16T12:00:00+00:00", + detected_at="2026-07-17T12:00:00+00:00", + suggested_research_task=f"{ticker}: Review source-backed evidence.", + ) + return SimpleNamespace( + event=event, + review_status=status, + wait_condition="Wait for changed source evidence." if status == "still_blocked" else "", + ) + + +def test_weekly_summary_groups_traceable_cohort_events_without_duplicate_or_recommendation_language(): + item = _item("evt-1", "AAA") + summary = build_weekly_research_summary( + _cohort("AAA", "BBB"), + [item, item, _item("outside", "OUT")], + as_of="2026-07-17T23:59:59+00:00", + ) + + assert summary.status == "review_required" + assert summary.unique_event_count == 1 + assert [entry.ticker for entry in summary.items] == ["AAA", "AAA"] + assert {entry.category for entry in summary.items} == {"new_evidence", "requires_review"} + assert all(entry.source_ref == "source:evt-1" for entry in summary.items) + rendered = weekly_summary_rows(summary).__str__().lower() + for prohibited in ("buy", "sell", "top pick", "winner", "recommendation score"): + assert prohibited not in rendered + + +def test_weekly_summary_classifies_newly_usable_newly_blocked_and_waiting_states(): + summary = build_weekly_research_summary( + _cohort("AAA", "BBB"), + [ + _item("usable", "AAA", subtype="dcf_readiness_changed", family="readiness", prior="false", current="true"), + _item("blocked", "BBB", subtype="peer_readiness_changed", family="readiness", prior="true", current="false", status="still_blocked"), + ], + as_of="2026-07-17T23:59:59+00:00", + ) + + categories = {(item.ticker, item.category) for item in summary.items} + assert ("AAA", "newly_usable") in categories + assert ("BBB", "newly_blocked") in categories + assert ("BBB", "waiting") in categories + waiting = next(item for item in summary.items if item.category == "waiting") + assert waiting.answer == "Wait for changed source evidence." + + +def test_weekly_summary_uses_only_reviewer_authored_stale_and_invalidation_rows(): + summary = build_weekly_research_summary( + _cohort("AAA"), + [], + journal_rows=[ + { + "ticker": "AAA", + "review_due_date": "2026-07-01", + "invalidation_triggered": True, + "invalidation_condition": "Source-backed margin condition requires review.", + "source_ref": "journal:aaa:3", + }, + { + "ticker": "OUT", + "review_due_date": "2026-07-01", + "invalidation_triggered": True, + "invalidation_condition": "Outside cohort.", + "source_ref": "journal:out:1", + }, + ], + as_of="2026-07-17T23:59:59+00:00", + ) + + assert {(item.category, item.ticker) for item in summary.items} == { + ("stale_review", "AAA"), + ("invalidation_condition", "AAA"), + } + assert all(item.source_ref == "journal:aaa:3" for item in summary.items) + + +def test_weekly_summary_empty_week_is_monitoring_not_fabricated_narrative(): + summary = build_weekly_research_summary( + _cohort("AAA"), + [], + as_of="2026-07-17T23:59:59+00:00", + ) + + assert summary.status == "no_changes" + assert summary.items == () + assert summary.message == "No traceable cohort evidence change requires review this week." + assert weekly_summary_rows(summary) == [] + + +def test_weekly_summary_excludes_events_outside_seven_day_window(): + old = _item("old", "AAA") + old.event.detected_at = "2026-07-01T12:00:00+00:00" + + summary = build_weekly_research_summary( + _cohort("AAA"), + [old], + as_of="2026-07-17T23:59:59+00:00", + ) + + assert summary.status == "no_changes" + assert summary.unique_event_count == 0