diff --git a/Makefile b/Makefile index 6eaa9c50..9a5c3208 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help help-full next-stage profile-context research-change-snapshot research-change-monitor research-review-queue research-event-review-record demo demo-data-build demo-data-check demo-dashboard demo-dashboard-smoke demo-dashboard-render-smoke local-profile-seed linkedin-share-check public-performance-contract public-performance-gate public-ux-review-checklist public-ux-review-checklist-json public-ux-review-notes public-ux-review-notes-check public-ux-review-note pilot-review-feedback pilot-feedback-closeout browser-qa-evidence browser-qa-capture-plan hosted-demo-readiness pilot-readiness-check pilot-readiness-packet pilot-share-brief trusted-data-pilot trusted-data-pilot-candidates trusted-data-pilot-packet trusted-data-pilot-lane trusted-data-pilot-board trusted-data-pilot-evidence reviewed-data-proof reviewed-data-proof-record reviewed-batch-proof reviewed-batch-proof-record reviewed-batch-compare reviewed-batch-preflight proof-readiness-reconciliation auto-refresh-plan auto-refresh-daily auto-refresh-weekly auto-refresh-optional auto-refresh-runbook auto-refresh-status scheduler-activation-checklist auto-apply-gate lane-outcome-history price-reviewed-run fundamentals-batch-proof peer-batch-proof peer-mapping-source-review peer-mapping-writeback-guard public-demo-readiness-pack readiness-ops-center readiness-queue data-coverage-proof-queues coverage-frontier data-coverage-planner coverage-expansion-loop readiness-ops-evidence reviewed-batch decision-proof-queue metric-readiness metric-readiness-board benchmark-risk-review diff-hygiene diff-hygiene-summary diff-hygiene-files data-release-decision public-release-package public-release-handoff license-status session-source-preflight source-activation-guide provider-setup-checklist provider-smoke fmp-smoke alpha-vantage-smoke finnhub-smoke universe-scope fundamentals-source-ladder fundamentals-source-ladder-queue optional-context-source-ladder optional-context-source-ladder-queue fmp-stage alpha-vantage-stage finnhub-stage yfinance-stage sec-filing-share-stage staged-hygiene-check public-wording-check public-check status status-check test pipeline stock-report stock-report-md local-tickers monthly track-record validate-data data-sources-check data-sources research-health research-health-check risk-context action-queue action-queue-check project-status project-status-check verify validate-all daily dashboard dashboard-smoke sec-stage sec-validate sec-preview sec-apply imports-validate imports-preview imports-apply import-staging universe-preview universe-preview-summary universe-stage universe-apply universe-refresh universe-report universe-active coverage data-wizard unlock-ladder unlock-summary command-bundles command-bundle-details command-bundle-runbook bundle-prices bundle-fundamentals bundle-peers bundle-prices-broader bundle-fundamentals-broader bundle-peers-broader detail-prices detail-fundamentals detail-peers detail-prices-broader detail-fundamentals-broader detail-peers-broader runbook-prices runbook-fundamentals runbook-peers runbook-prices-broader runbook-fundamentals-broader runbook-peers-broader focus-price focus-fundamentals focus-peers onboarding templates price-status price-worklist fundamentals-peer-worklist optional-context-worklist sec-stage-queue peer-mapping-queue dcf-input-proof-queue dcf-input-proof-handoff dcf-input-source-review dcf-input-source-command-plan dcf-input-source-guard share-count-proof-queue price-history-proof-queue price-validate price-preview price-apply price-refresh price-refresh-loop price-normalize import-prices price-coverage dcf-readiness import-fundamentals optional-context-summary optional-context-readiness import-earnings import-analyst-estimates readiness readiness-preview readiness-evidence-remediation readiness-release-review readiness-release-record readiness-release-guard readiness-materialize readiness-snapshot research-decisions earnings-nowcast-pilot earnings-nowcast-walkthrough earnings-nowcast-templates earnings-nowcast-validate earnings-nowcast-preview earnings-nowcast-readiness earnings-nowcast-prospective-plan +.PHONY: help help-full next-stage profile-context research-change-snapshot research-change-monitor research-review-queue research-event-review-record demo demo-data-build demo-data-check demo-dashboard demo-dashboard-smoke demo-dashboard-render-smoke local-profile-seed linkedin-share-check public-performance-contract public-performance-gate public-ux-review-checklist public-ux-review-checklist-json public-ux-review-notes public-ux-review-notes-check public-ux-review-note pilot-review-feedback pilot-feedback-closeout browser-qa-evidence browser-qa-capture-plan hosted-demo-readiness pilot-readiness-check pilot-readiness-packet pilot-share-brief trusted-data-pilot trusted-data-pilot-candidates trusted-data-pilot-packet trusted-data-pilot-lane trusted-data-pilot-board trusted-data-pilot-evidence reviewed-data-proof reviewed-data-proof-record reviewed-batch-proof reviewed-batch-proof-record reviewed-batch-compare reviewed-batch-preflight proof-readiness-reconciliation auto-refresh-plan auto-refresh-daily auto-refresh-weekly auto-refresh-optional auto-refresh-runbook auto-refresh-status scheduler-activation-checklist auto-apply-gate lane-outcome-history price-reviewed-run fundamentals-batch-proof peer-batch-proof peer-mapping-source-review peer-mapping-writeback-guard public-demo-readiness-pack readiness-ops-center readiness-queue data-coverage-proof-queues coverage-frontier data-coverage-planner coverage-expansion-loop readiness-ops-evidence reviewed-batch decision-proof-queue metric-readiness metric-readiness-board benchmark-risk-review diff-hygiene diff-hygiene-summary diff-hygiene-files data-release-decision public-release-package public-release-handoff license-status session-source-preflight source-activation-guide provider-setup-checklist provider-smoke fmp-smoke alpha-vantage-smoke finnhub-smoke universe-scope fundamentals-source-ladder fundamentals-source-ladder-queue optional-context-source-ladder optional-context-source-ladder-queue fmp-stage alpha-vantage-stage finnhub-stage yfinance-stage sec-filing-share-stage staged-hygiene-check public-wording-check public-check status status-check test pipeline stock-report stock-report-md local-tickers monthly track-record validate-data data-sources-check data-sources research-health research-health-check risk-context action-queue action-queue-check project-status project-status-check verify validate-all daily dashboard dashboard-smoke sec-stage sec-validate sec-preview sec-apply imports-validate imports-preview imports-apply import-staging universe-preview universe-preview-summary universe-stage universe-apply universe-refresh universe-report universe-active coverage data-wizard unlock-ladder unlock-summary command-bundles command-bundle-details command-bundle-runbook bundle-prices bundle-fundamentals bundle-peers bundle-prices-broader bundle-fundamentals-broader bundle-peers-broader detail-prices detail-fundamentals detail-peers detail-prices-broader detail-fundamentals-broader detail-peers-broader runbook-prices runbook-fundamentals runbook-peers runbook-prices-broader runbook-fundamentals-broader runbook-peers-broader focus-price focus-fundamentals focus-peers onboarding templates price-status price-worklist fundamentals-peer-worklist optional-context-worklist sec-stage-queue peer-mapping-queue dcf-input-proof-queue dcf-input-proof-handoff dcf-input-source-review dcf-input-source-command-plan dcf-input-source-guard share-count-proof-queue price-history-proof-queue price-validate price-preview price-apply price-refresh price-refresh-loop price-normalize import-prices price-coverage dcf-readiness import-fundamentals optional-context-summary optional-context-readiness import-earnings import-analyst-estimates readiness readiness-preview readiness-evidence-remediation golden-evidence-cohort readiness-release-review readiness-release-record readiness-release-guard readiness-materialize readiness-snapshot research-decisions earnings-nowcast-pilot earnings-nowcast-walkthrough earnings-nowcast-templates earnings-nowcast-validate earnings-nowcast-preview earnings-nowcast-readiness earnings-nowcast-prospective-plan .PHONY: thesis-journal thesis-journal-preview thesis-journal-record .PHONY: prospective-field-proof-status prospective-field-proof-audit prospective-field-proof-preview prospective-field-proof-record @@ -332,6 +332,7 @@ help-full: @echo "Advanced readiness boundaries:" @echo " make readiness-preview [TOP_N=20] In-memory preview: stable readiness impact, change causes, and promotion evidence without writing files" @echo " make readiness-evidence-remediation [TOP_N=20] [JSON=1] Closest-first inspection-only evidence queue; stdout only" + @echo " make golden-evidence-cohort [TOP_N=5] [JSON=1] Five-role inspection-only evidence packet; stdout only" @echo " make readiness-release-review [TOP_N=20] [JSON=1] Review the exact default readiness candidate without writing" @echo " make readiness-release-record PREVIEW_RECEIPT= REVIEWER= REVIEW_DATE= TECHNICAL_DECISION= DISTRIBUTION_DECISION= CONFIRM_REVIEWED=1 Record one exact named review" @echo " make readiness-release-guard RECORD_ID= Revalidate one record and print exact named staging paths" @@ -1486,6 +1487,9 @@ readiness-preview: readiness-evidence-remediation: @PYTHONDONTWRITEBYTECODE=1 python3 -m src.readiness_evidence_remediation --top-n $(or $(TOP_N),20) $(if $(JSON),--json,) +golden-evidence-cohort: + @PYTHONDONTWRITEBYTECODE=1 python3 -m src.golden_evidence_cohort --top-n $(or $(TOP_N),5) $(if $(JSON),--json,) + readiness-release-review: @PYTHONDONTWRITEBYTECODE=1 python3 -m src.readiness_release_review review --project-root . --top-n $(or $(TOP_N),20) $(if $(JSON),--json,) diff --git a/src/golden_evidence_cohort.py b/src/golden_evidence_cohort.py new file mode 100644 index 00000000..3e8f28df --- /dev/null +++ b/src/golden_evidence_cohort.py @@ -0,0 +1,778 @@ +"""Deterministic, inspection-only Golden Evidence Cohort packet.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import math +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Mapping + +import pandas as pd + +from src.commercial_source_rights import ( + SourceRights, + load_source_rights_registry, + review_commercial_field_scope, +) +from src.company_analysis_scope import company_dcf_exclusion_reasons +from src.dcf_price_lineage import DcfPriceLineageEvidence, review_dcf_price_lineage +from src.focused_research_cohort import build_focused_cohort +from src.paths import resolve_data_dir, resolve_project_root +from src.readiness_evidence_remediation import ( + INDEPENDENT_BLOCKER_ORDER, + ReadinessEvidenceRemediation, + ReadinessRemediationCandidate, + build_remediation_from_preview, +) +from src.readiness_preview import ( + STABLE_READINESS_FIELDS, + ReadinessImpactPreview, + build_readiness_impact_preview, +) + + +OPERATING_ASSET_TYPES = {"company", "adr"} +METHOD_FIT_ASSET_TYPES = {"etf", "index", "index_proxy", "fund"} +FUNDAMENTAL_SCOPE_FIELDS = ( + "revenue", + "free_cash_flow", + "fcf_margin", + "shares_outstanding", + "filing_dates", +) +RESEARCH_ONLY_BOUNDARY = "Research workflow evidence only; no security action guidance." + + +@dataclass(frozen=True) +class GoldenEvidenceMember: + ticker: str + asset_type: str + cohort_role: str + selection_reason: str + state: str + saved_readiness_identity: str + proposed_readiness_identity: str + usable_evidence_lanes: tuple[str, ...] + withheld_evidence_lanes: tuple[str, ...] + source_identifiers: tuple[str, ...] + source_rights_states: tuple[str, ...] + missing_registered_fields: tuple[str, ...] + provenance_omissions: tuple[str, ...] + temporal_evidence_omissions: tuple[str, ...] + price_lineage_omissions: tuple[str, ...] + method_fit_exclusions: tuple[str, ...] + independent_blockers: tuple[str, ...] + owner_decision_required: bool + next_evidence_review_action: str + saved_research_loop_status: str + research_only_boundary: str + + +@dataclass(frozen=True) +class GoldenEvidenceCohort: + status: str + saved_snapshot_identity: str + proposed_snapshot_identity: str + members: tuple[GoldenEvidenceMember, ...] + top_n: int + inspection_only: bool = True + canonical_apply_authorized: bool = False + readiness_materialization_authorized: bool = False + source_rights_change_authorized: bool = False + recommendation_authorized: bool = False + repository_writes: tuple[str, ...] = () + research_only_boundary: str = RESEARCH_ONLY_BOUNDARY + + +@dataclass(frozen=True) +class _FundamentalEvidence: + usable_lanes: tuple[str, ...] + source_identifiers: tuple[str, ...] + source_rights_states: tuple[str, ...] + source_statuses: tuple[tuple[str, str], ...] + missing_supported_fields: tuple[str, ...] + provenance_omissions: tuple[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 _ticker(value: object) -> str: + return _text(value).upper() + + +def _feature_set(value: object) -> set[str]: + return {part.strip().lower() for part in _text(value).split(",") if part.strip()} + + +def _is_finite_number(value: object) -> bool: + if isinstance(value, bool): + return False + try: + parsed = float(value) + except (TypeError, ValueError): + return False + return math.isfinite(parsed) + + +def _is_valid_date(value: object) -> bool: + text = _text(value) + if not text: + return False + return bool(pd.notna(pd.to_datetime(text, errors="coerce"))) + + +def _normalized(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) + return result.loc[result["ticker"].ne("")].copy() + + +def _row_index(frame: pd.DataFrame | None) -> dict[str, pd.Series]: + normalized = _normalized(frame) + if normalized.empty: + return {} + return { + ticker: group.iloc[-1] + for ticker, group in normalized.groupby("ticker", sort=False) + } + + +def _rows_by_ticker(frame: pd.DataFrame | None) -> dict[str, tuple[pd.Series, ...]]: + normalized = _normalized(frame) + if normalized.empty: + return {} + return { + ticker: tuple(group.iloc[index] for index in range(len(group))) + for ticker, group in normalized.groupby("ticker", sort=False) + } + + +def _readiness_identity(row: pd.Series | None) -> str: + if row is None: + return "unavailable" + payload = { + field: _text(row.get(field)) if field not in {"price_ready", "momentum_ready", "fundamentals_ready", "dcf_ready", "peer_ready", "earnings_ready", "analyst_estimates_ready"} else _truthy(row.get(field)) + for field in ("ticker", *STABLE_READINESS_FIELDS) + } + encoded = json.dumps(payload, ensure_ascii=False, separators=(",", ":"), sort_keys=True).encode("utf-8") + return f"sha256:{hashlib.sha256(encoded).hexdigest()}" + + +def _ordered_unique(values: list[str] | tuple[str, ...]) -> tuple[str, ...]: + return tuple(dict.fromkeys(value for value in values if value)) + + +def _asset_type(ticker: str, saved: Mapping[str, pd.Series], proposed: Mapping[str, pd.Series], universe: Mapping[str, pd.Series]) -> str: + for rows in (saved, proposed, universe): + value = _text(rows.get(ticker, pd.Series(dtype=object)).get("asset_type")).lower() + if value: + return value + return "unknown" + + +def _metadata(ticker: str, saved: Mapping[str, pd.Series], proposed: Mapping[str, pd.Series], universe: Mapping[str, pd.Series]) -> dict[str, object]: + values: dict[str, object] = {} + for column in ("name", "security_type", "industry"): + for rows in (saved, proposed, universe): + value = _text(rows.get(ticker, pd.Series(dtype=object)).get(column)) + if value: + values[column] = value + break + return values + + +def _price_evidence( + ticker: str, + saved_row: pd.Series | None, + prices: pd.DataFrame, + *, + rights_registry: Mapping[str, SourceRights], + review_cutoff: str | None, +) -> DcfPriceLineageEvidence: + """Reuse the existing DCF price-lineage reviewer without changing readiness.""" + + baseline = pd.DataFrame([dict(saved_row) if saved_row is not None else {"ticker": ticker, "dcf_ready": False}]) + baseline["ticker"] = ticker + baseline["dcf_ready"] = False + proposed = baseline.copy() + proposed["dcf_ready"] = True + review = review_dcf_price_lineage( + baseline, + proposed, + prices, + rights_registry=rights_registry, + review_cutoff=review_cutoff, + top_n=1, + ) + return review.evidence_rows[0] + + +def _fundamental_evidence( + ticker: str, + rows_by_ticker: Mapping[str, tuple[pd.Series, ...]], + rights_registry: Mapping[str, SourceRights], +) -> _FundamentalEvidence: + """Return fail-closed fundamental evidence without selecting ambiguous rows.""" + + rows = rows_by_ticker.get(ticker, ()) + if not rows: + return _FundamentalEvidence( + usable_lanes=(), + source_identifiers=(), + source_rights_states=(), + source_statuses=(), + missing_supported_fields=FUNDAMENTAL_SCOPE_FIELDS, + provenance_omissions=("fundamentals_row",), + ) + if len(rows) != 1: + scoped_sources = tuple( + ( + _text(row.get("source")) or "", + review_commercial_field_scope( + rights_registry, + _text(row.get("source")), + FUNDAMENTAL_SCOPE_FIELDS, + ), + ) + for row in rows + ) + source_statuses = tuple( + dict.fromkeys( + (source, scope.rights_status) for source, scope in scoped_sources + ) + ) + missing_supported_fields = tuple( + field + for field in FUNDAMENTAL_SCOPE_FIELDS + if any( + field in scope.missing_supported_fields + for _, scope in scoped_sources + ) + ) + return _FundamentalEvidence( + usable_lanes=(), + source_identifiers=tuple(source for source, _ in source_statuses), + source_rights_states=tuple( + f"{source}:{status}" for source, status in source_statuses + ), + source_statuses=source_statuses, + missing_supported_fields=missing_supported_fields, + provenance_omissions=("fundamentals_row",), + ) + row = rows[0] + source_id = _text(row.get("source")) + exact_source_id = source_id or "" + source_ref = _text(row.get("source_ref")) or _text(row.get("sec_accession")) + as_of_date = _text(row.get("as_of_date")) + provenance = tuple( + field + for field, value in (("source", source_id), ("source_reference", source_ref), ("as_of_date", as_of_date)) + if not value + ) + scope = review_commercial_field_scope(rights_registry, source_id, FUNDAMENTAL_SCOPE_FIELDS) + usable: list[str] = [] + if not provenance and scope.commercial_rights_approved: + if "revenue" not in scope.missing_supported_fields and _is_finite_number(row.get("revenue")): + usable.append("revenue") + if ( + "shares_outstanding" not in scope.missing_supported_fields + and _is_finite_number(row.get("shares_outstanding")) + ): + usable.append("shares_outstanding") + if ( + "free_cash_flow" not in scope.missing_supported_fields + and _is_finite_number(row.get("free_cash_flow")) + ): + usable.append("free_cash_flow") + if "fcf_margin" not in scope.missing_supported_fields and _is_finite_number(row.get("fcf_margin")): + usable.append("fcf_margin") + if ( + "filing_dates" not in scope.missing_supported_fields + and (_is_valid_date(row.get("sec_filed_date")) or _is_valid_date(row.get("filed_date"))) + ): + usable.append("filing_date") + return _FundamentalEvidence( + usable_lanes=tuple(usable), + source_identifiers=(exact_source_id,), + source_rights_states=(f"{exact_source_id}:{scope.rights_status}",), + source_statuses=((exact_source_id, scope.rights_status),), + missing_supported_fields=scope.missing_supported_fields, + provenance_omissions=provenance, + ) + + +def _candidate_groups(packet: ReadinessEvidenceRemediation) -> dict[str, tuple[ReadinessRemediationCandidate, ...]]: + groups: dict[str, list[ReadinessRemediationCandidate]] = {} + for candidate in packet.candidates: + groups.setdefault(candidate.ticker, []).append(candidate) + return {ticker: tuple(items) for ticker, items in groups.items()} + + +def _merged_candidate_blockers(candidates: tuple[ReadinessRemediationCandidate, ...]) -> tuple[str, ...]: + seen = {blocker for candidate in candidates for blocker in candidate.independent_blockers} + return tuple(blocker for blocker in INDEPENDENT_BLOCKER_ORDER if blocker in seen) + + +def _state_from_blockers(blockers: tuple[str, ...]) -> str: + mapping = { + "provenance": "withheld_provenance", + "price_lineage": "withheld_price_lineage", + "temporal_evidence": "withheld_temporal_evidence", + "exact_source_rights": "withheld_exact_source_rights", + "registered_field_scope": "withheld_registered_field_scope", + } + return next((mapping[blocker] for blocker in blockers if blocker in mapping), "insufficient_evidence") + + +def _withheld_lanes( + usable: tuple[str, ...], + *, + method_fit: tuple[str, ...], +) -> tuple[str, ...]: + lanes = [ + lane + for lane in ( + "revenue", + "price_lineage", + "free_cash_flow", + "fcf_margin", + "shares_outstanding", + "filing_date", + "dcf", + "peers", + "earnings_dates", + "point_in_time_consensus", + ) + if lane not in usable + ] + if method_fit: + lanes = [lane for lane in lanes if lane != "dcf"] + return tuple(lanes) + + +def _member( + *, + ticker: str, + asset_type: str, + role: str, + reason: str, + saved_row: pd.Series | None, + proposed_row: pd.Series | None, + fundamentals: Mapping[str, tuple[pd.Series, ...]], + prices: pd.DataFrame, + rights_registry: Mapping[str, SourceRights], + review_cutoff: str | None, + candidate_rows: tuple[ReadinessRemediationCandidate, ...] = (), + method_fit: tuple[str, ...] = (), +) -> GoldenEvidenceMember: + fundamental = _fundamental_evidence(ticker, fundamentals, rights_registry) + usable = fundamental.usable_lanes + sources = fundamental.source_identifiers + rights = fundamental.source_rights_states + missing_scope = fundamental.missing_supported_fields + provenance = fundamental.provenance_omissions + price = _price_evidence( + ticker, + saved_row, + prices, + rights_registry=rights_registry, + review_cutoff=review_cutoff, + ) + price_blockers = tuple(price.blockers) + price_lineage = _ordered_unique(list(price.missing_provenance_fields) + [blocker for blocker in price_blockers if blocker in {"missing_latest_price_row", "ambiguous_latest_price_row"}]) + temporal = tuple( + blocker + for blocker in price_blockers + if blocker in { + "invalid_observation_date", + "missing_retrieved_at", + "invalid_retrieved_at", + "retrieved_at_timezone_required", + "review_cutoff_required", + "invalid_review_cutoff", + "review_cutoff_timezone_required", + "retrieved_before_observation_available", + "retrieved_after_review_cutoff", + } + ) + blockers = list(_merged_candidate_blockers(candidate_rows)) + unresolved_sources = [ + source for source, status in fundamental.source_statuses if status != "approved" + ] + if unresolved_sources: + blockers.append("exact_source_rights") + if price_lineage: + blockers.append("price_lineage") + if temporal: + blockers.append("temporal_evidence") + if price.rights_status != "approved": + blockers.append("exact_source_rights") + unresolved_sources.append(price.source_id) + if price.missing_supported_fields: + blockers.append("registered_field_scope") + if missing_scope: + blockers.append("registered_field_scope") + if provenance: + blockers.append("provenance") + blockers = tuple(blocker for blocker in INDEPENDENT_BLOCKER_ORDER if blocker in set(blockers)) + if ( + price.latest_row_count == 1 + and not price.missing_provenance_fields + and price.temporal_status == "temporal_complete" + and price.rights_status == "approved" + and not price.missing_supported_fields + ): + usable = _ordered_unique(list(usable) + ["price_lineage"]) + if ( + saved_row is not None + and _truthy(saved_row.get("dcf_ready")) + and not method_fit + and {"price_lineage", "revenue", "free_cash_flow", "fcf_margin", "shares_outstanding"} + <= set(usable) + ): + usable = _ordered_unique(list(usable) + ["dcf"]) + price_rights = (f"{price.source_id}:{price.rights_status}",) + sources = _ordered_unique(list(sources) + [price.source_id]) + rights = _ordered_unique(list(rights) + list(price_rights)) + withheld = _withheld_lanes(usable, method_fit=method_fit) + if method_fit: + state = "method_fit_excluded" + elif blockers and blockers[0] in {"provenance", "exact_source_rights"}: + state = _state_from_blockers(blockers) + elif role == "saved_operating_company" and usable: + state = "reviewable_saved_evidence" + else: + state = _state_from_blockers(blockers) + if role == "method_fit_exclusion": + next_action = "Use a method appropriate to the saved fund or index evidence; keep company DCF excluded." + elif blockers and blockers[0] == "price_lineage": + omissions = ", ".join(price_lineage) or "one unambiguous latest-price row" + next_action = ( + f"Review exact latest-price lineage for {ticker}: {omissions}; " + "keep source or provider identity uninferred." + ) + elif blockers and blockers[0] == "temporal_evidence": + next_action = f"Review the exact price retrieval timestamp and cutoff evidence for {ticker}." + elif blockers and blockers[0] == "exact_source_rights": + next_action = ( + f"Owner decision required for exact-source commercial rights on {ticker}: " + f"{' | '.join(_ordered_unique(unresolved_sources)) or ''}; keep identifiers intact." + ) + elif blockers and blockers[0] == "registered_field_scope": + next_action = ( + f"Owner review required for registered field scope on {ticker}: " + f"{', '.join(_ordered_unique(list(missing_scope) + list(price.missing_supported_fields))) or 'required fields'}." + ) + elif blockers and blockers[0] == "provenance": + next_action = ( + f"Review exact fundamentals provenance for {ticker}: " + f"{', '.join(provenance) or 'one unambiguous fundamentals row'}; keep missing evidence uninferred." + ) + elif blockers: + next_action = f"Independent review must classify the primary evidence blocker for {ticker}." + else: + next_action = "Review saved evidence before any separate owner decision." + return GoldenEvidenceMember( + ticker=ticker, + asset_type=asset_type, + cohort_role=role, + selection_reason=reason, + state=state, + saved_readiness_identity=_readiness_identity(saved_row), + proposed_readiness_identity=_readiness_identity(proposed_row), + usable_evidence_lanes=usable, + withheld_evidence_lanes=withheld, + source_identifiers=sources, + source_rights_states=rights, + missing_registered_fields=_ordered_unique(list(missing_scope) + list(price.missing_supported_fields)), + provenance_omissions=provenance, + temporal_evidence_omissions=temporal, + price_lineage_omissions=price_lineage, + method_fit_exclusions=method_fit, + independent_blockers=blockers, + owner_decision_required=bool( + {"exact_source_rights", "registered_field_scope"} & set(blockers) + ), + next_evidence_review_action=next_action, + saved_research_loop_status="partial_saved_evidence_only", + research_only_boundary=RESEARCH_ONLY_BOUNDARY, + ) + + +def build_golden_evidence_cohort_from_evidence( + saved_readiness: pd.DataFrame, + proposed_readiness: pd.DataFrame, + universe_master: pd.DataFrame, + fundamentals: pd.DataFrame, + prices: pd.DataFrame, + *, + preview: ReadinessImpactPreview, + rights_registry: Mapping[str, SourceRights], + top_n: int = 5, + review_cutoff: str | None = None, +) -> GoldenEvidenceCohort: + """Compose saved cohort, remediation, rights, and method-fit evidence without writing.""" + + if top_n < 1: + raise ValueError("top_n must be at least 1") + saved = _normalized(saved_readiness) + proposed = _normalized(proposed_readiness) + universe = _normalized(universe_master) + saved_by_ticker = _row_index(saved) + proposed_by_ticker = _row_index(proposed) + universe_by_ticker = _row_index(universe) + fundamental_rows = _rows_by_ticker(fundamentals) + + full_remediation = build_remediation_from_preview( + preview, + top_n=max(1, len(preview.promotion_review.evidence_rows if preview.promotion_review else ()) * 2), + ) + candidate_groups = _candidate_groups(full_remediation) + focused = build_focused_cohort( + saved, + universe, + target_size=3, + minimum_size=1, + profile_freshness="stale_saved_evidence", + ) + members: list[GoldenEvidenceMember] = [] + selected = set() + for focused_member in focused.members[:3]: + ticker = focused_member.ticker + selected.add(ticker) + members.append( + _member( + ticker=ticker, + asset_type=_asset_type(ticker, saved_by_ticker, proposed_by_ticker, universe_by_ticker), + role="saved_operating_company", + reason="Selected from the existing saved focused-cohort order; saved evidence remains separately gated.", + saved_row=saved_by_ticker.get(ticker), + proposed_row=proposed_by_ticker.get(ticker), + fundamentals=fundamental_rows, + prices=prices, + rights_registry=rights_registry, + review_cutoff=review_cutoff, + ) + ) + + control_ticker = next( + ( + ticker + for ticker in candidate_groups + if ticker not in selected + and _asset_type(ticker, saved_by_ticker, proposed_by_ticker, universe_by_ticker) in OPERATING_ASSET_TYPES + ), + "", + ) + if control_ticker: + selected.add(control_ticker) + members.append( + _member( + ticker=control_ticker, + asset_type=_asset_type(control_ticker, saved_by_ticker, proposed_by_ticker, universe_by_ticker), + role="evidence_gap_control", + reason="First distinct operating-company control in the existing remediation order; same-ticker feature evidence is aggregated.", + saved_row=saved_by_ticker.get(control_ticker), + proposed_row=proposed_by_ticker.get(control_ticker), + fundamentals=fundamental_rows, + prices=prices, + rights_registry=rights_registry, + review_cutoff=review_cutoff, + candidate_rows=candidate_groups[control_ticker], + ) + ) + + method_tickers = sorted( + ticker + for ticker, row in saved_by_ticker.items() + if ticker not in selected + and _asset_type(ticker, saved_by_ticker, proposed_by_ticker, universe_by_ticker) in METHOD_FIT_ASSET_TYPES + and "dcf" in _feature_set(row.get("excluded_features")) + ) + if method_tickers: + ticker = method_tickers[0] + metadata = _metadata(ticker, saved_by_ticker, proposed_by_ticker, universe_by_ticker) + method_asset_type = _asset_type(ticker, saved_by_ticker, proposed_by_ticker, universe_by_ticker) + method_fit = company_dcf_exclusion_reasons( + "index_proxy" if method_asset_type == "index" else method_asset_type, + metadata, + None, + ) + if method_fit: + members.append( + _member( + ticker=ticker, + asset_type=_asset_type(ticker, saved_by_ticker, proposed_by_ticker, universe_by_ticker), + role="method_fit_exclusion", + reason="Distinct saved ETF, index, or fund with an explicit company-DCF method-fit exclusion.", + saved_row=saved_by_ticker.get(ticker), + proposed_row=proposed_by_ticker.get(ticker), + fundamentals=fundamental_rows, + prices=prices, + rights_registry=rights_registry, + review_cutoff=review_cutoff, + method_fit=method_fit, + ) + ) + emitted = tuple(members[: min(top_n, 5)]) + return GoldenEvidenceCohort( + status=( + "missing_saved_snapshot" + if preview.status == "missing_saved_snapshot" + else "inspection_only" + ), + saved_snapshot_identity=preview.saved_snapshot_identity, + proposed_snapshot_identity=preview.proposed_snapshot_identity, + members=emitted, + top_n=top_n, + ) + + +def build_golden_evidence_cohort( + root: Path | str, + *, + data_dir: Path | str | None = None, + top_n: int = 5, + rights_registry: Mapping[str, SourceRights] | None = None, + review_cutoff: str | None = None, +) -> GoldenEvidenceCohort: + project_root = resolve_project_root(root) + data_path = resolve_data_dir(data_dir, project_root) + saved_path = data_path / "reports" / "ticker_readiness_report.csv" + saved = pd.read_csv(saved_path) if saved_path.exists() else pd.DataFrame() + preview = build_readiness_impact_preview( + project_root, + data_dir=data_path, + top_n=max(top_n, 5), + rights_registry=rights_registry, + review_cutoff=review_cutoff, + include_all_evidence=True, + ) + proposed = pd.DataFrame() + if preview.status != "missing_saved_snapshot": + from src.readiness_engine import build_ticker_readiness_report + + proposed = build_ticker_readiness_report( + project_root, data_dir=data_path, write_outputs=False + )["ticker_readiness_report"] + universe_path = data_path / "universe_master.csv" + fundamentals_path = data_path / "fundamentals.csv" + prices_path = data_path / "prices.csv" + registry = ( + rights_registry + if rights_registry is not None + else load_source_rights_registry(project_root / "config" / "source_rights.yml") + ) + return build_golden_evidence_cohort_from_evidence( + saved, + proposed, + pd.read_csv(universe_path) if universe_path.exists() else pd.DataFrame(), + pd.read_csv(fundamentals_path) if fundamentals_path.exists() else pd.DataFrame(), + pd.read_csv(prices_path) if prices_path.exists() else pd.DataFrame(), + preview=preview, + rights_registry=registry, + top_n=top_n, + review_cutoff=review_cutoff, + ) + + +def render_golden_evidence_cohort_json(packet: GoldenEvidenceCohort) -> str: + return json.dumps(asdict(packet), ensure_ascii=False, separators=(",", ":"), sort_keys=True) + + +def render_golden_evidence_cohort(packet: GoldenEvidenceCohort) -> str: + lines = [ + "Golden Evidence Cohort", + "", + f"status={packet.status}", + f"saved_snapshot_identity={packet.saved_snapshot_identity or ''}", + f"proposed_snapshot_identity={packet.proposed_snapshot_identity or ''}", + f"members={len(packet.members)}; TOP_N={packet.top_n}", + ] + for member in packet.members: + lines.extend( + [ + f"- {member.ticker}: role={member.cohort_role}; state={member.state}", + f" asset_type={member.asset_type}", + f" reason={member.selection_reason}", + f" saved_readiness_identity={member.saved_readiness_identity}", + f" proposed_readiness_identity={member.proposed_readiness_identity}", + f" usable_evidence_lanes={','.join(member.usable_evidence_lanes) or 'none'}", + f" withheld_evidence_lanes={','.join(member.withheld_evidence_lanes) or 'none'}", + f" independent_blockers={','.join(member.independent_blockers) or 'none'}", + f" source_identifiers={','.join(member.source_identifiers) or 'none'}", + f" source_rights_states={','.join(member.source_rights_states) or 'none'}", + f" missing_registered_fields={','.join(member.missing_registered_fields) or 'none'}", + f" provenance_omissions={','.join(member.provenance_omissions) or 'none'}", + f" temporal_evidence_omissions={','.join(member.temporal_evidence_omissions) or 'none'}", + f" price_lineage_omissions={','.join(member.price_lineage_omissions) or 'none'}", + f" method_fit_exclusions={','.join(member.method_fit_exclusions) or 'none'}", + f" owner_decision_required={str(member.owner_decision_required).lower()}", + f" next_evidence_review_action={member.next_evidence_review_action}", + f" saved_research_loop_status={member.saved_research_loop_status}", + f" research_only_boundary={member.research_only_boundary}", + ] + ) + lines.extend( + [ + "", + "inspection_only=true", + "canonical_apply_authorized=false", + "readiness_materialization_authorized=false", + "source_rights_change_authorized=false", + "recommendation_authorized=false", + "repository_writes=[]", + RESEARCH_ONLY_BOUNDARY, + ] + ) + return "\n".join(lines) + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Build a deterministic, inspection-only Golden Evidence Cohort packet.") + parser.add_argument("--project-root", default=".") + parser.add_argument("--data-dir") + parser.add_argument("--top-n", type=int, default=5) + parser.add_argument("--review-cutoff") + parser.add_argument("--json", action="store_true") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = _parser().parse_args(argv) + try: + packet = build_golden_evidence_cohort( + Path(args.project_root), + data_dir=args.data_dir, + top_n=args.top_n, + review_cutoff=args.review_cutoff, + ) + except (KeyError, OSError, ValueError) as exc: + print(f"Golden evidence cohort failed: {exc}") + print("repository_writes=[]") + return 1 + print(render_golden_evidence_cohort_json(packet) if args.json else render_golden_evidence_cohort(packet)) + return 2 if packet.status == "missing_saved_snapshot" else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_golden_evidence_cohort.py b/tests/test_golden_evidence_cohort.py new file mode 100644 index 00000000..ca4b8e2d --- /dev/null +++ b/tests/test_golden_evidence_cohort.py @@ -0,0 +1,875 @@ +from dataclasses import replace +import json +from pathlib import Path +from typing import Mapping + +import pandas as pd + +from src.commercial_source_rights import SourceRights +from src.golden_evidence_cohort import ( + build_golden_evidence_cohort, + build_golden_evidence_cohort_from_evidence, + main as golden_evidence_main, + render_golden_evidence_cohort, + render_golden_evidence_cohort_json, +) +from src.readiness_preview import ( + compare_readiness_frames, + review_dcf_price_lineage, + review_readiness_changes, + review_readiness_promotions, +) + + +def _rights(source_id: str, *, supported_fields: tuple[str, ...], commercial_use: str = "approved") -> SourceRights: + return SourceRights( + source_id=source_id, + display_name=source_id, + permitted_use="source_backed_research", + commercial_use=commercial_use, + redistribution="derived_data_only", + storage_limits="reviewed local rows", + attribution="required", + rate_limits="provider terms", + authentication="none", + expected_freshness="filing_driven", + supported_fields=supported_fields, + fallback_priority=1, + ) + + +def _registry() -> dict[str, SourceRights]: + return { + "approved_fundamentals": _rights( + "approved_fundamentals", + supported_fields=("revenue", "shares_outstanding", "filing_dates"), + ), + "scope_gap": _rights("scope_gap", supported_fields=("revenue",)), + "approved_prices": _rights("approved_prices", supported_fields=("prices",)), + } + + +def _readiness_row(ticker: str, **overrides: object) -> dict[str, object]: + row: dict[str, object] = { + "ticker": ticker, + "name": f"{ticker} Example", + "asset_type": "company", + "in_active_universe": True, + "overall_readiness_state": "partial", + "price_ready": True, + "momentum_ready": True, + "fundamentals_ready": True, + "dcf_ready": True, + "peer_ready": False, + "earnings_ready": False, + "analyst_estimates_ready": False, + "ready_features": "price, fundamentals, dcf", + "partial_features": "", + "blocked_features": "peer, earnings, analyst_estimates", + "excluded_features": "", + "updated_at": "2026-01-01T00:00:00+00:00", + } + row.update(overrides) + return row + + +def _default_fundamentals() -> pd.DataFrame: + return pd.DataFrame( + [ + { + "ticker": ticker, + "revenue": 100.0, + "free_cash_flow": 20.0, + "fcf_margin": 0.2, + "shares_outstanding": 10.0, + "source": "approved_fundamentals; filing_document" if ticker == "AAA" else "approved_fundamentals", + "as_of_date": "2025-12-31", + "sec_accession": f"filing:{ticker}", + } + for ticker in ("AAA", "BBB", "CCC") + ] + + [ + { + "ticker": "ABAT", + "revenue": 100.0, + "free_cash_flow": 20.0, + "fcf_margin": 0.2, + "shares_outstanding": 10.0, + "source": "scope_gap", + "as_of_date": "2025-12-31", + "sec_accession": "filing:ABAT", + } + ] + ) + + +def _packet( + *, + prices: pd.DataFrame | None = None, + abat_source: str = "scope_gap", + fundamentals: pd.DataFrame | None = None, + include_etf: bool = True, + method_asset_type: str = "etf", + method_dcf_ready: bool = False, + method_core_evidence: bool = False, + rights_registry: Mapping[str, SourceRights] | None = None, +): + registry = rights_registry if rights_registry is not None else _registry() + saved = pd.DataFrame( + [ + _readiness_row("AAA"), + _readiness_row("BBB"), + _readiness_row("CCC"), + _readiness_row( + "ABAT", + in_active_universe=False, + fundamentals_ready=False, + dcf_ready=False, + ready_features="price", + blocked_features="fundamentals, dcf, peer, earnings, analyst_estimates", + ), + _readiness_row( + "QQQ", + asset_type=method_asset_type, + dcf_ready=method_dcf_ready, + ready_features="price, fundamentals, dcf" if method_dcf_ready else "price", + blocked_features="fundamentals, peer, earnings, analyst_estimates", + excluded_features="dcf", + ), + ] + ) + if not include_etf: + saved = saved.loc[saved["ticker"] != "QQQ"].copy() + proposed = saved.copy() + proposed.loc[proposed["ticker"] == "ABAT", ["fundamentals_ready", "dcf_ready"]] = True + proposed.loc[proposed["ticker"] == "ABAT", "ready_features"] = "price, fundamentals, dcf" + proposed.loc[proposed["ticker"] == "ABAT", "blocked_features"] = "peer, earnings, analyst_estimates" + proposed = pd.concat( + [ + proposed, + pd.DataFrame( + [ + _readiness_row( + "NEW", + fundamentals_ready=True, + dcf_ready=True, + in_active_universe=False, + ) + ] + ), + ], + ignore_index=True, + ) + universe = pd.DataFrame( + [ + {"ticker": "AAA", "asset_type": "company", "is_active_listing": True, "name": "AAA Example"}, + {"ticker": "BBB", "asset_type": "company", "is_active_listing": True, "name": "BBB Example"}, + {"ticker": "CCC", "asset_type": "company", "is_active_listing": True, "name": "CCC Example"}, + {"ticker": "ABAT", "asset_type": "company", "is_active_listing": True, "name": "ABAT Example"}, + {"ticker": "QQQ", "asset_type": method_asset_type, "is_active_listing": True, "name": "QQQ Example"}, + {"ticker": "NEW", "asset_type": "company", "is_active_listing": True, "name": "New Example"}, + ] + ) + if fundamentals is None: + fundamentals = _default_fundamentals() + fundamentals.loc[fundamentals["ticker"] == "ABAT", "source"] = abat_source + if method_core_evidence: + fundamentals = pd.concat( + [ + fundamentals, + pd.DataFrame( + [ + { + "ticker": "QQQ", + "revenue": 100.0, + "free_cash_flow": 20.0, + "fcf_margin": 0.2, + "shares_outstanding": 10.0, + "source": "approved_fundamentals", + "as_of_date": "2025-12-31", + "sec_accession": "filing:QQQ", + } + ] + ), + ], + ignore_index=True, + ) + if prices is None: + prices = pd.DataFrame( + [ + {"ticker": ticker, "date": "2026-01-03", "close": 10.0, "source": "approved_prices", "source_ref": f"price:{ticker}", "retrieved_at": "2026-01-04T01:00:00Z"} + for ticker in (("AAA", "BBB", "CCC", "QQQ") if method_core_evidence else ("AAA", "BBB", "CCC")) + ] + + [ + {"ticker": "ABAT", "date": "2026-01-03", "close": 10.0, "source": "", "source_ref": "", "retrieved_at": ""} + ] + ) + preview = compare_readiness_frames(saved, proposed, top_n=20) + preview = replace( + preview, + promotion_review=review_readiness_promotions( + saved, proposed, fundamentals, rights_registry=registry, top_n=20 + ), + change_review=review_readiness_changes(saved, proposed, fundamentals), + dcf_price_lineage_review=review_dcf_price_lineage( + saved, + proposed, + prices, + rights_registry=registry, + review_cutoff="2026-01-06T00:00:00Z", + top_n=20, + ), + ) + return build_golden_evidence_cohort_from_evidence( + saved, + proposed, + universe, + fundamentals, + prices, + preview=preview, + rights_registry=registry, + top_n=5, + review_cutoff="2026-01-06T00:00:00Z", + ) + + +def test_packet_keeps_the_five_roles_fail_closed_and_preserves_composite_identifiers(): + packet = _packet() + + assert [(member.ticker, member.cohort_role) for member in packet.members] == [ + ("AAA", "saved_operating_company"), + ("BBB", "saved_operating_company"), + ("CCC", "saved_operating_company"), + ("ABAT", "evidence_gap_control"), + ("QQQ", "method_fit_exclusion"), + ] + assert packet.inspection_only is True + assert packet.canonical_apply_authorized is False + assert packet.readiness_materialization_authorized is False + assert packet.source_rights_change_authorized is False + assert packet.recommendation_authorized is False + assert packet.repository_writes == () + + composite = packet.members[0] + assert "approved_fundamentals; filing_document" in composite.source_identifiers + assert all("filing_document" not in value or value == "approved_fundamentals; filing_document" for value in composite.source_identifiers) + + abat = packet.members[3] + assert abat.state == "withheld_price_lineage" + assert "registered_field_scope" in abat.independent_blockers + assert "price_lineage" in abat.independent_blockers + assert "temporal_evidence" in abat.independent_blockers + assert "exact_source_rights" in abat.independent_blockers + assert "free_cash_flow" in abat.missing_registered_fields + assert "source" in abat.price_lineage_omissions + assert "missing_retrieved_at" in abat.temporal_evidence_omissions + + qqq = packet.members[4] + assert qqq.state == "method_fit_excluded" + assert qqq.method_fit_exclusions == ("non_operating_asset_type",) + + for member in packet.members: + assert member.state not in {"activated", "current", "approved", "commercially_eligible"} + assert member.saved_research_loop_status == "partial_saved_evidence_only" + + +def test_packet_never_pads_categories_and_json_is_deterministic_under_input_ties(): + first = _packet(include_etf=False) + second = _packet(include_etf=False) + capped = build_golden_evidence_cohort_from_evidence( + pd.DataFrame(), + pd.DataFrame(), + pd.DataFrame(), + pd.DataFrame(), + pd.DataFrame(), + preview=compare_readiness_frames(pd.DataFrame(), pd.DataFrame(), top_n=1), + rights_registry=_registry(), + top_n=5, + ) + + assert [(member.ticker, member.cohort_role) for member in first.members] == [ + ("AAA", "saved_operating_company"), + ("BBB", "saved_operating_company"), + ("CCC", "saved_operating_company"), + ("ABAT", "evidence_gap_control"), + ] + assert render_golden_evidence_cohort_json(first) == render_golden_evidence_cohort_json(second) + assert capped.members == () + assert capped.status == "inspection_only" + assert capped.inspection_only is True + + +def test_packet_reuses_price_lineage_review_for_missing_malformed_and_ambiguous_latest_rows(): + missing = _packet() + malformed = _packet( + prices=pd.DataFrame( + [{"ticker": "ABAT", "date": "2026-01-03", "close": 10.0, "source": "approved_prices", "source_ref": "price:ABAT", "retrieved_at": "not-a-timestamp"}] + ) + ) + ambiguous = _packet( + prices=pd.DataFrame( + [ + {"ticker": "ABAT", "date": "2026-01-03", "close": 10.0, "source": "approved_prices", "source_ref": "price:ABAT:1", "retrieved_at": "2026-01-04T01:00:00Z"}, + {"ticker": "ABAT", "date": "2026-01-03", "close": 11.0, "source": "approved_prices", "source_ref": "price:ABAT:2", "retrieved_at": "2026-01-04T01:00:00Z"}, + ] + ) + ) + + assert "missing_retrieved_at" in missing.members[3].temporal_evidence_omissions + assert "invalid_retrieved_at" in malformed.members[3].temporal_evidence_omissions + assert "ambiguous_latest_price_row" in ambiguous.members[3].price_lineage_omissions + assert ambiguous.members[3].state == "withheld_price_lineage" + + +def test_real_saved_packet_has_the_verified_base_roles_and_never_uses_action_language(): + root = Path.cwd() + packet = build_golden_evidence_cohort(root, top_n=5) + rendered = render_golden_evidence_cohort_json(packet).lower() + + assert [member.ticker for member in packet.members] == ["AMD", "AVGO", "COHR", "ABAT", "QQQ"] + assert json.loads(rendered)["repository_writes"] == [] + for forbidden in ("buy", "sell", "return", "target", "upside", "allocation", "position sizing"): + assert forbidden not in rendered + + +def test_filing_date_is_withheld_when_the_registered_field_has_no_saved_value(): + packet = _packet() + + assert "filing_date" not in packet.members[1].usable_evidence_lanes + assert "filing_date" in packet.members[1].withheld_evidence_lanes + + +def test_explicit_empty_rights_registry_is_not_replaced_by_configured_rights(monkeypatch): + import src.golden_evidence_cohort as golden_evidence_cohort + import src.readiness_engine as readiness_engine + + saved = pd.read_csv(Path.cwd() / "data" / "reports" / "ticker_readiness_report.csv") + proposed = saved.copy() + preview = compare_readiness_frames(saved, proposed, top_n=5) + + monkeypatch.setattr(golden_evidence_cohort, "build_readiness_impact_preview", lambda *args, **kwargs: preview) + monkeypatch.setattr( + readiness_engine, + "build_ticker_readiness_report", + lambda *args, **kwargs: {"ticker_readiness_report": proposed}, + ) + monkeypatch.setattr( + golden_evidence_cohort, + "load_source_rights_registry", + lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("configured registry must not be loaded")), + ) + + packet = build_golden_evidence_cohort(Path.cwd(), rights_registry={}) + + assert packet.inspection_only is True + + +def test_default_text_packet_exposes_the_complete_member_contract(): + packet = _packet() + rendered = render_golden_evidence_cohort(packet) + + for field in ( + "asset_type", + "saved_readiness_identity", + "proposed_readiness_identity", + "missing_registered_fields", + "provenance_omissions", + "temporal_evidence_omissions", + "price_lineage_omissions", + "method_fit_exclusions", + "owner_decision_required", + "saved_research_loop_status", + "research_only_boundary", + ): + assert f" {field}=" in rendered + + +def test_missing_price_identity_and_its_rights_state_remain_explicit(): + abat = _packet().members[3] + + assert "" in abat.source_identifiers + assert ":unknown_source" in abat.source_rights_states + + +def test_literal_index_asset_type_can_fill_the_method_fit_role_deterministically(): + packet = _packet(method_asset_type="index") + + assert packet.members[-1].ticker == "QQQ" + assert packet.members[-1].asset_type == "index" + assert packet.members[-1].state == "method_fit_excluded" + assert packet.members[-1].method_fit_exclusions == ("non_operating_asset_type",) + + +def test_completed_price_and_fcf_evidence_is_usable_but_independent_blockers_remain_withheld(): + registry = { + **_registry(), + "approved_fundamentals": _rights( + "approved_fundamentals", + supported_fields=( + "revenue", + "free_cash_flow", + "fcf_margin", + "shares_outstanding", + "filing_dates", + ), + ), + } + packet = _packet(rights_registry=registry) + bbb = packet.members[1] + abat = packet.members[3] + + assert {"price_lineage", "free_cash_flow", "fcf_margin"} <= set(bbb.usable_evidence_lanes) + assert not {"price_lineage", "free_cash_flow", "fcf_margin"} & set(bbb.withheld_evidence_lanes) + assert "free_cash_flow" not in abat.usable_evidence_lanes + assert "registered_field_scope" in abat.independent_blockers + + +def test_malformed_or_nonfinite_fundamentals_stay_withheld_without_dcf_activation(): + fundamentals = _default_fundamentals().astype(object) + bbb = fundamentals["ticker"] == "BBB" + fundamentals.loc[bbb, "revenue"] = "not-a-number" + fundamentals.loc[bbb, "free_cash_flow"] = float("nan") + fundamentals.loc[bbb, "fcf_margin"] = True + fundamentals.loc[bbb, "shares_outstanding"] = "inf" + fundamentals.loc[bbb, "sec_filed_date"] = "not-a-date" + registry = { + **_registry(), + "approved_fundamentals": _rights( + "approved_fundamentals", + supported_fields=( + "revenue", + "free_cash_flow", + "fcf_margin", + "shares_outstanding", + "filing_dates", + ), + ), + } + + member = _packet(fundamentals=fundamentals, rights_registry=registry).members[1] + + malformed_lanes = { + "revenue", + "free_cash_flow", + "fcf_margin", + "shares_outstanding", + "filing_date", + } + assert not malformed_lanes & set(member.usable_evidence_lanes) + assert malformed_lanes <= set(member.withheld_evidence_lanes) + assert "dcf" not in member.usable_evidence_lanes + assert "dcf" in member.withheld_evidence_lanes + assert member.state not in {"activated", "current", "approved", "commercially_eligible"} + + +def test_finite_numeric_strings_and_a_valid_filing_date_remain_usable(): + fundamentals = _default_fundamentals().astype(object) + bbb = fundamentals["ticker"] == "BBB" + fundamentals.loc[bbb, "revenue"] = "100.25" + fundamentals.loc[bbb, "free_cash_flow"] = "20.5" + fundamentals.loc[bbb, "fcf_margin"] = "0.204" + fundamentals.loc[bbb, "shares_outstanding"] = "10" + fundamentals.loc[bbb, "sec_filed_date"] = "2026-01-02" + registry = { + **_registry(), + "approved_fundamentals": _rights( + "approved_fundamentals", + supported_fields=( + "revenue", + "free_cash_flow", + "fcf_margin", + "shares_outstanding", + "filing_dates", + ), + ), + } + + member = _packet(fundamentals=fundamentals, rights_registry=registry).members[1] + + assert { + "revenue", + "free_cash_flow", + "fcf_margin", + "shares_outstanding", + "filing_date", + "price_lineage", + "dcf", + } <= set(member.usable_evidence_lanes) + + +def test_duplicate_fundamentals_preserve_every_exact_source_and_rights_state(): + fundamentals = _default_fundamentals() + duplicate = dict(fundamentals.loc[fundamentals["ticker"] == "BBB"].iloc[0]) + duplicate["source"] = "unresolved_feed; filing_copy" + duplicate["sec_accession"] = "filing:BBB:duplicate" + fundamentals = pd.concat([fundamentals, pd.DataFrame([duplicate])], ignore_index=True) + registry = { + **_registry(), + "approved_fundamentals": _rights( + "approved_fundamentals", + supported_fields=( + "revenue", + "free_cash_flow", + "fcf_margin", + "shares_outstanding", + "filing_dates", + ), + ), + } + + member = _packet(fundamentals=fundamentals, rights_registry=registry).members[1] + + assert member.source_identifiers == ( + "approved_fundamentals", + "unresolved_feed; filing_copy", + "approved_prices", + ) + assert member.source_rights_states == ( + "approved_fundamentals:approved", + "unresolved_feed; filing_copy:unknown_source", + "approved_prices:approved", + ) + assert member.provenance_omissions == ("fundamentals_row",) + assert member.independent_blockers[:3] == ( + "provenance", + "exact_source_rights", + "registered_field_scope", + ) + assert member.state == "withheld_provenance" + assert member.owner_decision_required is True + assert not { + "revenue", + "free_cash_flow", + "fcf_margin", + "shares_outstanding", + "filing_date", + "dcf", + } & set(member.usable_evidence_lanes) + + +def test_duplicate_full_scope_sources_are_provenance_blocked_without_owner_scope_decision(): + fundamentals = _default_fundamentals() + duplicate = dict(fundamentals.loc[fundamentals["ticker"] == "BBB"].iloc[0]) + duplicate["source"] = "approved_duplicate" + duplicate["sec_accession"] = "filing:BBB:duplicate" + fundamentals = pd.concat([fundamentals, pd.DataFrame([duplicate])], ignore_index=True) + full_scope = ( + "revenue", + "free_cash_flow", + "fcf_margin", + "shares_outstanding", + "filing_dates", + ) + registry = { + **_registry(), + "approved_fundamentals": _rights( + "approved_fundamentals", supported_fields=full_scope + ), + "approved_duplicate": _rights( + "approved_duplicate", supported_fields=full_scope + ), + } + + member = _packet(fundamentals=fundamentals, rights_registry=registry).members[1] + + assert member.source_rights_states[:2] == ( + "approved_fundamentals:approved", + "approved_duplicate:approved", + ) + assert member.missing_registered_fields == () + assert member.independent_blockers == ("provenance",) + assert member.owner_decision_required is False + assert member.state == "withheld_provenance" + + +def test_duplicate_mixed_scope_sources_keep_exact_missing_field_owner_decision(): + fundamentals = _default_fundamentals() + duplicate = dict(fundamentals.loc[fundamentals["ticker"] == "BBB"].iloc[0]) + duplicate["source"] = "approved_partial_duplicate" + duplicate["sec_accession"] = "filing:BBB:duplicate" + fundamentals = pd.concat([fundamentals, pd.DataFrame([duplicate])], ignore_index=True) + full_scope = ( + "revenue", + "free_cash_flow", + "fcf_margin", + "shares_outstanding", + "filing_dates", + ) + registry = { + **_registry(), + "approved_fundamentals": _rights( + "approved_fundamentals", supported_fields=full_scope + ), + "approved_partial_duplicate": _rights( + "approved_partial_duplicate", supported_fields=("revenue",) + ), + } + + member = _packet(fundamentals=fundamentals, rights_registry=registry).members[1] + + assert member.missing_registered_fields == ( + "free_cash_flow", + "fcf_margin", + "shares_outstanding", + "filing_dates", + ) + assert member.independent_blockers == ( + "provenance", + "registered_field_scope", + ) + assert member.owner_decision_required is True + assert member.state == "withheld_provenance" + + +def test_saved_dcf_is_usable_only_with_all_completed_saved_core_evidence(): + registry = { + **_registry(), + "approved_fundamentals": _rights( + "approved_fundamentals", + supported_fields=( + "revenue", + "free_cash_flow", + "fcf_margin", + "shares_outstanding", + "filing_dates", + ), + ), + } + completed = _packet(rights_registry=registry).members[1] + price_blocked = _packet( + rights_registry=registry, + prices=pd.DataFrame( + [ + { + "ticker": "BBB", + "date": "2026-01-03", + "close": 10.0, + "source": "approved_prices", + "source_ref": "price:BBB", + "retrieved_at": "", + } + ] + ), + ).members[1] + + assert "dcf" in completed.usable_evidence_lanes + assert "dcf" not in completed.withheld_evidence_lanes + assert "dcf" not in price_blocked.usable_evidence_lanes + assert "dcf" in price_blocked.withheld_evidence_lanes + assert "temporal_evidence" in price_blocked.independent_blockers + + +def test_control_next_action_matches_the_merged_primary_price_lineage_blocker(): + abat = _packet().members[3] + + assert abat.state == "withheld_price_lineage" + assert abat.independent_blockers[0] == "price_lineage" + assert abat.next_evidence_review_action.startswith("Review exact latest-price lineage for ABAT:") + assert "provider" in abat.next_evidence_review_action.lower() + + +def test_exact_rights_action_lists_only_the_unresolved_exact_source_identifiers(): + registry = { + **_registry(), + "approved_fundamentals": _rights( + "approved_fundamentals", + supported_fields=( + "revenue", + "free_cash_flow", + "fcf_margin", + "shares_outstanding", + "filing_dates", + ), + ), + } + bbb = _packet( + rights_registry=registry, + prices=pd.DataFrame( + [ + { + "ticker": "BBB", + "date": "2026-01-03", + "close": 10.0, + "source": "unknown_prices", + "source_ref": "price:BBB", + "retrieved_at": "2026-01-04T01:00:00Z", + } + ] + ), + ).members[1] + + assert bbb.independent_blockers[0] == "exact_source_rights" + assert "approved_fundamentals:approved" in bbb.source_rights_states + assert "unknown_prices:unknown_source" in bbb.source_rights_states + assert "unknown_prices" in bbb.next_evidence_review_action + assert "approved_fundamentals" not in bbb.next_evidence_review_action + + +def test_unresolved_composite_fundamental_rights_drive_the_primary_owner_action(): + member = _packet().members[0] + + assert member.source_identifiers == ( + "approved_fundamentals; filing_document", + "approved_prices", + ) + assert member.source_rights_states == ( + "approved_fundamentals; filing_document:unknown_source", + "approved_prices:approved", + ) + assert member.independent_blockers[:2] == ( + "exact_source_rights", + "registered_field_scope", + ) + assert member.state == "withheld_exact_source_rights" + assert member.owner_decision_required is True + assert member.next_evidence_review_action == ( + "Owner decision required for exact-source commercial rights on AAA: " + "approved_fundamentals; filing_document; keep identifiers intact." + ) + + +def test_owner_decision_requires_only_rights_or_registered_scope_authority(): + full_registry = { + **_registry(), + "approved_fundamentals": _rights( + "approved_fundamentals", + supported_fields=( + "revenue", + "free_cash_flow", + "fcf_margin", + "shares_outstanding", + "filing_dates", + ), + ), + } + temporal_only = _packet( + rights_registry=full_registry, + prices=pd.DataFrame( + [ + { + "ticker": "BBB", + "date": "2026-01-03", + "close": 10.0, + "source": "approved_prices", + "source_ref": "price:BBB", + "retrieved_at": "2026-01-03T00:00:00Z", + } + ] + ), + ).members[1] + rights_member = _packet( + rights_registry=full_registry, + prices=pd.DataFrame( + [ + { + "ticker": "BBB", + "date": "2026-01-03", + "close": 10.0, + "source": "unknown_prices", + "source_ref": "price:BBB", + "retrieved_at": "2026-01-04T01:00:00Z", + } + ] + ), + ).members[1] + scope_member = _packet( + prices=pd.DataFrame( + [ + { + "ticker": "ABAT", + "date": "2026-01-03", + "close": 10.0, + "source": "approved_prices", + "source_ref": "price:ABAT", + "retrieved_at": "2026-01-04T01:00:00Z", + } + ] + ), + ).members[3] + + assert temporal_only.independent_blockers == ("temporal_evidence",) + assert temporal_only.owner_decision_required is False + assert "exact_source_rights" in rights_member.independent_blockers + assert rights_member.owner_decision_required is True + assert scope_member.independent_blockers == ("registered_field_scope",) + assert scope_member.owner_decision_required is True + + +def test_method_fit_exclusion_overrides_stale_saved_dcf_and_completed_core_evidence(): + registry = { + **_registry(), + "approved_fundamentals": _rights( + "approved_fundamentals", + supported_fields=( + "revenue", + "free_cash_flow", + "fcf_margin", + "shares_outstanding", + "filing_dates", + ), + ), + } + qqq = _packet( + method_asset_type="index", + method_dcf_ready=True, + method_core_evidence=True, + rights_registry=registry, + ).members[-1] + + assert qqq.state == "method_fit_excluded" + assert qqq.method_fit_exclusions == ("non_operating_asset_type",) + assert "dcf" not in qqq.usable_evidence_lanes + + +def test_missing_saved_snapshot_is_explicit_and_cli_exits_nonzero_in_text_and_json( + tmp_path: Path, + capsys, +): + missing_data = tmp_path / "missing-data" + packet = build_golden_evidence_cohort( + Path.cwd(), + data_dir=missing_data, + rights_registry={}, + ) + + assert packet.status == "missing_saved_snapshot" + assert packet.members == () + assert packet.inspection_only is True + assert packet.canonical_apply_authorized is False + assert packet.readiness_materialization_authorized is False + assert packet.source_rights_change_authorized is False + assert packet.recommendation_authorized is False + assert packet.repository_writes == () + + text_exit = golden_evidence_main( + ["--project-root", str(Path.cwd()), "--data-dir", str(missing_data)] + ) + text_output = capsys.readouterr().out + json_exit = golden_evidence_main( + [ + "--project-root", + str(Path.cwd()), + "--data-dir", + str(missing_data), + "--json", + ] + ) + json_output = capsys.readouterr().out + + assert text_exit == 2 + assert "status=missing_saved_snapshot" in text_output + assert "inspection_only=true" in text_output + assert "canonical_apply_authorized=false" in text_output + assert "readiness_materialization_authorized=false" in text_output + assert "source_rights_change_authorized=false" in text_output + assert "recommendation_authorized=false" in text_output + assert "repository_writes=[]" in text_output + assert json_exit == 2 + payload = json.loads(json_output) + assert payload["status"] == "missing_saved_snapshot" + assert payload["inspection_only"] is True + assert payload["canonical_apply_authorized"] is False + assert payload["readiness_materialization_authorized"] is False + assert payload["source_rights_change_authorized"] is False + assert payload["recommendation_authorized"] is False + assert payload["repository_writes"] == [] diff --git a/tests/test_launchers.py b/tests/test_launchers.py index 67a2fea6..c2a3f1d0 100644 --- a/tests/test_launchers.py +++ b/tests/test_launchers.py @@ -165,6 +165,133 @@ def test_readiness_evidence_remediation_make_is_deterministic_json_and_write_fre assert _tree_manifest(root) == before +def test_golden_evidence_cohort_make_is_deterministic_json_and_write_free(): + root = Path.cwd() + before = _tree_manifest(root) + + first = subprocess.run( + ["make", "--no-print-directory", "golden-evidence-cohort", "TOP_N=5", "JSON=1"], + cwd=root, + capture_output=True, + text=True, + check=False, + env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"}, + ) + second = subprocess.run( + ["make", "--no-print-directory", "golden-evidence-cohort", "TOP_N=5", "JSON=1"], + cwd=root, + capture_output=True, + text=True, + check=False, + env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"}, + ) + + assert first.returncode == 0, first.stderr + assert second.returncode == 0, second.stderr + assert first.stdout == second.stdout + payload = json.loads(first.stdout) + assert [member["ticker"] for member in payload["members"]] == ["AMD", "AVGO", "COHR", "ABAT", "QQQ"] + assert payload["inspection_only"] is True + assert payload["canonical_apply_authorized"] is False + assert payload["readiness_materialization_authorized"] is False + assert payload["source_rights_change_authorized"] is False + assert payload["recommendation_authorized"] is False + assert payload["repository_writes"] == [] + assert _tree_manifest(root) == before + + +def _run_isolated_golden_make(root: Path, fixture_root: Path, *, json_output: bool): + command = [ + "make", + "--no-print-directory", + "-f", + str(root / "Makefile"), + "golden-evidence-cohort", + "TOP_N=5", + ] + if json_output: + command.append("JSON=1") + return subprocess.run( + command, + cwd=fixture_root, + capture_output=True, + text=True, + check=False, + env={ + **os.environ, + "PYTHONDONTWRITEBYTECODE": "1", + "PYTHONPATH": str(root), + }, + ) + + +def test_golden_evidence_cohort_make_fails_closed_for_missing_snapshot_in_text_and_json( + tmp_path: Path, +): + root = Path.cwd() + config = tmp_path / "config" + config.mkdir() + (config / "source_rights.yml").write_bytes( + (root / "config" / "source_rights.yml").read_bytes() + ) + before = _tree_manifest(tmp_path) + + text_result = _run_isolated_golden_make(root, tmp_path, json_output=False) + json_result = _run_isolated_golden_make(root, tmp_path, json_output=True) + + assert text_result.returncode != 0 + assert "status=missing_saved_snapshot" in text_result.stdout + assert "inspection_only=true" in text_result.stdout + assert "canonical_apply_authorized=false" in text_result.stdout + assert "readiness_materialization_authorized=false" in text_result.stdout + assert "source_rights_change_authorized=false" in text_result.stdout + assert "recommendation_authorized=false" in text_result.stdout + assert "repository_writes=[]" in text_result.stdout + assert json_result.returncode != 0 + payload = json.loads(json_result.stdout) + assert payload["status"] == "missing_saved_snapshot" + assert payload["inspection_only"] is True + assert payload["canonical_apply_authorized"] is False + assert payload["readiness_materialization_authorized"] is False + assert payload["source_rights_change_authorized"] is False + assert payload["recommendation_authorized"] is False + assert payload["repository_writes"] == [] + assert _tree_manifest(tmp_path) == before + + +def test_golden_evidence_cohort_make_distinguishes_a_valid_empty_cohort_in_text_and_json( + tmp_path: Path, +): + root = Path.cwd() + config = tmp_path / "config" + config.mkdir() + (config / "source_rights.yml").write_bytes( + (root / "config" / "source_rights.yml").read_bytes() + ) + reports = tmp_path / "data" / "reports" + reports.mkdir(parents=True) + (reports / "ticker_readiness_report.csv").write_text("ticker\n", encoding="utf-8") + before = _tree_manifest(tmp_path) + + text_result = _run_isolated_golden_make(root, tmp_path, json_output=False) + json_result = _run_isolated_golden_make(root, tmp_path, json_output=True) + + assert text_result.returncode == 0, text_result.stderr + assert "status=inspection_only" in text_result.stdout + assert "members=0; TOP_N=5" in text_result.stdout + assert json_result.returncode == 0, json_result.stderr + payload = json.loads(json_result.stdout) + assert payload["status"] == "inspection_only" + assert payload["members"] == [] + assert payload["inspection_only"] is True + assert payload["canonical_apply_authorized"] is False + assert payload["readiness_materialization_authorized"] is False + assert payload["source_rights_change_authorized"] is False + assert payload["recommendation_authorized"] is False + assert payload["repository_writes"] == [] + assert _tree_manifest(tmp_path) == before + + def test_interview_brief_local_artifact_boundary_is_narrow(): expected_local = ( "output/documents/Stock_Research_Command_Center_Interview_Brief.docx",