Download engine/state.py from s-ttp/crucible: direct link, hf CLI and curl.
- Browser
- Download file 26.1 kB
-
https://huggingface.co/spaces/s-ttp/crucible/resolve/main/engine/state.py
- Command line
-
hf download hf://spaces/s-ttp/crucible/engine/state.py
-
curl -L -o state.py https://huggingface.co/spaces/s-ttp/crucible/resolve/main/engine/state.py
26.1 kB
| """AnalysisState: the one authoritative object for a run. | |
| Every published concept has exactly one location here (``state.forecast.eps``, | |
| ``state.pricing.market_composite``, ``state.blend.headline_return`` …). Legacy dicts produced by | |
| the older modules are raw inputs to adapters that write this object; no persona, validator, | |
| summary generator, renderer, claim audit or recommendation mapper reads them. | |
| Three guarantees are enforced by the object itself rather than by convention: | |
| * **Typed metrics.** Every number is a ``Metric`` carrying concept, period class, accounting basis, | |
| scope, currency, security basis, source, date, derivation and quality. Two metrics are only | |
| compared through ``compatible()``; an incompatible pair raises instead of producing a number. | |
| * **Stage order.** ``advance()`` moves through DATA_VALIDATED → FORECAST_VALIDATED → | |
| ANALYSIS_FINALIZED → REPORT_VALIDATED → RENDERED, only on a passing validation result, and only | |
| in order. | |
| * **No mutation after validation.** Passing a stage seals the namespaces that stage validated. | |
| Writing to a sealed namespace raises ``SealedStateError``; after ANALYSIS_FINALIZED the whole | |
| state is frozen and carries a content version. | |
| """ | |
| from __future__ import annotations | |
| import copy | |
| import hashlib | |
| import json | |
| from dataclasses import asdict, dataclass, replace | |
| from types import MappingProxyType | |
| # ── period classes and bases ────────────────────────────────────────────────────────── | |
| TTM, FY, NTM, FY1, FY2, SPOT, INTRINSIC, POINT = "TTM", "FY", "NTM", "FY1", "FY2", "SPOT", "INTRINSIC", "POINT" | |
| TRAILING = frozenset({TTM, FY}) | |
| FORWARD = frozenset({NTM, FY1, FY2}) | |
| GAAP, IFRS, ADJUSTED, MODEL, MARKET, NA = "GAAP", "IFRS", "ADJUSTED", "MODEL", "MARKET", "NA" | |
| REPORTED_BASES = frozenset({GAAP, IFRS}) | |
| ORDINARY_SHARE, ADS = "ORDINARY_SHARE", "ADS" | |
| # quality states | |
| VALIDATED, RECONCILED, UNVERIFIED, DERIVED, MODEL_ESTIMATE, WITHHELD, UNAVAILABLE = ( | |
| "VALIDATED", "RECONCILED", "UNVERIFIED", "DERIVED", "MODEL_ESTIMATE", "WITHHELD", "UNAVAILABLE") | |
| PARTIALLY_RECONCILED = "PARTIALLY_RECONCILED" # some, not all, of the periods a trailing figure sums were checked | |
| class IncompatibleMetrics(Exception): | |
| """Two metrics were compared that do not describe comparable quantities.""" | |
| class SealedStateError(Exception): | |
| """A namespace was written after the stage that validated it had passed.""" | |
| class StageOrderError(Exception): | |
| """A stage was entered before the stage it depends on had passed.""" | |
| class Metric: | |
| metric_id: str | |
| economic_concept: str | |
| value: float | None | |
| period_type: str | |
| accounting_basis: str = NA | |
| currency: str | None = "USD" | |
| scale: str = "UNIT" | |
| period: str | None = None # period end (ISO date) or as-of date | |
| scope: str = "CONSOLIDATED" | |
| perimeter: str = "consolidated" | |
| security_basis: str = "NA" # ORDINARY_SHARE / ADS for per-share values | |
| per_share: bool = False | |
| source: str = "" | |
| source_date: str | None = None | |
| derivation: str | None = None | |
| quality_status: str = VALIDATED | |
| unit: str = "USD" # USD, ratio, x, per_share, count, pct_points | |
| # non-GAAP semantics: "ADJUSTED" alone is not a definition. Two adjusted figures compare only | |
| # when their definitions match or an explicit reconciliation exists (definitions.py) | |
| adjustment_definition_id: str | None = None # e.g. PROVIDER_REPORTED_EPS, SELL_SIDE_CONSENSUS_EPS | |
| adjustment_provider: str | None = None | |
| adjustment_version: str | None = None | |
| excluded_items: tuple = () | |
| definition_effective_date: str | None = None | |
| # the actual fiscal period, not just its class: NTM and FY1 are both "forward" but need not overlap | |
| period_start: str | None = None | |
| period_end: str | None = None | |
| forecast_horizon_days: int | None = None | |
| # what the number IS (quantities.py): a margin level and a margin change can share a decimal, never a type | |
| quantity_type: str | None = None # LEVEL, GROWTH_RATE, MARGIN_LEVEL, MARGIN_CHANGE, … | |
| display_unit: str | None = None # PERCENT, BASIS_POINTS, PERCENTAGE_POINTS, USD_PER_SHARE, … | |
| # the corporate action that defines this figure's perimeter (corporate_actions.py); None = no perimeter event | |
| corporate_action_id: str | None = None | |
| def period_class(self): | |
| if self.period_type in FORWARD: | |
| return "FORWARD" | |
| if self.period_type in TRAILING: | |
| return "TRAILING" | |
| return self.period_type | |
| def basis_class(self): | |
| if self.accounting_basis in REPORTED_BASES: | |
| return "REPORTED" | |
| return self.accounting_basis | |
| def to_dict(self): | |
| d = asdict(self) | |
| d["period_class"] = self.period_class | |
| return d | |
| def with_value(self, value, **kw): | |
| return replace(self, value=value, **kw) | |
| def metric(metric_id, concept, value, period_type, **kw): | |
| try: | |
| v = None if value is None else float(value) | |
| except (TypeError, ValueError): | |
| v = None | |
| if v is not None and v != v: # NaN | |
| v = None | |
| if "quantity_type" not in kw: # what the number IS follows from its concept (quantities.py) | |
| import quantities as _q | |
| kw["quantity_type"] = _q.CONCEPT_QUANTITY.get(concept) or _EXTRA_QUANTITY.get(concept) | |
| return Metric(metric_id=metric_id, economic_concept=concept, value=v, period_type=period_type, **kw) | |
| _EXTRA_QUANTITY = {"PRICE": "LEVEL", "MARKET_CAP": "LEVEL", "SHARES": "LEVEL", "DILUTED_SHARES": "LEVEL", | |
| "DIVIDEND_YIELD": "YIELD", "ROE": "MARGIN_LEVEL", "ROA": "MARGIN_LEVEL", "PE": "LEVEL", "PEG": "LEVEL", | |
| "INTRINSIC_VALUE": "LEVEL", "MARKET_PRICING": "LEVEL", "TARGET_PRICE": "LEVEL", | |
| "PRETAX_INCOME": "LEVEL", "NET_INCOME": "LEVEL", "BOOK_EQUITY": "LEVEL", "EPS_GAP": "CHANGE", | |
| "COST_OF_CAPITAL": "YIELD", "TERMINAL_GROWTH": "GROWTH_RATE", "TARGET_GAP": "CHANGE", | |
| "BREAK_EVEN_REVENUE": "LEVEL", "BREAK_EVEN_OPERATING_MARGIN": "MARGIN_LEVEL", | |
| "BREAK_EVEN_REVENUE_CAGR": "GROWTH_RATE", "MARKET_SIZE": "LEVEL", "YEARS": "LEVEL", | |
| "MARKET_SHARE": "MARGIN_LEVEL"} | |
| def stamp_perimeter(state, action_id, namespaces): | |
| """Attach the perimeter's corporate_action_id to every Metric on a separated perimeter (pre-separation or | |
| continuing company), in the given namespaces, including Metrics nested one level in dicts.""" | |
| if not action_id: | |
| return 0 | |
| n = 0 | |
| split = ("pre-separation", "continuing company") | |
| def fix(v): | |
| nonlocal n | |
| if isinstance(v, Metric) and v.perimeter in split and v.corporate_action_id != action_id: | |
| n += 1 | |
| return replace(v, corporate_action_id=action_id) | |
| return v | |
| for ns in namespaces: | |
| space = getattr(state, ns, None) | |
| if space is None: | |
| continue | |
| for k in list(space.keys()): | |
| v = space.get(k) | |
| if isinstance(v, Metric): | |
| nv = fix(v) | |
| if nv is not v: | |
| space.set(k, nv) | |
| elif isinstance(v, dict) and any(isinstance(x, Metric) for x in v.values()): | |
| nd = {kk: fix(x) for kk, x in v.items()} | |
| space.set(k, nd) | |
| return n | |
| # perimeter: which business the figure describes. A completed separation leaves the trailing | |
| # statements on the old perimeter while consensus and the forecast describe the continuing company. | |
| CONSOLIDATED, PRE_SEPARATION, CONTINUING = "consolidated", "pre-separation", "continuing company" | |
| def _same_perimeter(a, b): | |
| pa, pb = a.perimeter or CONSOLIDATED, b.perimeter or CONSOLIDATED | |
| # "consolidated" (no separation recorded) and "continuing company" describe the same business today | |
| norm = lambda p: CONTINUING if p == CONSOLIDATED else p | |
| return norm(pa) == norm(pb) | |
| # ── compatibility: the ONLY way two metrics may be compared ─────────────────────────── | |
| COMPARISON_TYPES = ("level", "margin_relationship", "growth", "ratio", "benchmark", "valuation", "trend", "scenario", "path") | |
| # a scenario concept is the same economic quantity under a stated condition (what today's price requires): it compares | |
| # with its base concept, never with another quantity | |
| SCENARIO_BASE = {"BREAK_EVEN_REVENUE": "REVENUE", "BREAK_EVEN_OPERATING_MARGIN": "OPERATING_MARGIN", | |
| "BREAK_EVEN_REVENUE_CAGR": "REVENUE_GROWTH"} | |
| def base_concept(c): | |
| return SCENARIO_BASE.get(c, c) | |
| def compatible(a: Metric, b: Metric, comparison_type: str): | |
| """(ok, reasons). Mandatory before any comparison or derivation. | |
| level same concept, same period class, same basis class, same scope, currency, | |
| security basis — e.g. model EPS vs consensus EPS | |
| margin_relationship different concepts of one income statement: same period class, same basis | |
| class, same scope — e.g. forward net margin vs forward operating margin | |
| growth same concept and basis; one trailing, one forward (a forecast vs its base) | |
| ratio numerator and denominator of a multiple: same currency and security basis; | |
| a SPOT price may divide a forward or trailing per-share figure | |
| benchmark a company figure against an industry or peer aggregate of the SAME concept, period class | |
| and basis class (scope and perimeter may differ: the benchmark is another universe) | |
| valuation two per-share values (price, intrinsic value, composite, target): same currency and | |
| security basis; concepts may differ | |
| """ | |
| if comparison_type not in COMPARISON_TYPES: | |
| raise ValueError(f"unknown comparison type {comparison_type!r}") | |
| r = [] | |
| if a is None or b is None or a.value is None or b.value is None: | |
| return False, ["a value is missing"] | |
| if comparison_type in ("scenario", "path"): | |
| # scenario: a scenario figure against its base concept at the same horizon (break-even vs story revenue); | |
| # path: two levels of one quantity at two points of one path (starting revenue → break-even revenue) | |
| if base_concept(a.economic_concept) != base_concept(b.economic_concept): | |
| r.append(f"concept {a.economic_concept} ≠ {b.economic_concept}") | |
| if a.quantity_type and b.quantity_type and a.quantity_type != b.quantity_type: | |
| r.append(f"quantity {a.quantity_type} ≠ {b.quantity_type}") | |
| if comparison_type == "scenario" and a.period_class != b.period_class: | |
| r.append(f"period {a.period_class} ≠ {b.period_class}") | |
| if (a.currency or "") != (b.currency or "") and a.unit not in ("ratio", "x") and b.unit not in ("ratio", "x"): | |
| r.append(f"currency {a.currency} ≠ {b.currency}") | |
| if not _same_perimeter(a, b): | |
| r.append(f"perimeter {a.perimeter} ≠ {b.perimeter}") | |
| return (not r), r | |
| if comparison_type in ("level", "growth", "benchmark") and a.economic_concept != b.economic_concept: | |
| r.append(f"concept {a.economic_concept} ≠ {b.economic_concept}") | |
| if comparison_type in ("level", "growth", "benchmark") and a.quantity_type and b.quantity_type \ | |
| and a.quantity_type != b.quantity_type: | |
| r.append(f"quantity {a.quantity_type} ≠ {b.quantity_type}") | |
| if comparison_type == "benchmark": | |
| if a.period_class != b.period_class: | |
| r.append(f"period {a.period_class} ≠ {b.period_class}") | |
| if MODEL not in (a.basis_class, b.basis_class) and a.basis_class != b.basis_class: | |
| r.append(f"accounting basis {a.accounting_basis} ≠ {b.accounting_basis}") | |
| return (not r), r | |
| if comparison_type == "trend": | |
| # the same quantity at two points in time (trailing vs forward): one concept, one basis, ONE business | |
| if a.economic_concept != b.economic_concept: | |
| r.append(f"concept {a.economic_concept} ≠ {b.economic_concept}") | |
| if a.quantity_type and b.quantity_type and a.quantity_type != b.quantity_type: | |
| r.append(f"quantity {a.quantity_type} ≠ {b.quantity_type}") | |
| if MODEL not in (a.basis_class, b.basis_class) and a.basis_class != b.basis_class: | |
| r.append(f"accounting basis {a.accounting_basis} ≠ {b.accounting_basis}") | |
| if a.scope != b.scope: | |
| r.append(f"scope {a.scope} ≠ {b.scope}") | |
| if not _same_perimeter(a, b): | |
| r.append(f"perimeter {a.perimeter} ≠ {b.perimeter}") | |
| return (not r), r | |
| if comparison_type == "valuation": | |
| if (a.currency or "") != (b.currency or ""): | |
| r.append(f"currency {a.currency} ≠ {b.currency}") | |
| if not (a.per_share and b.per_share): | |
| r.append("a valuation comparison needs two per-share values") | |
| elif a.security_basis != b.security_basis and "NA" not in (a.security_basis, b.security_basis): | |
| r.append(f"security basis {a.security_basis} ≠ {b.security_basis}") | |
| return (not r), r | |
| if comparison_type in ("level", "margin_relationship") and a.period_class != b.period_class: | |
| r.append(f"period {a.period_class} ≠ {b.period_class}") | |
| if comparison_type == "growth" and not ({a.period_class, b.period_class} == {"FORWARD", "TRAILING"}): | |
| r.append("growth needs one trailing base and one forward figure") | |
| if comparison_type in ("level", "margin_relationship", "growth"): | |
| ba, bb = a.basis_class, b.basis_class | |
| if MODEL not in (ba, bb) and ba != bb: | |
| r.append(f"accounting basis {a.accounting_basis} ≠ {b.accounting_basis}") | |
| if a.scope != b.scope: | |
| r.append(f"scope {a.scope} ≠ {b.scope}") | |
| if comparison_type in ("level", "growth") and ADJUSTED in (a.accounting_basis, b.accounting_basis): | |
| # two adjusted figures are comparable only on one adjustment definition (or a declared reconciliation) | |
| import definitions as _defs | |
| ok_d, why_d = _defs.definitions_compatible(a, b) | |
| if not ok_d: | |
| r.append(why_d) | |
| if comparison_type == "level" and a.period_class == "FORWARD" and b.period_class == "FORWARD": | |
| import definitions as _defs | |
| ok_p, why_p = _defs.periods_overlap(a, b) | |
| if not ok_p: | |
| r.append(why_p) | |
| if comparison_type in ("level", "growth", "ratio"): | |
| if (a.currency or "") != (b.currency or "") and a.unit not in ("ratio", "x") and b.unit not in ("ratio", "x"): | |
| r.append(f"currency {a.currency} ≠ {b.currency}") | |
| if a.per_share and b.per_share and a.security_basis != b.security_basis: | |
| r.append(f"security basis {a.security_basis} ≠ {b.security_basis}") | |
| if not _same_perimeter(a, b): | |
| r.append(f"perimeter {a.perimeter} ≠ {b.perimeter}") | |
| if comparison_type == "ratio": | |
| if SPOT not in (a.period_type, b.period_type) and a.period_class != b.period_class: | |
| r.append("a multiple needs a spot price or matching periods") | |
| return (not r), r | |
| def require_compatible(a, b, comparison_type): | |
| ok, reasons = compatible(a, b, comparison_type) | |
| if not ok: | |
| raise IncompatibleMetrics(f"{a.metric_id if a else None} vs {b.metric_id if b else None} " | |
| f"({comparison_type}): " + "; ".join(reasons)) | |
| return True | |
| # ── canonical selection: one explicit choice per use case ───────────────────────────── | |
| SELECTION_POLICY = { | |
| "EPS_GROWTH_BASE": ("historical.eps_adjusted_ttm", | |
| "the EPS forecast is anchored to sell-side estimates, which are on the adjusted basis"), | |
| "FORWARD_PE_DENOMINATOR": ("forecast.eps", "the reconciled 12-month EPS forecast"), | |
| "TRAILING_PE_DENOMINATOR": ("historical.eps_adjusted_ttm", | |
| "same basis as the forward EPS, so trailing and forward multiples compare like for like"), | |
| "GAAP_PE_DENOMINATOR": ("historical.eps_gaap_ttm", "reported net income per diluted share"), | |
| "STORY_START_REVENUE": ("historical.revenue_ttm", "the reconciled historical revenue every model uses"), | |
| "STORY_START_MARGIN": ("historical.operating_margin_ttm", | |
| "the reconciled historical operating margin every model uses"), | |
| "FORECAST_GROWTH_BASE_REVENUE": ("historical.revenue_ttm", "the reconciled historical revenue"), | |
| "FORECAST_ACCOUNTING_CHECK": ("forecast.operating_margin vs forecast.net_margin", | |
| "both from the one reconciled forecast state, same period and basis"), | |
| "INTRINSIC_VALUE": ("valuation.intrinsic_value", "the value produced by the engine selected for this issuer"), | |
| "MARKET_PRICING": ("pricing.market_composite", "multiples and analyst targets, blended"), | |
| "HEADLINE_RETURN": ("blend.headline_return", "the blended model-implied 12-month price return"), | |
| "PM_RETURN": ("scenarios.pm_return", "the panel's probability-weighted scenario return"), | |
| "DIVIDEND_YIELD": ("market.dividend_yield", "trailing dividends paid ÷ market capitalisation"), | |
| "UNCERTAINTY_RANGE": ("uncertainty.scenario_range", "bear and bull scenario returns"), | |
| } | |
| # ── stages ──────────────────────────────────────────────────────────────────────────── | |
| STAGES = ("INITIAL", "DATA_VALIDATED", "FORECAST_VALIDATED", "ANALYSIS_FINALIZED", "REPORT_VALIDATED", "RENDERED") | |
| NAMESPACES = ("identity", "issuer", "market", "historical", "consensus", "segments", "guidance", "forecast", "valuation", | |
| "pricing", "quant", "macro", "technical", "sentiment", "insiders", "ownership", "leadership", "corporate_actions", | |
| "context", "relations", "evidence", "scenarios", "blend", "recommendation", "uncertainty", "claims", "provenance") | |
| # what each stage validates, and therefore seals | |
| SEALS = { | |
| "DATA_VALIDATED": ("identity", "issuer", "market", "historical", "consensus", "segments", "guidance"), | |
| "FORECAST_VALIDATED": ("forecast", "valuation", "pricing", "quant", "macro", "technical", "sentiment", | |
| "insiders", "ownership", "leadership", "corporate_actions", "context", "relations", "evidence"), | |
| "ANALYSIS_FINALIZED": NAMESPACES, | |
| } | |
| def _freeze(o): | |
| """Read-only deep copy: dicts become mapping proxies, lists become tuples.""" | |
| if isinstance(o, (dict, MappingProxyType)): | |
| return MappingProxyType({k: _freeze(v) for k, v in o.items()}) | |
| if isinstance(o, (list, tuple)): | |
| return tuple(_freeze(v) for v in o) | |
| if isinstance(o, set): | |
| return frozenset(o) | |
| return o | |
| def _jsonable(o): | |
| if isinstance(o, Metric): | |
| return o.to_dict() | |
| if isinstance(o, (dict, MappingProxyType)): | |
| return {k: _jsonable(v) for k, v in o.items()} | |
| if isinstance(o, (list, tuple)): | |
| return [_jsonable(v) for v in o] | |
| if isinstance(o, float) and o != o: | |
| return None | |
| return o | |
| class Namespace: | |
| __slots__ = ("_name", "_data", "_state") | |
| def __init__(self, name, state): | |
| object.__setattr__(self, "_name", name) | |
| object.__setattr__(self, "_data", {}) | |
| object.__setattr__(self, "_state", state) | |
| def __getattr__(self, k): | |
| try: | |
| return self._data[k] | |
| except KeyError: | |
| raise AttributeError(f"{self._name}.{k} is not set") from None | |
| def __setattr__(self, k, v): | |
| self._state._check_writable(self._name, k) | |
| self._data[k] = v | |
| def get(self, k, default=None): | |
| return self._data.get(k, default) | |
| def set(self, k, v): | |
| setattr(self, k, v) | |
| return v | |
| def keys(self): | |
| return self._data.keys() | |
| def items(self): | |
| return self._data.items() | |
| def __contains__(self, k): | |
| return k in self._data | |
| class AnalysisState: | |
| # declared for readers and type checkers; set through object.__setattr__ in __init__ | |
| _stage: str | |
| _sealed: set | |
| _frozen: bool | |
| _history: list | |
| version: str | None | |
| identity: Namespace; issuer: Namespace; market: Namespace; historical: Namespace | |
| consensus: Namespace; forecast: Namespace; valuation: Namespace; pricing: Namespace | |
| quant: Namespace; macro: Namespace; technical: Namespace; sentiment: Namespace | |
| insiders: Namespace; leadership: Namespace; corporate_actions: Namespace; context: Namespace | |
| scenarios: Namespace; blend: Namespace; recommendation: Namespace; uncertainty: Namespace | |
| claims: Namespace; provenance: Namespace | |
| def __init__(self, ticker, run_id=None): | |
| object.__setattr__(self, "_stage", "INITIAL") | |
| object.__setattr__(self, "_sealed", set()) | |
| object.__setattr__(self, "_frozen", False) | |
| object.__setattr__(self, "_history", []) | |
| object.__setattr__(self, "version", None) | |
| for ns in NAMESPACES: | |
| object.__setattr__(self, ns, Namespace(ns, self)) | |
| self.identity.ticker = ticker | |
| self.identity.run_id = run_id | |
| # ── writes ──────────────────────────────────────────────────────────────────────── | |
| def __setattr__(self, k, v): | |
| raise AttributeError("write through a namespace, e.g. state.forecast.eps = …") | |
| def _check_writable(self, ns, key): | |
| if self._frozen: | |
| raise SealedStateError(f"state is frozen at {self._stage}; cannot set {ns}.{key}") | |
| if ns in self._sealed: | |
| raise SealedStateError(f"{ns} was sealed when its validation stage passed; cannot set {ns}.{key}") | |
| # ── reads ───────────────────────────────────────────────────────────────────────── | |
| def get(self, path, default=None): | |
| """Resolve 'forecast.eps' or 'valuation.story.assumptions.margin_target'.""" | |
| parts = path.split(".") | |
| ns = getattr(self, parts[0], None) | |
| if not isinstance(ns, Namespace): | |
| return default | |
| cur = ns.get(parts[1]) if len(parts) > 1 else ns | |
| for p in parts[2:]: | |
| if cur is None: | |
| return default | |
| if isinstance(cur, Metric): | |
| cur = getattr(cur, p, None) | |
| elif isinstance(cur, (dict, MappingProxyType)): | |
| cur = cur.get(p) | |
| elif isinstance(cur, (list, tuple)) and p.isdigit(): | |
| i = int(p) | |
| cur = cur[i] if i < len(cur) else None | |
| else: | |
| cur = getattr(cur, p, None) | |
| return default if cur is None else cur | |
| def value(self, path, default=None): | |
| v = self.get(path) | |
| if isinstance(v, Metric): | |
| return default if v.value is None else v.value | |
| return default if v is None else v | |
| def select(self, use_case): | |
| """The canonical metric for a use case (SELECTION_POLICY). Consumers never choose.""" | |
| path, _reason = SELECTION_POLICY[use_case] | |
| return self.get(path) | |
| # ── stages ──────────────────────────────────────────────────────────────────────── | |
| def stage(self): | |
| return self._stage | |
| def require_stage(self, stage): | |
| if STAGES.index(self._stage) < STAGES.index(stage): | |
| raise StageOrderError(f"requires {stage}; state is at {self._stage}") | |
| def advance(self, stage, validation): | |
| """Enter ``stage`` only in order and only when its validation passed.""" | |
| cur = STAGES.index(self._stage) | |
| nxt = STAGES.index(stage) | |
| if nxt != cur + 1: | |
| raise StageOrderError(f"cannot move from {self._stage} to {stage}") | |
| if not getattr(validation, "passed", False): | |
| raise StageOrderError(f"{stage} validation did not pass: {getattr(validation, 'blocking', None)}") | |
| for ns in SEALS.get(stage, ()): | |
| if ns not in self._sealed: | |
| n = getattr(self, ns) | |
| object.__setattr__(n, "_data", {k: _freeze(v) for k, v in n._data.items()}) | |
| self._sealed.add(ns) | |
| self._history.append({"stage": stage, "checks": len(getattr(validation, "checks", []) or [])}) | |
| object.__setattr__(self, "_stage", stage) | |
| if stage == "ANALYSIS_FINALIZED": | |
| object.__setattr__(self, "_frozen", True) | |
| object.__setattr__(self, "version", self.content_hash()) | |
| return self | |
| def mark(self, stage): | |
| """Post-finalization stages (REPORT_VALIDATED, RENDERED) record progress without writes.""" | |
| if STAGES.index(stage) != STAGES.index(self._stage) + 1: | |
| raise StageOrderError(f"cannot move from {self._stage} to {stage}") | |
| object.__setattr__(self, "_stage", stage) | |
| def frozen(self): | |
| return self._frozen | |
| # ── serialisation ───────────────────────────────────────────────────────────────── | |
| def to_dict(self): | |
| return {ns: _jsonable(dict(getattr(self, ns)._data)) for ns in NAMESPACES} | |
| def content_hash(self): | |
| blob = json.dumps(self.to_dict(), sort_keys=True, default=str) | |
| return hashlib.sha256(blob.encode()).hexdigest()[:16] | |
| def snapshot(self): | |
| """A deep, JSON-safe copy (for the QA record).""" | |
| return copy.deepcopy(self.to_dict()) | |