diff --git a/.github/workflows/docs-quality.yml b/.github/workflows/docs-quality.yml index 37e448b8e..edc8720e8 100644 --- a/.github/workflows/docs-quality.yml +++ b/.github/workflows/docs-quality.yml @@ -2,12 +2,18 @@ name: Documentation Quality on: pull_request: + types: + - opened + - synchronize + - reopened + - ready_for_review paths: - "**/*.md" - "**/*.json" - ".github/workflows/**" - "scripts/validate_documentation.py" - "tests/quality/test_validate_documentation.py" + - "crates/**" push: branches: - main @@ -17,6 +23,7 @@ on: - ".github/workflows/**" - "scripts/validate_documentation.py" - "tests/quality/test_validate_documentation.py" + - "crates/**" workflow_dispatch: permissions: diff --git a/.gitignore b/.gitignore index 0c0bcc762..d74cd8c33 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ __pycache__/ *.py[cod] .pytest_cache/ /.codegraph/ +node_modules/ +origin/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 9a285d377..729b6d2b7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -53,16 +53,16 @@ boundaries above remain the target modular MSA architecture. |---|---| | `evidence_core` | immutable evidence domain primitives | | `semantic_core` | span-grounded semantic units; language is not identity | +| `location_membership` | location is not entity identity and not a language channel | | `temporal_core` | typed clocks, intervals, and temporal reasoning | -| `event_core` | event instances, mentions, roles, and provenance | +| `event_core` | event instances, mentions, roles, provenance, and CHRONOS occurrence-prediction calibration | | `relation_graph` | typed relations and forward-transition validation | | `membership_core` | time-varying cross-classified multiple membership, Kish ESS, nested ICC with non-nested refusal | | `persistence_postgres` | PostgreSQL repositories and migrations | | `corpus_split` | cutoff-safe, relation-aware partitioning | | `tepp_simulation` | known-truth temporal/event data generation | -| `validation_core` | RMSE, bias, coverage, graph, and Monte Carlo metrics | +| `validation_core` | RMSE, bias, coverage, graph, Monte Carlo, and exact-head claim-promotion metrics | | `tepp_api` | versioned DTO, schema, and export contracts | -| `location_membership` | location is not entity identity and not a language channel | | `prompt_source` | prompt boilerplate is not unique latent content and not stopword deletion | | `corpus_background` | corpus-background wording is not unique latent content and not stopword deletion | | `modality_source` | non-lexical modality is not unique latent content and not stopword deletion | @@ -99,12 +99,18 @@ boundaries above remain the target modular MSA architecture. | `interpretation_gateway` | evidence-bounded LLM interpretations; not estimators or observed facts | | `model_selection` | statistical/Pareto candidate-`K` gates; LLM votes are not numerical authority | | `checkpoint_authority` | a model checkpoint is not the CPU `f64` estimator | +| `compute_backend` | VRAM-budgeted streamed planning, executable OOM retry plans, and a compensated CPU `f64` reference | +| `episode_membership` | episode membership cannot escape the episode event-time interval | | `membership_target` | language, episode, template, department, and opportunity-pool targets cannot collapse into entity or project | Foundation crates expose only tested contracts. Empty façades are not public APIs. +No crate exposes placeholder production behavior in Task 1. This prevents an +empty façade from becoming a de facto public API before its invariants and tests +exist. + ## Immutable evidence boundary Task 2 begins the executable `evidence_core` boundary. Stable RFC 9562 `UUIDv7` @@ -158,6 +164,8 @@ TEPP stores event/valid time, assertion time, document time, system time, availa \operatorname{available\_time}(d) \leq \operatorname{knowledge\_cutoff}. \] +When availability is an interval, every possible instant in that interval must satisfy the inequality. Unknown or open-ended availability that can extend past the cutoff fails closed; event time and document time cannot substitute for availability. + Forward transition edges require a temporally valid partial order. Retrospective, revision, translation, citation, support, and contradiction relations retain their direction and provenance but do not create reverse state transitions. ## Measurement invariants diff --git a/CHANGELOG.md b/CHANGELOG.md index abc114e6b..20b7289aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,7 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang - `tepp_api` LineageWeave temporal-context contract (v1): cutoff-safe event eligibility, deterministic event-time ordering, explicit non-causal association/gap boundaries, HTTPS interchange construction, and loopback listener handling at `POST /v1/temporal-context`; read-only context requests no longer require the write-only idempotency header, and no causal inference or completed-result service is included. - `tepp_api` LineageWeave consumer-scoped analysis-run ingress: versioned, credential-free requests use a published consumer identity and isolate idempotency by consumer, tenant workspace, and opaque caller key; the one-shot restack workflow is removed after the protected-main merge is verified. - ADR 0018 records the consumer-scoped analysis-run ingress, its in-memory loopback maturity, and the persistence boundary required before production use. -- ADR 0020 records the credential-free bounded LineageWeave project-history service boundary and keeps source authorization with LineageWeave while TEPP owns temporal validation and deterministic projection. +- ADR 0021 records the credential-free bounded LineageWeave project-history service boundary and keeps source authorization with LineageWeave while TEPP owns temporal validation and deterministic projection. - `tepp_api` project-history wire-size symmetry (ADR 0019): request and projection serialization enforce the shared 256 KiB limit, and generated projections fail closed before returning when their deterministic response would exceed it. - `summarizes_edge` identity gate: summaries may point to earlier event time without becoming state transitions or reusing source-document identity; recovery tests outperform collapsing every summary to the source. - `outcome_order` identity gate: `input_to` and `process_to` require strict forward event-time rank, while `outcome_of` remains non-transition provenance; recovery tests outperform collapsing every kind to `input_to`. @@ -34,16 +34,12 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang - `stopword_deletion` method gate: a default or global stopword list cannot erase repeated report language; recovered deletion kinds match known truth at a higher computed rate than collapsing every token treatment to stopword deletion (ADR 0004/0012). - `persistence_postgres` retention/deletion/legal-hold (migration `0007`): policy rows, legal holds that block completed deletion, evidence tombstones without raw-source restore, analysis exclusion only for `logical_revocation`/`identity_tombstone` (not `cache_export_removal`), and deletion requests bound to the cited retention policy's tenant/class/purpose. - `copy_identity` identity gate: a template or pasted copy cannot reuse the source document identity or become a state transition; recovered copy kinds match known truth at a higher computed rate than collapsing every copy to the source (ADR 0003). -- `persistence_postgres` retention/deletion/legal-hold (migration `0007`): policy rows, legal holds that block completed deletion, evidence tombstones without raw-source restore, analysis exclusion only for `logical_revocation`/`identity_tombstone` (not `cache_export_removal`), and deletion requests bound to the cited retention policy's tenant/class/purpose. - `provider_receipt` disclosure receipt: records provider field codes and purpose-bound receipt metadata without persisting source text or source identity (ADR 0009). - `intake_authorization` identity gate: documents, serialized records, checkpoints, and LLM outputs cannot be accepted without a purpose-bound grant; size/identity/provenance bounds are not that grant; recovered grant-presence flags match known truth at a higher computed rate than accepting every intake (ADR 0009). -- `persistence_postgres` retention/deletion/legal-hold (migration `0007`): policy rows, legal holds that block completed deletion, evidence tombstones without raw-source restore, analysis exclusion only for `logical_revocation`/`identity_tombstone` (not `cache_export_removal`), and deletion requests bound to the cited retention policy's tenant/class/purpose. - `summarizes_edge` identity gate: a summary may point to earlier event time but cannot become a state transition or reuse the source document identity; recovered summary kinds match known truth at a higher computed rate than collapsing every summary to the source (ADR 0003). -- `persistence_postgres` retention/deletion/legal-hold (migration `0007`): policy rows, legal holds that block completed deletion, evidence tombstones without raw-source restore, analysis exclusion only for `logical_revocation`/`identity_tombstone` (not `cache_export_removal`), and deletion requests bound to the cited retention policy's tenant/class/purpose. - `outcome_order` identity gate: `input_to` and `process_to` cannot move backward or stay contemporaneous in event-time rank; `outcome_of` may point at an earlier producer and cannot become a state transition; recovered kinds match known truth at a higher computed rate than collapsing every kind to `input_to` (ADR 0002/0003). -- `persistence_postgres` retention/deletion/legal-hold (migration `0007`): policy rows, legal holds that block completed deletion, evidence tombstones without raw-source restore, analysis exclusion only for `logical_revocation`/`identity_tombstone` (not `cache_export_removal`), and deletion requests bound to the cited retention policy's tenant/class/purpose. - `retrospective_edge` identity gate: retrospective reporting may point to earlier event time but cannot become a state transition or a translation; recovered reporting kinds match known truth at a higher computed rate than collapsing every report to a contemporaneous forward report (ADR 0002/0003). - `payload_bound` identity gate: documents, serialized records, model checkpoints, and LLM outputs stay untrusted until identity, provenance, size, and depth validate; recovered accept/reject flags match known truth at a higher computed rate than accepting every payload (ADR 0008/0013). - `inferred_status` identity gate: inferred relations cannot be promoted to observed evidence or to state transitions; recovered observed/inferred labels match known truth at a higher computed rate than treating every status as observed (ADR 0003). @@ -86,9 +82,38 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang not required; this PR is implemented-main`). Only never/do not/does not/ cannot/must not plus promote/treat/make/mean counts as a promotion denial. - `checkpoint_authority` estimator gate: a model checkpoint remains an untrusted run artifact until identity, canonical `SHA-256`, and model-run provenance validate, and it cannot replace the CPU `f64` estimator or promote a scientific claim; recovered roles match known truth at a higher computed rate than collapsing every artifact to the estimator (ADR 0001/0014). -- `persistence_postgres` retention/deletion/legal-hold (migration `0007`): policy rows, legal holds that block completed deletion, evidence tombstones without raw-source restore, analysis exclusion only for `logical_revocation`/`identity_tombstone` (not `cache_export_removal`), and deletion requests bound to the cited retention policy's tenant/class/purpose. - `event_core` now requires and retains `EventEvidenceLayer::PromotedTransition` when constructing an `EventInstance`; every other layer is rejected at the promotion boundary, and TDT story classification uses a caller-owned hash set for expected constant-time membership checks. - `event_core` ADR 0016 evidence-status gates: TDT detections and CHRONOS predictions cannot admit a forward state transition; first-story detection scores miss/false-alarm rates against a known story stream (Allan 2002 task). +- `compute_backend` ADR 0006 first slice: VRAM profiles and reserve-aware micro-batching, executable successive OOM retry plans, CPU fallback, compensated `f64` reference arithmetic with scale-aware parity tolerance, grouped adaptation policies, and fail-closed estimand-preserving memory policies. +- `validation_core` ADR 0014 claim-promotion gates: `decision_accepted`, `implemented_main`, `scientifically_supported`, and `released` bind to an exact commit SHA; queued, predecessor, skipped-required, and LLM evidence fail closed; scientific promotion uses computed RMSE and its standard error rather than a hardcoded threshold (no new migration). +- `relation_graph` causal-identification gate: only `causes` and `intervenes_on` may be described as causal; association, temporal precedence, production, and provenance fail closed. +- `temporal_core` interval-aware historical eligibility: `evaluate_historical_eligibility` admits an `AvailableTime` interval only when every possible availability instant is at or before `KnowledgeCutoff`; unknown and open-ended upper availability fail closed, and event/document time cannot be substituted. +- `event_core` TDT tracking contracts: hypothesized track assignments, fail-closed duplicate mentions, refusal to treat a track as an instance or state transition, and computed pair precision/recall, identity-switch rate, and RMSE against known-truth assignments. +- `event_core` CHRONOS schema-slot gate: predicted role fillers stay distinct from promoted instances and transitions, slot precision/recall are computed from known-truth fills, and production label/confidence APIs produce calibrated occupancy RMSE ≈ 0.1411 versus always-fill ≈ 0.7071 in the contract fixture. +- `event_core` TDT story-segmentation contracts: ordered unit partitions, fail-closed empty/mismatched windows, refusal to treat a detected story cut as an instance or state transition, and computed `WindowDiff`, `Pk`, boundary precision/recall, plus RMSE against known-truth boundaries. +- `event_core` CHRONOS occurrence-prediction calibration: forecasts stay hypothetical, refuse promotion to event instances, and recover a computed Brier score against later-observed occurrence truth, with empty or mismatched streams failing closed. +- `persistence_postgres` entity/project target SQL: fail-closed insert/lookup for `entity_record` and `project_record` so membership foreign keys cannot be seeded from empty, oversized, or hostile type/status labels. No new migration number (ADR 0003 / ADR 0013). +- `cutoff_clock` identity gate: event time, system time, and availability time cannot stand in for knowledge cutoff; recovered cutoff stamps match known truth at a higher computed rate than treating every stamp as availability time (ADR 0002). +- `assertion_clock` identity gate: event, system, document, and availability time cannot stand in for assertion time; recovered assertion stamps match known truth at a higher computed rate than treating every stamp as event time (ADR 0002). +- `event_clock` identity gate: assertion, system, document, and availability time cannot stand in for event/valid time; recovered event stamps match known truth at a higher computed rate than treating every stamp as assertion time (ADR 0002). +- `system_clock` identity gate: event, assertion, document, availability, and knowledge-cutoff time cannot stand in for system time; recovered system stamps match known truth at a higher computed rate than treating every stamp as event time (ADR 0002). +- `support_edge` identity gate: support, contradiction, summary, and `outcome_of` edges cannot become state transitions; recovered evidential kinds match known truth at a higher computed rate than collapsing every kind to support (ADR 0002/0003). +- `inferred_status` identity gate: inferred relations cannot be promoted to observed evidence or to state transitions; recovered observed/inferred labels match known truth at a higher computed rate than treating every status as observed (ADR 0003). +- `payload_bound` identity gate: documents, serialized records, model checkpoints, and LLM outputs stay untrusted until identity, provenance, size, and depth validate; recovered accept/reject flags match known truth at a higher computed rate than accepting every payload (ADR 0008/0013). +- `outcome_order` identity gate: `input_to` and `process_to` cannot move backward or stay contemporaneous in event-time rank; `outcome_of` may point at an earlier producer and cannot become a state transition; recovered kinds match known truth at a higher computed rate than collapsing every kind to `input_to` (ADR 0002/0003). +- `summarizes_edge` identity gate: a summary may point to earlier event time but cannot become a state transition or reuse the source document identity; recovered summary kinds match known truth at a higher computed rate than collapsing every summary to the source (ADR 0003). +- `provider_receipt` disclosure receipt: records provider field codes and + purpose-bound receipt metadata without persisting source text or source + identity (ADR 0009). +- `intake_authorization` identity gate: documents, serialized records, checkpoints, and LLM outputs cannot be accepted without a purpose-bound grant; size/identity/provenance bounds are not that grant; recovered grant-presence flags match known truth at a higher computed rate than accepting every intake (ADR 0009). +- `copy_identity` identity gate: a template or pasted copy cannot reuse the source document identity or become a state transition; recovered copy kinds match known truth at a higher computed rate than collapsing every copy to the source (ADR 0003). +- `stopword_deletion` method gate: a default or global stopword list cannot erase repeated report language; recovered deletion kinds match known truth at a higher computed rate than collapsing every token treatment to stopword deletion (ADR 0004/0012). +- `episode_membership` identity gate: a document's episode membership cannot start before or end after the episode event-time interval; recovered containment flags match known truth at a higher computed rate than accepting every membership (ADR 0003). +- `style_source` identity gate: house-voice style residue is not unique latent content and is not erased by a stopword list; recovered style kinds match known truth at a higher computed rate than collapsing every token to unique content (ADR 0004/0012). +- `modality_source` identity gate: non-lexical modality is not unique latent content and is not erased by a stopword list; recovered modality kinds match known truth at a higher computed rate than collapsing every token to unique content (ADR 0004/0012). +- `corpus_background` identity gate: corpus-level background wording is not unique latent content and is not erased by a stopword list; recovered background kinds match known truth at a higher computed rate than collapsing every token to unique content (ADR 0004/0012). +- `prompt_source` identity gate: instruction and prompt boilerplate is not unique latent content and is not erased by a stopword list; `identity_recovery_rate` reports exact kind matches, with a contract test comparing correct recovery with an all-unique collapse on a mixed known-truth fixture (ADR 0004/0012). +- `location_membership` identity gate: geographic and market assignments are time-varying memberships, not permanent entity identity and not language channels; recovered location kinds match known truth at a higher computed rate than collapsing every assignment to entity identity (ADR 0003). - `membership_target` identity gate: language, episode, template, department, and opportunity-pool memberships cannot collapse into the entity/project pair stored by migration `0006`; comparison-contract tests record recovered target kinds against an entity-collapse baseline (ADR 0003). - `tepp_api` naruon live loopback HTTP/1.1 listener: `serve_one` installs a read/write deadline, requires a loopback `Host`, refuses `Transfer-Encoding` and NIM/proxy credential headers, parses `knowledge_cutoff` as RFC 3339 and refuses a future cutoff, keys analysis-run idempotency by tenant plus key, and proves both analysis-run and export POSTs over a real `TcpStream`. Not a production TLS/`$PORT` service (ADR 0011). @@ -187,8 +212,12 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang head SHA to that exact-head register, including #201 `6afd650667e1` (RFC 5646 cited once; first GAP-005 slice, not implemented-main), stacked drafts #202–#204, and #164 `ff2e645b1785` as the predecessor register head. Duplicate - PR #179 remains closed. Stacked-merged heads and queued Checks are not - implemented-main. + PR #179 remains closed. Stacked-merged heads and queued Checks are not + implemented-main. +- Removed the completed one-shot PR #51 repair job from `docs-quality.yml`; the + workflow no longer invokes deleted repair scripts or requests write authority + after the executable compute implementation is already present. +- `persistence_postgres` entity and project target inserts now bind their tenant session context before rendering SQL, keeping `FORCE ROW LEVEL SECURITY` behavior consistent with every other tenant-scoped write; live coverage still proves raw wrong-tenant rejection. - Clarified ADR 0001 so it owns Rust-first numerical/reference-backend authority while ADR 0011 owns cross-service MSA/service authority. - Clarified ADR 0006 so it owns GPU/VRAM and model-credential boundaries; ADR 0010 now owns LLM orchestration policy and ADR 0015 owns autonomous repository-write/review/merge authority. - Expanded ADR 0002–0005 and 0009–0011 with explicit implementation maturity, alternatives, failure/recovery, compatibility/migration, verification, and rollback/supersession boundaries where they were previously implicit. diff --git a/Cargo.lock b/Cargo.lock index ea659c832..330696fc3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -191,6 +191,10 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +[[package]] +name = "compute_backend" +version = "0.1.0" + [[package]] name = "copied_text" version = "0.1.0" @@ -392,6 +396,10 @@ dependencies = [ "sha2", ] +[[package]] +name = "episode_membership" +version = "0.1.0" + [[package]] name = "equivalent" version = "1.0.2" diff --git a/Cargo.toml b/Cargo.toml index abe2cf869..8586f16f6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,8 +49,9 @@ members = [ "crates/interpretation_gateway", "crates/model_selection", "crates/checkpoint_authority", + "crates/compute_backend", + "crates/episode_membership", "crates/membership_target", - ] default-members = [ "crates/evidence_core", @@ -101,8 +102,9 @@ default-members = [ "crates/interpretation_gateway", "crates/model_selection", "crates/checkpoint_authority", + "crates/compute_backend", + "crates/episode_membership", "crates/membership_target", - ] [workspace.package] diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index c46900c38..7bf758d83 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -29,6 +29,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | Foundation validation ledger | [`docs/validation/temporal-event-foundation.md`](docs/validation/temporal-event-foundation.md) | | Standards and APA 7 literature | [`docs/research/standards-and-literature.md`](docs/research/standards-and-literature.md) | | Operational log / source-separation doctoring | [`docs/research/operational-log-source-separation.md`](docs/research/operational-log-source-separation.md) | +| Interval cutoff eligibility doctoring | [`docs/research/interval-cutoff-eligibility.md`](docs/research/interval-cutoff-eligibility.md) | | Governance | [`GOVERNANCE.md`](GOVERNANCE.md) | | Agent development rules | [`AGENTS.md`](AGENTS.md) | | Agent context | [`CLAUDE.md`](CLAUDE.md) | @@ -37,6 +38,15 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | Actions fleet research doctoring | [`docs/research/actions-workflow-fleet.md`](docs/research/actions-workflow-fleet.md) | | Mention-confidence Brier doctoring | [`docs/research/mention-confidence-brier.md`](docs/research/mention-confidence-brier.md) | | Event-intelligence status-gate doctoring | [`docs/research/event-intelligence-status-gates.md`](docs/research/event-intelligence-status-gates.md) | +| VRAM budget / GPU fallback doctoring | [`docs/research/vram-budget-types.md`](docs/research/vram-budget-types.md) | +| Causal-identification gate doctoring | [`docs/research/causal-identification-gate.md`](docs/research/causal-identification-gate.md) | +| TDT story-segmentation `WindowDiff`/`Pk` doctoring | [`docs/research/tdt-story-segmentation.md`](docs/research/tdt-story-segmentation.md) | +| CHRONOS prediction-calibration doctoring | [`docs/research/chronos-prediction-calibration.md`](docs/research/chronos-prediction-calibration.md) | +| CHRONOS schema-slot calibration doctoring | [`docs/research/chronos-schema-slot-calibration.md`](docs/research/chronos-schema-slot-calibration.md) | +| Event-tracking calibration doctoring | [`docs/research/event-tracking-calibration.md`](docs/research/event-tracking-calibration.md) | +| Episode-membership identity doctoring | [`docs/research/episode-membership-identity.md`](docs/research/episode-membership-identity.md) | +| Entity/project target SQL doctoring | [`docs/research/entity-project-sql.md`](docs/research/entity-project-sql.md) | +| Scientific claim-promotion gate doctoring | [`docs/research/scientific-claim-promotion-gates.md`](docs/research/scientific-claim-promotion-gates.md) | | Retention/deletion/legal-hold doctoring | [`docs/research/retention-deletion-legal-hold.md`](docs/research/retention-deletion-legal-hold.md) | | Stopword-deletion doctoring | [`docs/research/stopword-deletion.md`](docs/research/stopword-deletion.md) | | Provider-payload minimization doctoring | [`docs/research/provider-payload-minimization.md`](docs/research/provider-payload-minimization.md) | @@ -66,5 +76,81 @@ The documentation graph is **design-sufficient** when a reviewer can reconstruct It is **protected-main-sufficient** only after the canonical documents are integrated on protected `main`, remain semantically current with live code, and their required exact-head documentation/security/review gates pass. An active documentation PR can therefore be design-sufficient while the protected branch remains documentation-insufficient. -At the time of this review, immutable evidence records/exact spans, the Rust workspace quality foundation, typed six-clock values/uncertain intervals (PR #8), Allen interval algebra and bounded path-consistency (PR #9), event ontology/membership, and PostgreSQL persistence through restore-integrity probes are implemented-main. The active-PR coverage gate in `prediction_contradiction` requires observed Allen coverage (`during`, `starts`, `finishes`, or `equals`) before unmatched predicted mass may be authorized for promotion; `refuse_promotion` is that authority and is not a contradiction-only filter. Coverage may authorize promotion; it does not convert a forecast into observed fact. Drafts #93, #94, #97, #101, #102, #104, #108, #109, #111, and #112 are superseded non-landable lineage. Remaining TDT/CHRONOS tasks, shared-latent topic estimation, GPU kernels, longitudinal ESEM/DSEM, visual analytics, production HTTP services, and deployment assurance stay accepted-target or deployment-owned. -As of 2026-08-19, protected `main` at `7c29e7c971d7940e1fb3def1ed3aae2d1bc8ad4a`, immutable evidence records/exact spans, the Rust workspace quality foundation, typed six-clock values/uncertain intervals (merged PR #8), and Allen interval algebra/bounded path-consistency (merged PR #9) are implemented-main. Superseded PRs #5 and #6 remain historical lineage only and are not current-product claims. Shared-latent topic estimation, GPU kernels, TDT/CHRONOS intelligence, longitudinal ESEM/DSEM, visual analytics, production HTTP services, and deployment assurance remain later accepted-target or deployment-owned work unless a [`docs/TRACEABILITY.md`](docs/TRACEABILITY.md) row records a narrower implemented-main or partial subset. Unmerged or draft PRs are not implemented-main claims. +At the time of this review, protected `main` at `7c29e7c971d7940e1fb3def1ed3aae2d1bc8ad4a` implements immutable evidence records/exact spans, the Rust workspace quality foundation, typed six-clock values/uncertain intervals (merged PR #8), Allen interval algebra and bounded path-consistency (merged PR #9), event ontology/membership, and PostgreSQL persistence through restore-integrity probes. Superseded PRs #5 and #6 remain historical lineage only, and drafts #93, #94, #97, #101, #102, #104, #108, #109, #111, and #112 are superseded non-landable lineage; unmerged or draft PRs are never implemented-main claims. The active-PR `prediction_contradiction` coverage gate requires observed Allen coverage (`during`, `starts`, `finishes`, or `equals`) before unmatched predicted mass may be authorized for promotion; `refuse_promotion` is that authority and not a contradiction-only filter — coverage may authorize promotion but does not convert a forecast into observed fact. Remaining TDT/CHRONOS intelligence, shared-latent topic estimation, GPU kernels, longitudinal ESEM/DSEM, visual analytics, production HTTP services, and deployment assurance stay accepted-target or deployment-owned unless a [`docs/TRACEABILITY.md`](docs/TRACEABILITY.md) row records a narrower implemented-main or partial subset. +# TEPP Documentation Map + +TEPP's approved PRD v0.4 and implementation plan are the primary product baseline. This index makes the technical, data, scientific, security/privacy, integration, quality, operating, and assurance contracts discoverable without duplicating that source material. + +| Area | Canonical document | +|---|---| +| Approved product requirements | [`docs/product/prd-v0.4-approved.md`](docs/product/prd-v0.4-approved.md) | +| Live product and technical gap baseline | [`docs/product-technical-gap-baseline.md`](docs/product-technical-gap-baseline.md) | +| Whole-conversation documentation fitness | [`docs/DOCUMENTATION_ASSESSMENT.md`](docs/DOCUMENTATION_ASSESSMENT.md) | +| Technical requirements | [`docs/TRD.md`](docs/TRD.md) | +| Architecture | [`ARCHITECTURE.md`](ARCHITECTURE.md) | +| Modular/API integration contract | [`docs/API_CONTRACT.md`](docs/API_CONTRACT.md) | +| naruon modular consumer contract | [`docs/connectors/naruon-artifact-consumer.md`](docs/connectors/naruon-artifact-consumer.md) | +| contextual-orchestrator interpretation port | [`docs/connectors/contextual-orchestrator-interpretation-port.md`](docs/connectors/contextual-orchestrator-interpretation-port.md) | +| UML/runtime/scientific flows | [`docs/UML.md`](docs/UML.md) | +| Logical/physical ERD | [`docs/ERD.md`](docs/ERD.md) | +| Security policy | [`SECURITY.md`](SECURITY.md) | +| Threat model | [`docs/THREAT_MODEL.md`](docs/THREAT_MODEL.md) | +| Privacy and data governance | [`docs/PRIVACY_DATA_GOVERNANCE.md`](docs/PRIVACY_DATA_GOVERNANCE.md) | +| Compliance/assurance readiness | [`docs/COMPLIANCE_READINESS.md`](docs/COMPLIANCE_READINESS.md) | +| LLM orchestration/test-time compute | [`docs/LLM_ORCHESTRATION.md`](docs/LLM_ORCHESTRATION.md) | +| Test/scientific validation strategy | [`docs/TEST_STRATEGY.md`](docs/TEST_STRATEGY.md) | +| Operability/recovery/release | [`docs/OPERABILITY.md`](docs/OPERABILITY.md) | +| Requirement/research/evidence traceability | [`docs/TRACEABILITY.md`](docs/TRACEABILITY.md) | +| Architecture decision index / ownership map | [`docs/adr/README.md`](docs/adr/README.md) | +| ADR status, maturity, and supersession policy | [`docs/adr/ADR_POLICY.md`](docs/adr/ADR_POLICY.md) | +| Delivery roadmap | [`docs/roadmaps/2026-08-05-tepp-delivery-roadmap.md`](docs/roadmaps/2026-08-05-tepp-delivery-roadmap.md) | +| Foundation implementation plan | [`docs/superpowers/plans/2026-08-05-temporal-event-foundation.md`](docs/superpowers/plans/2026-08-05-temporal-event-foundation.md) | +| Foundation validation ledger | [`docs/validation/temporal-event-foundation.md`](docs/validation/temporal-event-foundation.md) | +| Standards and APA 7 literature | [`docs/research/standards-and-literature.md`](docs/research/standards-and-literature.md) | +| Operational log / source-separation doctoring | [`docs/research/operational-log-source-separation.md`](docs/research/operational-log-source-separation.md) | +| Interval cutoff eligibility doctoring | [`docs/research/interval-cutoff-eligibility.md`](docs/research/interval-cutoff-eligibility.md) | +| Governance | [`GOVERNANCE.md`](GOVERNANCE.md) | +| Agent development rules | [`AGENTS.md`](AGENTS.md) | +| Agent context | [`CLAUDE.md`](CLAUDE.md) | +| Hourly NIM product-development operations | [`docs/operations/HOURLY_NIM_PRODUCT_DEVELOPMENT.md`](docs/operations/HOURLY_NIM_PRODUCT_DEVELOPMENT.md) | +| Actions workflow fleet audit | [`docs/operations/ACTIONS_WORKFLOW_FLEET.md`](docs/operations/ACTIONS_WORKFLOW_FLEET.md) | +| Actions fleet research doctoring | [`docs/research/actions-workflow-fleet.md`](docs/research/actions-workflow-fleet.md) | +| Mention-confidence Brier doctoring | [`docs/research/mention-confidence-brier.md`](docs/research/mention-confidence-brier.md) | +| Event-intelligence status-gate doctoring | [`docs/research/event-intelligence-status-gates.md`](docs/research/event-intelligence-status-gates.md) | +| VRAM budget / GPU fallback doctoring | [`docs/research/vram-budget-types.md`](docs/research/vram-budget-types.md) | +| Causal-identification gate doctoring | [`docs/research/causal-identification-gate.md`](docs/research/causal-identification-gate.md) | +| TDT story-segmentation `WindowDiff`/`Pk` doctoring | [`docs/research/tdt-story-segmentation.md`](docs/research/tdt-story-segmentation.md) | +| CHRONOS prediction-calibration doctoring | [`docs/research/chronos-prediction-calibration.md`](docs/research/chronos-prediction-calibration.md) | +| CHRONOS schema-slot calibration doctoring | [`docs/research/chronos-schema-slot-calibration.md`](docs/research/chronos-schema-slot-calibration.md) | +| Event-tracking calibration doctoring | [`docs/research/event-tracking-calibration.md`](docs/research/event-tracking-calibration.md) | +| Episode-membership identity doctoring | [`docs/research/episode-membership-identity.md`](docs/research/episode-membership-identity.md) | +| Entity/project target SQL doctoring | [`docs/research/entity-project-sql.md`](docs/research/entity-project-sql.md) | +| Scientific claim-promotion gate doctoring | [`docs/research/scientific-claim-promotion-gates.md`](docs/research/scientific-claim-promotion-gates.md) | +| Retention/deletion/legal-hold doctoring | [`docs/research/retention-deletion-legal-hold.md`](docs/research/retention-deletion-legal-hold.md) | +| Stopword-deletion doctoring | [`docs/research/stopword-deletion.md`](docs/research/stopword-deletion.md) | +| Provider-payload minimization doctoring | [`docs/research/provider-payload-minimization.md`](docs/research/provider-payload-minimization.md) | +| Adaptive orchestration router doctoring | [`docs/research/adaptive-orchestration-router.md`](docs/research/adaptive-orchestration-router.md) | +| Hourly NIM OpenCode doctoring | [`docs/doctoring/hourly-nim-opencode-development.md`](docs/doctoring/hourly-nim-opencode-development.md) | +| Corpus-split leakage-audit wire doctoring | [`docs/research/corpus-split-manifest-wire.md`](docs/research/corpus-split-manifest-wire.md) | +| Unicode canonical-identity doctoring | [`docs/research/unicode-canonical-identity.md`](docs/research/unicode-canonical-identity.md) | +| Change history | [`CHANGELOG.md`](CHANGELOG.md) | + +## Maturity vocabulary + +The canonical implementation-maturity vocabulary is defined in [`docs/adr/ADR_POLICY.md`](docs/adr/ADR_POLICY.md) and promotion evidence in [`docs/TRACEABILITY.md`](docs/TRACEABILITY.md). In particular, **an ADR with decision status `Accepted` is not automatically implemented or shipped.** + +- **implemented-main** — source is integrated on protected `main` and the relevant exact-current-head tests, scientific/recovery/validation evidence, security and supply-chain gates, and qualifying review required by live policy pass. +- **active-PR** — implementation exists only on an open PR and is not a protected-main claim. +- **partial** — an explicitly identified subset is implemented on protected main while the rest remains target work. +- **accepted-target** — accepted PRD/ADR architecture not yet integrated. +- **research-only** — evaluated research direction not accepted as production behavior. +- **out-of-scope** — explicitly outside TEPP ownership. +- **conceptual** — logical entity/service/model contract; not evidence of a migration or deployment. +- **deployment-owned** — evidence depends on a concrete deployed environment or organization and cannot be claimed by repository design alone. +- **external-assurance** — certification, attestation, legal opinion, or other independent assessment that TEPP cannot self-issue. + +## Documentation fitness + +The documentation graph is **design-sufficient** when a reviewer can reconstruct TEPP's product requirements, technical/scientific estimands, authority boundaries, temporal/event/membership semantics, data model, failure modes, security/privacy controls, validation strategy, API/integration contract, operability, research basis, ADR ownership/supersession, and release acceptance without chat history. + +It is **protected-main-sufficient** only after the canonical documents are integrated on protected `main`, remain semantically current with live code, and their required exact-head documentation/security/review gates pass. An active documentation PR can therefore be design-sufficient while the protected branch remains documentation-insufficient. diff --git a/README.md b/README.md index 93952bdc8..fbed41ea6 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,18 @@ implemented in Rust. ## Current implementation state +The current workspace contains 50 independently documented Rust crates. Each +crate exposes a bounded, tested contract for evidence, temporal semantics, +event and relation reasoning, membership, persistence, simulation, validation, +API exchange, compute planning, or evidence-grounded interpretation. Numerical +and psychometric authority remains on the CPU `f64` reference path; streamed +accelerator plans must preserve the full observation set and fail closed to the +reference path when resources or validation are insufficient. + +These are production contracts, not a claim that the complete commercial +estimator, operator workspace, or supported release already exists. Read the +[product and technical gap baseline](docs/product-technical-gap-baseline.md) +before treating a crate as a shipped product capability. This branch keeps the Rust workspace quality foundation and the bounded foundation crates. Domain crates expose only tested contracts: immutable evidence, six-clock temporal values, event mentions/instances, relations, @@ -45,6 +57,7 @@ crates/corpus_split crates/tepp_simulation crates/validation_core crates/tepp_api +crates/location_membership crates/prompt_source crates/corpus_background crates/modality_source @@ -72,7 +85,6 @@ crates/citation_edge crates/psychometric_fit crates/subevent_containment crates/prediction_contradiction -crates/provider_receipt crates/operational_log crates/service_tls crates/derived_sensitivity @@ -82,6 +94,8 @@ crates/network_analysis crates/interpretation_gateway crates/model_selection crates/checkpoint_authority +crates/compute_backend +crates/episode_membership crates/membership_target ``` diff --git a/crates/compute_backend/Cargo.toml b/crates/compute_backend/Cargo.toml new file mode 100644 index 000000000..503caaacb --- /dev/null +++ b/crates/compute_backend/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "compute_backend" +description = "VRAM-budgeted GPU planning with a CPU f64 reference and fail-closed OOM fallback." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true +readme.workspace = true +keywords.workspace = true +categories.workspace = true +publish = false + +[dependencies] + +[lints] +workspace = true diff --git a/crates/compute_backend/src/controller.rs b/crates/compute_backend/src/controller.rs new file mode 100644 index 000000000..3981fe449 --- /dev/null +++ b/crates/compute_backend/src/controller.rs @@ -0,0 +1,394 @@ +//! VRAM controller: reserve, predict, autotune, retry, and fall back. + +use crate::error::ComputeBackendError; +use crate::inventory::{DeviceInventory, SafetyReserve, VramBudget}; +use crate::plan::{ComputeBackendKind, FallbackReason, MicroBatchPlan, predicted_peak_bytes}; +use crate::request::{ + CorpusPlacement, CutoffPolicy, ModelComplexity, ObservationRetention, PrecisionMode, + WorkloadRequest, +}; + +/// Plans streamed work under a VRAM budget without changing the estimand. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct VramController { + inventory: DeviceInventory, + max_retries: u32, +} + +impl VramController { + /// Construct a controller with a bounded OOM retry budget. + /// + /// # Errors + /// + /// This constructor is currently infallible for valid inventories. It + /// returns [`Result`] so callers can share the crate error type. + pub const fn new( + inventory: DeviceInventory, + max_retries: u32, + ) -> Result { + Ok(Self { + inventory, + max_retries, + }) + } + + /// Return the reserved safety headroom. + #[must_use] + pub const fn safety_reserve(self) -> SafetyReserve { + self.inventory.safety_reserve() + } + + /// Return the usable VRAM budget. + #[must_use] + pub const fn budget(self) -> VramBudget { + self.inventory.budget() + } + + /// Return the bounded OOM retry budget. + #[must_use] + pub const fn max_retries(self) -> u32 { + self.max_retries + } + + /// Plan a micro-batch or CPU fallback without dropping observations. + /// + /// # Errors + /// + /// Returns a fail-closed [`ComputeBackendError`] when the caller requests a + /// forbidden memory adaptation or mixed-precision finals. An overflowing + /// peak prediction is treated as unable to fit and falls back to the CPU + /// reference plan. + pub fn plan(&self, request: &WorkloadRequest) -> Result { + Self::validate_request(request)?; + + if !self.inventory.device_present() { + return Ok(Self::cpu_plan( + request.requested_batch(), + 0, + FallbackReason::DeviceUnavailable, + )); + } + + if self.inventory.budget().usable_bytes() == 0 { + return Ok(Self::cpu_plan( + request.requested_batch(), + 0, + FallbackReason::InsufficientVram, + )); + } + + Ok(self.gpu_plan_or_cpu( + request, + request.requested_batch(), + 0, + FallbackReason::InsufficientVram, + )) + } + + /// Return the next executable plan after one observed device OOM. + /// + /// Each accepted retry halves the current micro-batch and recomputes its + /// peak estimate from the original workload. Once the configured retry + /// budget is exhausted, or a unit batch fails, the plan switches to the CPU + /// `f64` reference without dropping any observation. + /// + /// # Errors + /// + /// Returns [`ComputeBackendError::RetryBudgetExceeded`] when the supplied + /// plan is already on the CPU path, and validation/overflow errors for an + /// invalid workload or retry counter. + pub fn recover_from_oom( + &self, + request: &WorkloadRequest, + plan: &MicroBatchPlan, + ) -> Result { + Self::validate_request(request)?; + if plan.backend() != ComputeBackendKind::GpuStreamed { + return Err(ComputeBackendError::RetryBudgetExceeded); + } + let next_retry = plan + .oom_retry_count() + .checked_add(1) + .ok_or(ComputeBackendError::InvalidBudget)?; + if next_retry <= self.max_retries && plan.batch_size() > 1 { + return Ok(self.gpu_plan_or_cpu( + request, + plan.batch_size() / 2, + next_retry, + FallbackReason::OutOfMemoryRetryExhausted, + )); + } + Ok(Self::cpu_plan( + request.requested_batch(), + next_retry, + FallbackReason::OutOfMemoryRetryExhausted, + )) + } + + fn validate_request(request: &WorkloadRequest) -> Result<(), ComputeBackendError> { + if request.corpus_placement() == CorpusPlacement::FullCorpusOnDevice { + return Err(ComputeBackendError::FullCorpusTensorRefused); + } + if request.observation_retention() == ObservationRetention::DropToFit { + return Err(ComputeBackendError::ObservationDropForbidden); + } + if request.model_complexity() == ModelComplexity::ReduceToFit { + return Err(ComputeBackendError::ComplexityReductionForbidden); + } + if request.cutoff_policy() == CutoffPolicy::MoveToFit { + return Err(ComputeBackendError::CutoffMutationForbidden); + } + if request.final_quantity_precision() != PrecisionMode::ReferenceF64 { + return Err(ComputeBackendError::UnsupportedPrecision); + } + Ok(()) + } + + fn gpu_plan_or_cpu( + &self, + request: &WorkloadRequest, + mut batch: u32, + oom_retry_count: u32, + fallback_reason: FallbackReason, + ) -> MicroBatchPlan { + loop { + if let Some(plan) = self.gpu_plan_if_fits(request, batch, oom_retry_count) { + return plan; + } + if batch == 1 { + return Self::cpu_plan(request.requested_batch(), oom_retry_count, fallback_reason); + } + batch /= 2; + } + } + + fn gpu_plan_if_fits( + &self, + request: &WorkloadRequest, + batch: u32, + oom_retry_count: u32, + ) -> Option { + let peak = predicted_peak_bytes( + batch, + request.bytes_per_observation(), + request.working_set_bytes(), + ) + .ok()?; + if peak > self.inventory.budget().usable_bytes() { + return None; + } + Some(MicroBatchPlan::new( + ComputeBackendKind::GpuStreamed, + batch, + peak, + PrecisionMode::ReferenceF64, + oom_retry_count, + None, + )) + } + + const fn cpu_plan( + batch_size: u32, + oom_retry_count: u32, + reason: FallbackReason, + ) -> MicroBatchPlan { + MicroBatchPlan::new( + ComputeBackendKind::CpuF64Reference, + batch_size, + 0, + PrecisionMode::ReferenceF64, + oom_retry_count, + Some(reason), + ) + } +} + +#[cfg(test)] +mod tests { + use super::VramController; + use crate::error::ComputeBackendError; + use crate::inventory::DeviceInventory; + use crate::plan::{ComputeBackendKind, FallbackReason, MicroBatchPlan}; + use crate::profile::VramProfile; + use crate::request::{ + AdaptationPolicy, CorpusPlacement, CutoffPolicy, ModelComplexity, ObservationRetention, + PrecisionMode, WorkloadRequest, + }; + + fn request(batch: u32, bytes_per_observation: u64) -> WorkloadRequest { + WorkloadRequest::new( + 4, + 2, + bytes_per_observation, + 8, + batch, + AdaptationPolicy { + corpus_placement: CorpusPlacement::StreamedMicroBatches, + observation_retention: ObservationRetention::KeepAll, + model_complexity: ModelComplexity::KeepSpecified, + cutoff_policy: CutoffPolicy::KeepCutoff, + }, + PrecisionMode::ReferenceF64, + ) + .expect("valid") + } + + #[test] + fn cpu_only_and_unusable_vram_fall_back() { + let cpu = VramController::new(DeviceInventory::cpu_only(VramProfile::Gib4), 1) + .expect("cpu controller"); + assert_eq!(cpu.max_retries(), 1); + assert_eq!( + cpu.safety_reserve().bytes(), + VramProfile::Gib4.safety_bytes() + ); + assert_eq!(cpu.budget().usable_bytes(), 0); + let planned = cpu.plan(&request(4, 8)).expect("cpu plan"); + assert_eq!(planned.backend(), ComputeBackendKind::CpuF64Reference); + assert_eq!(planned.fallback(), Some(FallbackReason::DeviceUnavailable)); + assert_eq!( + cpu.recover_from_oom(&request(4, 8), &planned), + Err(ComputeBackendError::RetryBudgetExceeded) + ); + + let tight = DeviceInventory::gpu(VramProfile::Gib4, VramProfile::Gib4.safety_bytes()) + .expect("tight"); + let controller = VramController::new(tight, 0).expect("tight controller"); + let planned = controller.plan(&request(2, 8)).expect("unusable"); + assert_eq!(planned.fallback(), Some(FallbackReason::InsufficientVram)); + } + + #[test] + fn unit_batch_that_still_exceeds_usable_vram_falls_back() { + let available = VramProfile::Gib4.safety_bytes() + 16; + let inventory = DeviceInventory::gpu(VramProfile::Gib4, available).expect("small usable"); + let controller = VramController::new(inventory, 1).expect("controller"); + let planned = controller.plan(&request(8, 64)).expect("fallback"); + assert_eq!(planned.backend(), ComputeBackendKind::CpuF64Reference); + assert_eq!(planned.fallback(), Some(FallbackReason::InsufficientVram)); + assert_eq!(planned.batch_size(), 8); + assert_eq!(planned.precision(), PrecisionMode::ReferenceF64); + assert_eq!(planned.predicted_peak_bytes(), 0); + assert_eq!(planned.oom_retry_count(), 0); + } + + #[test] + fn overflowing_peak_falls_back_to_cpu() { + let inventory = + DeviceInventory::gpu(VramProfile::Gib24, VramProfile::Gib24.bytes()).expect("24"); + let controller = VramController::new(inventory, 1).expect("controller"); + let huge = WorkloadRequest::new( + 1, + 1, + u64::MAX, + u64::MAX, + 2, + AdaptationPolicy { + corpus_placement: CorpusPlacement::StreamedMicroBatches, + observation_retention: ObservationRetention::KeepAll, + model_complexity: ModelComplexity::KeepSpecified, + cutoff_policy: CutoffPolicy::KeepCutoff, + }, + PrecisionMode::ReferenceF64, + ) + .expect("request"); + let plan = controller.plan(&huge).expect("overflow falls back"); + assert_eq!(plan.backend(), ComputeBackendKind::CpuF64Reference); + assert_eq!(plan.fallback(), Some(FallbackReason::InsufficientVram)); + } + + #[test] + fn overflowing_oom_retry_peak_falls_back_to_cpu() { + let inventory = + DeviceInventory::gpu(VramProfile::Gib24, VramProfile::Gib24.bytes()).expect("24"); + let controller = VramController::new(inventory, 1).expect("controller"); + let huge = WorkloadRequest::new( + 1, + 1, + u64::MAX, + u64::MAX, + 2, + AdaptationPolicy { + corpus_placement: CorpusPlacement::StreamedMicroBatches, + observation_retention: ObservationRetention::KeepAll, + model_complexity: ModelComplexity::KeepSpecified, + cutoff_policy: CutoffPolicy::KeepCutoff, + }, + PrecisionMode::ReferenceF64, + ) + .expect("request"); + let initial = MicroBatchPlan::new( + ComputeBackendKind::GpuStreamed, + 2, + 0, + PrecisionMode::ReferenceF64, + 0, + None, + ); + let plan = controller + .recover_from_oom(&huge, &initial) + .expect("overflow falls back"); + assert_eq!(plan.backend(), ComputeBackendKind::CpuF64Reference); + assert_eq!( + plan.fallback(), + Some(FallbackReason::OutOfMemoryRetryExhausted) + ); + } + + #[test] + fn oom_recovery_emits_retries_then_falls_back() { + let inventory = + DeviceInventory::gpu(VramProfile::Gib12, VramProfile::Gib12.bytes()).expect("12"); + let controller = VramController::new(inventory, 1).expect("controller"); + let workload = request(4, 8); + let initial = controller.plan(&workload).expect("gpu"); + let retry = controller + .recover_from_oom(&workload, &initial) + .expect("retry"); + assert_eq!(retry.backend(), ComputeBackendKind::GpuStreamed); + assert_eq!(retry.batch_size(), 2); + assert_eq!(retry.oom_retry_count(), 1); + let fallback = controller + .recover_from_oom(&workload, &retry) + .expect("fallback"); + assert_eq!(fallback.backend(), ComputeBackendKind::CpuF64Reference); + // CPU `f64` fallback restores the requested batch; it is not a GPU retry plan. + assert_eq!(fallback.batch_size(), 4); + assert_eq!(fallback.oom_retry_count(), 2); + + let zero_retry = VramController::new(inventory, 0).expect("zero retry"); + let immediate = zero_retry + .recover_from_oom(&workload, &initial) + .expect("immediate fallback"); + assert_eq!(immediate.backend(), ComputeBackendKind::CpuF64Reference); + assert_eq!(immediate.oom_retry_count(), 1); + + let unit_workload = request(1, 8); + let unit_plan = controller.plan(&unit_workload).expect("unit gpu"); + let unit_fallback = controller + .recover_from_oom(&unit_workload, &unit_plan) + .expect("unit fallback"); + assert_eq!(unit_fallback.backend(), ComputeBackendKind::CpuF64Reference); + assert_eq!(unit_fallback.batch_size(), 1); + } + + #[test] + fn overflowing_retry_counter_fails_closed() { + let inventory = + DeviceInventory::gpu(VramProfile::Gib12, VramProfile::Gib12.bytes()).expect("12"); + let controller = VramController::new(inventory, u32::MAX).expect("controller"); + let workload = request(4, 8); + let invalid = MicroBatchPlan::new( + ComputeBackendKind::GpuStreamed, + 4, + 40, + PrecisionMode::ReferenceF64, + u32::MAX, + None, + ); + assert_eq!( + controller.recover_from_oom(&workload, &invalid), + Err(ComputeBackendError::InvalidBudget) + ); + } +} diff --git a/crates/compute_backend/src/error.rs b/crates/compute_backend/src/error.rs new file mode 100644 index 000000000..5db3b3e50 --- /dev/null +++ b/crates/compute_backend/src/error.rs @@ -0,0 +1,181 @@ +//! Fail-closed VRAM and compute-backend errors. + +use std::fmt; + +/// A fail-closed compute-backend error. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum ComputeBackendError { + /// Device allocation failed. This is an expected operating state. + OutOfMemory, + /// The accelerator disappeared after planning. + DeviceLoss, + /// A reference or diagnostic quantity was non-finite. + NonFiniteOutput, + /// CPU `f64` and candidate outputs diverged beyond tolerance. + ParityFailure, + /// A parity tolerance was negative. + InvalidTolerance, + /// Mixed precision was requested for a final diagnostic quantity. + UnsupportedPrecision, + /// A claimed accelerator could not be initialized. + BackendInitFailure, + /// A full document-by-topic tensor was requested on device memory. + FullCorpusTensorRefused, + /// Observations would be dropped to fit memory. + ObservationDropForbidden, + /// Topic or model complexity would be reduced to fit memory. + ComplexityReductionForbidden, + /// A knowledge cutoff would change to fit memory. + CutoffMutationForbidden, + /// A budget, inventory, or workload field was empty or overflowed. + InvalidBudget, + /// Telemetry attempted to carry raw source text. + SourceTextInTelemetry, + /// Further OOM retries were requested after the bounded budget. + RetryBudgetExceeded, +} + +impl ComputeBackendError { + /// Return whether the error is a tested operating state rather than a bug. + #[must_use] + pub const fn is_expected_operating_state(self) -> bool { + matches!(self, Self::OutOfMemory) + } +} + +impl fmt::Display for ComputeBackendError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let message = match self { + Self::OutOfMemory => "device out of memory", + Self::DeviceLoss => "compute device lost", + Self::NonFiniteOutput => "non-finite compute output", + Self::ParityFailure => "cpu gpu parity failure", + Self::InvalidTolerance => "invalid parity tolerance", + Self::UnsupportedPrecision => "mixed precision cannot finalize diagnostics", + Self::BackendInitFailure => "compute backend initialization failed", + Self::FullCorpusTensorRefused => "full-corpus device tensor is refused", + Self::ObservationDropForbidden => "observations cannot be dropped to fit memory", + Self::ComplexityReductionForbidden => { + "model complexity cannot be reduced to fit memory" + } + Self::CutoffMutationForbidden => "knowledge cutoff cannot change to fit memory", + Self::InvalidBudget => "invalid compute budget", + Self::SourceTextInTelemetry => "telemetry cannot carry source text", + Self::RetryBudgetExceeded => "oom retry budget exceeded", + }; + formatter.write_str(message) + } +} + +impl std::error::Error for ComputeBackendError {} + +/// Return the typed out-of-memory operating state. +#[must_use] +pub const fn report_out_of_memory() -> ComputeBackendError { + ComputeBackendError::OutOfMemory +} + +/// Return the typed device-loss failure. +#[must_use] +pub const fn report_device_loss() -> ComputeBackendError { + ComputeBackendError::DeviceLoss +} + +/// Return the typed backend-initialization failure. +#[must_use] +pub const fn refuse_uninitialized_backend() -> ComputeBackendError { + ComputeBackendError::BackendInitFailure +} + +#[cfg(test)] +mod tests { + use super::{ + ComputeBackendError, refuse_uninitialized_backend, report_device_loss, report_out_of_memory, + }; + + #[test] + fn messages_and_operating_states_are_stable() { + for (error, message, expected) in [ + ( + ComputeBackendError::OutOfMemory, + "device out of memory", + true, + ), + ( + ComputeBackendError::DeviceLoss, + "compute device lost", + false, + ), + ( + ComputeBackendError::NonFiniteOutput, + "non-finite compute output", + false, + ), + ( + ComputeBackendError::ParityFailure, + "cpu gpu parity failure", + false, + ), + ( + ComputeBackendError::InvalidTolerance, + "invalid parity tolerance", + false, + ), + ( + ComputeBackendError::UnsupportedPrecision, + "mixed precision cannot finalize diagnostics", + false, + ), + ( + ComputeBackendError::BackendInitFailure, + "compute backend initialization failed", + false, + ), + ( + ComputeBackendError::FullCorpusTensorRefused, + "full-corpus device tensor is refused", + false, + ), + ( + ComputeBackendError::ObservationDropForbidden, + "observations cannot be dropped to fit memory", + false, + ), + ( + ComputeBackendError::ComplexityReductionForbidden, + "model complexity cannot be reduced to fit memory", + false, + ), + ( + ComputeBackendError::CutoffMutationForbidden, + "knowledge cutoff cannot change to fit memory", + false, + ), + ( + ComputeBackendError::InvalidBudget, + "invalid compute budget", + false, + ), + ( + ComputeBackendError::SourceTextInTelemetry, + "telemetry cannot carry source text", + false, + ), + ( + ComputeBackendError::RetryBudgetExceeded, + "oom retry budget exceeded", + false, + ), + ] { + assert_eq!(error.to_string(), message); + assert_eq!(error.is_expected_operating_state(), expected); + } + assert_eq!(report_out_of_memory(), ComputeBackendError::OutOfMemory); + assert_eq!(report_device_loss(), ComputeBackendError::DeviceLoss); + assert_eq!( + refuse_uninitialized_backend(), + ComputeBackendError::BackendInitFailure + ); + } +} diff --git a/crates/compute_backend/src/inventory.rs b/crates/compute_backend/src/inventory.rs new file mode 100644 index 000000000..3e5ead731 --- /dev/null +++ b/crates/compute_backend/src/inventory.rs @@ -0,0 +1,187 @@ +//! Measured device inventory and usable VRAM budget. + +use crate::error::ComputeBackendError; +use crate::profile::VramProfile; + +/// Observed accelerator inventory for one planning decision. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct DeviceInventory { + profile: VramProfile, + available_bytes: u64, + device_present: bool, +} + +/// Reserved bytes that working tensors must not consume. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct SafetyReserve { + bytes: u64, +} + +/// Usable VRAM remaining after the safety reserve. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct VramBudget { + profile: VramProfile, + available_bytes: u64, + safety_bytes: u64, + usable_bytes: u64, +} + +impl DeviceInventory { + /// Construct a present GPU inventory that cannot exceed its profile. + /// + /// # Errors + /// + /// Returns [`ComputeBackendError::InvalidBudget`] when `available_bytes` is + /// zero or larger than the profile capacity. + pub const fn gpu( + profile: VramProfile, + available_bytes: u64, + ) -> Result { + if available_bytes == 0 || available_bytes > profile.bytes() { + return Err(ComputeBackendError::InvalidBudget); + } + Ok(Self { + profile, + available_bytes, + device_present: true, + }) + } + + /// Construct a CPU-only inventory with no accelerator. + #[must_use] + pub const fn cpu_only(profile: VramProfile) -> Self { + Self { + profile, + available_bytes: 0, + device_present: false, + } + } + + /// Return the governing profile. + #[must_use] + pub const fn profile(self) -> VramProfile { + self.profile + } + + /// Return currently free device bytes. + #[must_use] + pub const fn available_bytes(self) -> u64 { + self.available_bytes + } + + /// Return whether an accelerator is present. + #[must_use] + pub const fn device_present(self) -> bool { + self.device_present + } + + /// Return the reserved safety headroom. + #[must_use] + pub const fn safety_reserve(self) -> SafetyReserve { + SafetyReserve { + bytes: self.profile.safety_bytes(), + } + } + + /// Return the usable budget after reserving safety memory. + #[must_use] + pub const fn budget(self) -> VramBudget { + let safety_bytes = self.profile.safety_bytes(); + let usable_bytes = if self.device_present && self.available_bytes > safety_bytes { + self.available_bytes - safety_bytes + } else { + 0 + }; + VramBudget { + profile: self.profile, + available_bytes: self.available_bytes, + safety_bytes, + usable_bytes, + } + } +} + +impl SafetyReserve { + /// Return reserved bytes. + #[must_use] + pub const fn bytes(self) -> u64 { + self.bytes + } +} + +impl VramBudget { + /// Return the governing profile. + #[must_use] + pub const fn profile(self) -> VramProfile { + self.profile + } + + /// Return observed free bytes. + #[must_use] + pub const fn available_bytes(self) -> u64 { + self.available_bytes + } + + /// Return reserved safety bytes. + #[must_use] + pub const fn safety_bytes(self) -> u64 { + self.safety_bytes + } + + /// Return bytes available for working tensors. + #[must_use] + pub const fn usable_bytes(self) -> u64 { + self.usable_bytes + } +} + +#[cfg(test)] +mod tests { + use super::DeviceInventory; + use crate::error::ComputeBackendError; + use crate::profile::VramProfile; + + #[test] + fn gpu_inventory_rejects_empty_and_oversize_availability() { + assert_eq!( + DeviceInventory::gpu(VramProfile::Gib4, 0), + Err(ComputeBackendError::InvalidBudget) + ); + assert_eq!( + DeviceInventory::gpu(VramProfile::Gib4, VramProfile::Gib4.bytes() + 1), + Err(ComputeBackendError::InvalidBudget) + ); + let inventory = + DeviceInventory::gpu(VramProfile::Gib4, VramProfile::Gib4.bytes()).expect("full 4 GiB"); + assert!(inventory.device_present()); + assert_eq!(inventory.profile(), VramProfile::Gib4); + assert_eq!(inventory.available_bytes(), VramProfile::Gib4.bytes()); + assert_eq!( + inventory.safety_reserve().bytes(), + VramProfile::Gib4.safety_bytes() + ); + let budget = inventory.budget(); + assert_eq!(budget.profile(), VramProfile::Gib4); + assert_eq!(budget.available_bytes(), VramProfile::Gib4.bytes()); + assert_eq!(budget.safety_bytes(), VramProfile::Gib4.safety_bytes()); + assert_eq!( + budget.usable_bytes(), + VramProfile::Gib4.bytes() - VramProfile::Gib4.safety_bytes() + ); + } + + #[test] + fn cpu_only_inventory_has_zero_usable_bytes() { + let inventory = DeviceInventory::cpu_only(VramProfile::Gib8); + assert!(!inventory.device_present()); + assert_eq!(inventory.available_bytes(), 0); + assert_eq!(inventory.budget().usable_bytes(), 0); + } + + #[test] + fn availability_at_or_below_reserve_is_unusable() { + let reserve = VramProfile::Gib6.safety_bytes(); + let inventory = DeviceInventory::gpu(VramProfile::Gib6, reserve).expect("at reserve"); + assert_eq!(inventory.budget().usable_bytes(), 0); + } +} diff --git a/crates/compute_backend/src/lib.rs b/crates/compute_backend/src/lib.rs new file mode 100644 index 000000000..9431056bd --- /dev/null +++ b/crates/compute_backend/src/lib.rs @@ -0,0 +1,67 @@ +#![forbid(unsafe_code)] +#![deny(missing_docs)] +//! VRAM-budgeted compute planning with a CPU `f64` reference path. +//! +//! This crate plans streamed GPU work under 4/6/8/12/24-GiB profiles and +//! treats out-of-memory as an expected operating state. It does not claim a +//! live accelerator lane. Full document-by-topic tensors are refused, and +//! memory pressure may not drop observations, shrink the model, or move a +//! knowledge cutoff. + +mod controller; +mod error; +mod inventory; +mod plan; +mod profile; +mod reference; +mod request; +mod telemetry; + +/// VRAM controller that autotunes batches and falls back to CPU. +pub use controller::VramController; +/// Fail-closed compute-backend errors. +pub use error::ComputeBackendError; +/// Typed backend-initialization failure. +pub use error::refuse_uninitialized_backend; +/// Typed device-loss failure. +pub use error::report_device_loss; +/// Typed out-of-memory operating state. +pub use error::report_out_of_memory; +/// Observed accelerator inventory. +pub use inventory::DeviceInventory; +/// Reserved unused device bytes. +pub use inventory::SafetyReserve; +/// Usable bytes after the safety reserve. +pub use inventory::VramBudget; +/// Selected execution backend. +pub use plan::ComputeBackendKind; +/// Why a plan left the accelerator. +pub use plan::FallbackReason; +/// Planned micro-batch. +pub use plan::MicroBatchPlan; +/// Predict peak bytes for a micro-batch. +pub use plan::predicted_peak_bytes; +/// Accepted device-class profile. +pub use profile::VramProfile; +/// Compare a candidate quantity to the CPU `f64` reference. +pub use reference::require_cpu_gpu_parity; +/// Reject a non-finite diagnostic quantity. +pub use reference::require_finite; +/// CPU `f64` streamed weighted sum. +pub use reference::streamed_weighted_sum; +/// Memory-adaptation policies grouped for safe workload construction. +pub use request::AdaptationPolicy; +/// Corpus placement policy. +pub use request::CorpusPlacement; +/// Cutoff-mutation policy. +pub use request::CutoffPolicy; +/// Model-complexity policy. +pub use request::ModelComplexity; +/// Observation-retention policy. +pub use request::ObservationRetention; +/// Transient versus diagnostic precision. +pub use request::PrecisionMode; +/// Streamed workload request. +pub use request::WorkloadRequest; +/// Resource telemetry without source text. +pub use telemetry::AllocationTelemetry; diff --git a/crates/compute_backend/src/plan.rs b/crates/compute_backend/src/plan.rs new file mode 100644 index 000000000..e85976dd2 --- /dev/null +++ b/crates/compute_backend/src/plan.rs @@ -0,0 +1,145 @@ +//! Planned backend, micro-batch, and fallback reason. + +use crate::request::PrecisionMode; + +/// Executable backend selected by the VRAM controller. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ComputeBackendKind { + /// CPU `f64` numerical reference and universal fallback. + CpuF64Reference, + /// Streamed GPU plan that still finalizes diagnostics on CPU `f64`. + GpuStreamed, +} + +/// Why a plan left the accelerator or reduced a batch. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum FallbackReason { + /// Usable VRAM could not hold even a unit micro-batch. + InsufficientVram, + /// Bounded OOM retries still could not keep the work on device. + OutOfMemoryRetryExhausted, + /// No accelerator was present. + DeviceUnavailable, + /// A non-finite guard forced the CPU reference path. + NonFiniteGuard, +} + +/// A planned micro-batch that preserves the full observation set. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct MicroBatchPlan { + backend: ComputeBackendKind, + batch_size: u32, + predicted_peak_bytes: u64, + precision: PrecisionMode, + oom_retry_count: u32, + fallback: Option, +} + +impl MicroBatchPlan { + pub(crate) const fn new( + backend: ComputeBackendKind, + batch_size: u32, + predicted_peak_bytes: u64, + precision: PrecisionMode, + oom_retry_count: u32, + fallback: Option, + ) -> Self { + Self { + backend, + batch_size, + predicted_peak_bytes, + precision, + oom_retry_count, + fallback, + } + } + + /// Return the selected backend. + #[must_use] + pub const fn backend(self) -> ComputeBackendKind { + self.backend + } + + /// Return the planned micro-batch size. + #[must_use] + pub const fn batch_size(self) -> u32 { + self.batch_size + } + + /// Return the predicted peak working-set plus batch charge. + #[must_use] + pub const fn predicted_peak_bytes(self) -> u64 { + self.predicted_peak_bytes + } + + /// Return the precision used for final diagnostics. + #[must_use] + pub const fn precision(self) -> PrecisionMode { + self.precision + } + + /// Return how many observed OOMs led to this plan. + #[must_use] + pub const fn oom_retry_count(self) -> u32 { + self.oom_retry_count + } + + /// Return the fallback reason, if the accelerator was not used. + #[must_use] + pub const fn fallback(self) -> Option { + self.fallback + } +} + +/// Predict peak bytes for a micro-batch plus fixed working set. +/// +/// # Errors +/// +/// Returns [`crate::ComputeBackendError::InvalidBudget`] on overflow. +pub const fn predicted_peak_bytes( + batch_size: u32, + bytes_per_observation: u64, + working_set_bytes: u64, +) -> Result { + let Some(batch_bytes) = bytes_per_observation.checked_mul(batch_size as u64) else { + return Err(crate::ComputeBackendError::InvalidBudget); + }; + match batch_bytes.checked_add(working_set_bytes) { + Some(peak) => Ok(peak), + None => Err(crate::ComputeBackendError::InvalidBudget), + } +} + +#[cfg(test)] +mod tests { + use super::{ComputeBackendKind, FallbackReason, MicroBatchPlan, predicted_peak_bytes}; + use crate::error::ComputeBackendError; + use crate::request::PrecisionMode; + + #[test] + fn peak_prediction_and_plan_accessors() { + assert_eq!(predicted_peak_bytes(2, 8, 16).expect("peak"), 32); + assert_eq!( + predicted_peak_bytes(2, u64::MAX, 1), + Err(ComputeBackendError::InvalidBudget) + ); + assert_eq!( + predicted_peak_bytes(1, u64::MAX, 1), + Err(ComputeBackendError::InvalidBudget) + ); + let plan = MicroBatchPlan::new( + ComputeBackendKind::CpuF64Reference, + 3, + 24, + PrecisionMode::ReferenceF64, + 2, + Some(FallbackReason::NonFiniteGuard), + ); + assert_eq!(plan.backend(), ComputeBackendKind::CpuF64Reference); + assert_eq!(plan.batch_size(), 3); + assert_eq!(plan.predicted_peak_bytes(), 24); + assert_eq!(plan.precision(), PrecisionMode::ReferenceF64); + assert_eq!(plan.oom_retry_count(), 2); + assert_eq!(plan.fallback(), Some(FallbackReason::NonFiniteGuard)); + } +} diff --git a/crates/compute_backend/src/profile.rs b/crates/compute_backend/src/profile.rs new file mode 100644 index 000000000..d0f202912 --- /dev/null +++ b/crates/compute_backend/src/profile.rs @@ -0,0 +1,68 @@ +//! Accepted VRAM device-class profiles. + +/// Binary-gigabyte VRAM profile used to select micro-batches. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub enum VramProfile { + /// 4 GiB class. + Gib4, + /// 6 GiB class. + Gib6, + /// 8 GiB class. + Gib8, + /// 12 GiB class. + Gib12, + /// 24 GiB class. + Gib24, +} + +impl VramProfile { + /// One binary gigabyte in bytes. + pub const GIBIBYTE: u64 = 1 << 30; + + /// Return every accepted profile in increasing capacity order. + #[must_use] + pub const fn all() -> [Self; 5] { + [Self::Gib4, Self::Gib6, Self::Gib8, Self::Gib12, Self::Gib24] + } + + /// Return the profile capacity in binary gigabytes. + #[must_use] + pub const fn gibibytes(self) -> u64 { + match self { + Self::Gib4 => 4, + Self::Gib6 => 6, + Self::Gib8 => 8, + Self::Gib12 => 12, + Self::Gib24 => 24, + } + } + + /// Return the profile capacity in bytes. + #[must_use] + pub const fn bytes(self) -> u64 { + self.gibibytes() * Self::GIBIBYTE + } + + /// Return the reserved safety headroom (one eighth of capacity). + #[must_use] + pub const fn safety_bytes(self) -> u64 { + self.bytes() / 8 + } +} + +#[cfg(test)] +mod tests { + use super::VramProfile; + + #[test] + fn capacities_match_accepted_profiles() { + assert_eq!(VramProfile::GIBIBYTE, 1_073_741_824); + assert_eq!(VramProfile::Gib6.gibibytes(), 6); + assert_eq!(VramProfile::Gib8.bytes(), 8 * VramProfile::GIBIBYTE); + assert_eq!( + VramProfile::Gib12.safety_bytes(), + VramProfile::Gib12.bytes() / 8 + ); + assert_eq!(VramProfile::all().len(), 5); + } +} diff --git a/crates/compute_backend/src/reference.rs b/crates/compute_backend/src/reference.rs new file mode 100644 index 000000000..cc5eb57d7 --- /dev/null +++ b/crates/compute_backend/src/reference.rs @@ -0,0 +1,156 @@ +//! CPU `f64` streamed reference arithmetic. + +use crate::error::ComputeBackendError; + +/// Stream a compensated weighted sum on the CPU `f64` reference path. +/// +/// Neumaier-style compensation preserves low-order terms in cancellation-heavy +/// inputs while keeping deterministic input order. This sequential function is +/// the numerical reference for later fixed-pool CPU and GPU implementations. +/// +/// # Errors +/// +/// Returns [`ComputeBackendError::InvalidBudget`] when the slices are empty or +/// unequal, and [`ComputeBackendError::NonFiniteOutput`] when any term or +/// accumulator is non-finite. +pub fn streamed_weighted_sum(weights: &[f64], values: &[f64]) -> Result { + if weights.is_empty() || weights.len() != values.len() { + return Err(ComputeBackendError::InvalidBudget); + } + let mut total = 0.0_f64; + let mut compensation = 0.0_f64; + for (weight, value) in weights.iter().zip(values) { + let term = require_finite(*weight)? * require_finite(*value)?; + let term = require_finite(term)?; + let next = require_finite(total + term)?; + let correction = if total.abs() >= term.abs() { + (total - next) + term + } else { + (term - next) + total + }; + compensation = require_finite(compensation + correction)?; + total = next; + } + require_finite(total + compensation) +} + +/// Reject a non-finite diagnostic quantity. +/// +/// # Errors +/// +/// Returns [`ComputeBackendError::NonFiniteOutput`] when `value` is NaN or +/// infinite. +pub fn require_finite(value: f64) -> Result { + if value.is_finite() { + Ok(value) + } else { + Err(ComputeBackendError::NonFiniteOutput) + } +} + +/// Compare a candidate quantity against the CPU `f64` reference. +/// +/// # Errors +/// +/// Returns [`ComputeBackendError::NonFiniteOutput`] when either value or the +/// tolerance is non-finite, [`ComputeBackendError::InvalidTolerance`] for a +/// negative tolerance, and [`ComputeBackendError::ParityFailure`] when the +/// normalized gap exceeds `tolerance`, where the gap is divided by +/// `max(1, |reference|, |candidate|)`. Computing the normalized gap first +/// prevents a finite tolerance bound from overflowing before comparison. +pub fn require_cpu_gpu_parity( + cpu_reference: f64, + candidate: f64, + tolerance: f64, +) -> Result<(), ComputeBackendError> { + let left = require_finite(cpu_reference)?; + let right = require_finite(candidate)?; + let bound = require_finite(tolerance)?; + if bound < 0.0 { + return Err(ComputeBackendError::InvalidTolerance); + } + let scale = left.abs().max(right.abs()).max(1.0); + let normalized_gap = require_finite((left - right).abs() / scale)?; + if normalized_gap <= bound { + Ok(()) + } else { + Err(ComputeBackendError::ParityFailure) + } +} + +#[cfg(test)] +mod tests { + use super::{require_cpu_gpu_parity, require_finite, streamed_weighted_sum}; + use crate::error::ComputeBackendError; + + #[test] + fn compensated_reference_recovers_low_order_cancellation_term() { + let result = + streamed_weighted_sum(&[1.0, 1.0, 1.0], &[1e16, 1.0, -1e16]).expect("compensated sum"); + assert!((result - 1.0).abs() < 1e-15); + let reverse = streamed_weighted_sum(&[1.0, 1.0, 1.0], &[-1e16, 1.0, 1e16]) + .expect("reverse compensation branch"); + assert!((reverse - 1.0).abs() < 1e-15); + } + + #[test] + fn reference_path_rejects_invalid_and_non_finite_input() { + assert_eq!( + streamed_weighted_sum(&[], &[1.0]), + Err(ComputeBackendError::InvalidBudget) + ); + assert_eq!( + streamed_weighted_sum(&[1.0], &[1.0, 2.0]), + Err(ComputeBackendError::InvalidBudget) + ); + assert_eq!( + streamed_weighted_sum(&[f64::NAN], &[1.0]), + Err(ComputeBackendError::NonFiniteOutput) + ); + assert_eq!( + streamed_weighted_sum(&[1.0], &[f64::INFINITY]), + Err(ComputeBackendError::NonFiniteOutput) + ); + assert_eq!( + streamed_weighted_sum(&[1e308], &[1e308]), + Err(ComputeBackendError::NonFiniteOutput) + ); + assert_eq!( + require_finite(f64::NEG_INFINITY), + Err(ComputeBackendError::NonFiniteOutput) + ); + let finite = require_finite(1.5).expect("finite"); + assert!((finite - 1.5).abs() < 1e-15); + require_cpu_gpu_parity(1.0, 1.0, 0.0).expect("exact parity"); + assert_eq!( + require_cpu_gpu_parity(1.0, 2.0, 0.1), + Err(ComputeBackendError::ParityFailure) + ); + require_cpu_gpu_parity(1.0e12, 1.0e12 + 1.0e6, 1.0e-6) + .expect("relative parity at large scale"); + assert_eq!( + require_cpu_gpu_parity(1.0e12, 1.0e12 + 2.0e6, 1.0e-6), + Err(ComputeBackendError::ParityFailure) + ); + assert_eq!( + require_cpu_gpu_parity(1.0, 1.0, -0.1), + Err(ComputeBackendError::InvalidTolerance) + ); + assert_eq!( + require_cpu_gpu_parity(f64::NAN, 1.0, 0.1), + Err(ComputeBackendError::NonFiniteOutput) + ); + assert_eq!( + require_cpu_gpu_parity(1.0, f64::NAN, 0.1), + Err(ComputeBackendError::NonFiniteOutput) + ); + assert_eq!( + require_cpu_gpu_parity(1.0, 1.0, f64::NAN), + Err(ComputeBackendError::NonFiniteOutput) + ); + assert_eq!( + require_cpu_gpu_parity(f64::MAX, -f64::MAX, f64::MAX), + Err(ComputeBackendError::NonFiniteOutput) + ); + } +} diff --git a/crates/compute_backend/src/request.rs b/crates/compute_backend/src/request.rs new file mode 100644 index 000000000..8189cabc2 --- /dev/null +++ b/crates/compute_backend/src/request.rs @@ -0,0 +1,262 @@ +//! Workload request and precision policy. + +use crate::error::ComputeBackendError; + +/// Arithmetic mode for transient kernels versus final diagnostics. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PrecisionMode { + /// CPU `f64` reference precision required for diagnostics. + ReferenceF64, + /// Approved mixed precision for transient device computation only. + TransientMixed, +} + +/// Whether a full document-by-topic tensor may reside on device. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CorpusPlacement { + /// Stream micro-batches only. + StreamedMicroBatches, + /// Pin the full corpus responsibility tensor on the device. + FullCorpusOnDevice, +} + +/// Whether observations may be dropped under memory pressure. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ObservationRetention { + /// Keep every observation. + KeepAll, + /// Drop observations so a batch fits. + DropToFit, +} + +/// Whether topic or model complexity may shrink to fit memory. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ModelComplexity { + /// Keep the requested topic/model complexity. + KeepSpecified, + /// Reduce complexity so a batch fits. + ReduceToFit, +} + +/// Whether a knowledge cutoff may move to fit memory. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CutoffPolicy { + /// Keep the requested cutoff. + KeepCutoff, + /// Move the cutoff so a batch fits. + MoveToFit, +} + +/// Memory-adaptation policies that must remain explicit at workload creation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct AdaptationPolicy { + /// Whether the corpus may be fully resident on the device. + pub corpus_placement: CorpusPlacement, + /// Whether observations may be dropped to fit the device budget. + pub observation_retention: ObservationRetention, + /// Whether model complexity may be reduced to fit the device budget. + pub model_complexity: ModelComplexity, + /// Whether the knowledge cutoff may move to fit the device budget. + pub cutoff_policy: CutoffPolicy, +} + +/// A streamed workload that must never pin a full document-by-topic tensor. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct WorkloadRequest { + document_count: u64, + topic_count: u64, + bytes_per_observation: u64, + working_set_bytes: u64, + requested_batch: u32, + corpus_placement: CorpusPlacement, + observation_retention: ObservationRetention, + model_complexity: ModelComplexity, + cutoff_policy: CutoffPolicy, + final_quantity_precision: PrecisionMode, +} + +impl WorkloadRequest { + /// Construct a fail-closed workload request. + /// + /// Streamed document and topic cardinalities are stored independently; the + /// constructor deliberately does not materialize or size a hypothetical + /// full-corpus tensor that the controller refuses to allocate. + /// + /// # Errors + /// + /// Returns [`ComputeBackendError::InvalidBudget`] when counts, batch size, + /// or per-observation bytes are zero. + pub const fn new( + document_count: u64, + topic_count: u64, + bytes_per_observation: u64, + working_set_bytes: u64, + requested_batch: u32, + policy: AdaptationPolicy, + final_quantity_precision: PrecisionMode, + ) -> Result { + if document_count == 0 + || topic_count == 0 + || bytes_per_observation == 0 + || requested_batch == 0 + { + return Err(ComputeBackendError::InvalidBudget); + } + Ok(Self { + document_count, + topic_count, + bytes_per_observation, + working_set_bytes, + requested_batch, + corpus_placement: policy.corpus_placement, + observation_retention: policy.observation_retention, + model_complexity: policy.model_complexity, + cutoff_policy: policy.cutoff_policy, + final_quantity_precision, + }) + } + + /// Return the document count. + #[must_use] + pub const fn document_count(self) -> u64 { + self.document_count + } + + /// Return the topic count. + #[must_use] + pub const fn topic_count(self) -> u64 { + self.topic_count + } + + /// Return bytes charged per streamed observation. + #[must_use] + pub const fn bytes_per_observation(self) -> u64 { + self.bytes_per_observation + } + + /// Return the fixed working-set charge. + #[must_use] + pub const fn working_set_bytes(self) -> u64 { + self.working_set_bytes + } + + /// Return the caller-requested micro-batch. + #[must_use] + pub const fn requested_batch(self) -> u32 { + self.requested_batch + } + + /// Return the corpus placement policy. + #[must_use] + pub const fn corpus_placement(self) -> CorpusPlacement { + self.corpus_placement + } + + /// Return the observation-retention policy. + #[must_use] + pub const fn observation_retention(self) -> ObservationRetention { + self.observation_retention + } + + /// Return the model-complexity policy. + #[must_use] + pub const fn model_complexity(self) -> ModelComplexity { + self.model_complexity + } + + /// Return the cutoff policy. + #[must_use] + pub const fn cutoff_policy(self) -> CutoffPolicy { + self.cutoff_policy + } + + /// Return the precision required for final diagnostics. + #[must_use] + pub const fn final_quantity_precision(self) -> PrecisionMode { + self.final_quantity_precision + } +} + +#[cfg(test)] +mod tests { + use super::{ + AdaptationPolicy, CorpusPlacement, CutoffPolicy, ModelComplexity, ObservationRetention, + PrecisionMode, WorkloadRequest, + }; + use crate::error::ComputeBackendError; + + fn request( + documents: u64, + topics: u64, + bytes_per_observation: u64, + batch: u32, + ) -> Result { + WorkloadRequest::new( + documents, + topics, + bytes_per_observation, + 0, + batch, + AdaptationPolicy { + corpus_placement: CorpusPlacement::StreamedMicroBatches, + observation_retention: ObservationRetention::KeepAll, + model_complexity: ModelComplexity::KeepSpecified, + cutoff_policy: CutoffPolicy::KeepCutoff, + }, + PrecisionMode::ReferenceF64, + ) + } + + #[test] + fn request_rejects_zero_counts() { + assert_eq!(request(0, 1, 8, 1), Err(ComputeBackendError::InvalidBudget)); + assert_eq!(request(1, 0, 8, 1), Err(ComputeBackendError::InvalidBudget)); + assert_eq!(request(1, 1, 0, 1), Err(ComputeBackendError::InvalidBudget)); + assert_eq!(request(1, 1, 8, 0), Err(ComputeBackendError::InvalidBudget)); + } + + #[test] + fn streamed_dimensions_are_not_multiplied_into_a_full_tensor() { + let request = request(u64::MAX, u64::MAX, 8, 1).expect("streamed cardinality"); + assert_eq!(request.document_count(), u64::MAX); + assert_eq!(request.topic_count(), u64::MAX); + } + + #[test] + fn request_accessors_preserve_policy_enums() { + let request = WorkloadRequest::new( + 2, + 3, + 8, + 16, + 4, + AdaptationPolicy { + corpus_placement: CorpusPlacement::StreamedMicroBatches, + observation_retention: ObservationRetention::KeepAll, + model_complexity: ModelComplexity::KeepSpecified, + cutoff_policy: CutoffPolicy::KeepCutoff, + }, + PrecisionMode::TransientMixed, + ) + .expect("valid"); + assert_eq!(request.document_count(), 2); + assert_eq!(request.topic_count(), 3); + assert_eq!(request.bytes_per_observation(), 8); + assert_eq!(request.working_set_bytes(), 16); + assert_eq!(request.requested_batch(), 4); + assert_eq!( + request.corpus_placement(), + CorpusPlacement::StreamedMicroBatches + ); + assert_eq!( + request.observation_retention(), + ObservationRetention::KeepAll + ); + assert_eq!(request.model_complexity(), ModelComplexity::KeepSpecified); + assert_eq!(request.cutoff_policy(), CutoffPolicy::KeepCutoff); + assert_eq!( + request.final_quantity_precision(), + PrecisionMode::TransientMixed + ); + } +} diff --git a/crates/compute_backend/src/telemetry.rs b/crates/compute_backend/src/telemetry.rs new file mode 100644 index 000000000..324df894c --- /dev/null +++ b/crates/compute_backend/src/telemetry.rs @@ -0,0 +1,112 @@ +//! Allocation telemetry that must not carry source text. + +use crate::error::ComputeBackendError; +use crate::plan::FallbackReason; +use crate::request::PrecisionMode; + +/// Resource telemetry for one planning or retry decision. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct AllocationTelemetry { + allocated_bytes: u64, + transfer_bytes: u64, + retry_count: u32, + kernel_launches: u32, + precision: PrecisionMode, + fallback: Option, +} + +impl AllocationTelemetry { + /// Record allocation, transfer, retry, kernel, precision, and fallback. + #[must_use] + pub const fn new( + allocated_bytes: u64, + transfer_bytes: u64, + retry_count: u32, + kernel_launches: u32, + precision: PrecisionMode, + fallback: Option, + ) -> Self { + Self { + allocated_bytes, + transfer_bytes, + retry_count, + kernel_launches, + precision, + fallback, + } + } + + /// Refuse to attach raw source text to telemetry. + /// + /// # Errors + /// + /// Always returns [`ComputeBackendError::SourceTextInTelemetry`]. + #[allow(clippy::unused_self)] + pub fn attach_source_text(&self, _source_text: &str) -> Result<(), ComputeBackendError> { + Err(ComputeBackendError::SourceTextInTelemetry) + } + + /// Return allocated bytes. + #[must_use] + pub const fn allocated_bytes(self) -> u64 { + self.allocated_bytes + } + + /// Return transfer bytes. + #[must_use] + pub const fn transfer_bytes(self) -> u64 { + self.transfer_bytes + } + + /// Return OOM retry count. + #[must_use] + pub const fn retry_count(self) -> u32 { + self.retry_count + } + + /// Return recorded kernel launches. + #[must_use] + pub const fn kernel_launches(self) -> u32 { + self.kernel_launches + } + + /// Return recorded precision. + #[must_use] + pub const fn precision(self) -> PrecisionMode { + self.precision + } + + /// Return recorded fallback reason. + #[must_use] + pub const fn fallback(self) -> Option { + self.fallback + } +} + +#[cfg(test)] +mod tests { + use super::AllocationTelemetry; + use crate::plan::FallbackReason; + use crate::request::PrecisionMode; + + #[test] + fn telemetry_accessors_exclude_source_text() { + let telemetry = AllocationTelemetry::new( + 8, + 4, + 2, + 1, + PrecisionMode::TransientMixed, + Some(FallbackReason::DeviceUnavailable), + ); + assert_eq!(telemetry.allocated_bytes(), 8); + assert_eq!(telemetry.transfer_bytes(), 4); + assert_eq!(telemetry.retry_count(), 2); + assert_eq!(telemetry.kernel_launches(), 1); + assert_eq!(telemetry.precision(), PrecisionMode::TransientMixed); + assert_eq!( + telemetry.fallback(), + Some(FallbackReason::DeviceUnavailable) + ); + } +} diff --git a/crates/compute_backend/tests/crate_contract.rs b/crates/compute_backend/tests/crate_contract.rs new file mode 100644 index 000000000..56d994f6d --- /dev/null +++ b/crates/compute_backend/tests/crate_contract.rs @@ -0,0 +1,7 @@ +//! Integration contract for the `compute_backend` package identity. + +#[test] +fn package_identity_is_stable() { + let observed = std::hint::black_box(env!("CARGO_PKG_NAME")); + assert_eq!(observed, "compute_backend"); +} diff --git a/crates/compute_backend/tests/vram_budget_contract.rs b/crates/compute_backend/tests/vram_budget_contract.rs new file mode 100644 index 000000000..32569fcf0 --- /dev/null +++ b/crates/compute_backend/tests/vram_budget_contract.rs @@ -0,0 +1,267 @@ +//! VRAM budget, executable OOM retry, and CPU `f64` reference contracts. +#![allow(clippy::cast_precision_loss)] + +use compute_backend::{ + AdaptationPolicy, AllocationTelemetry, ComputeBackendError, ComputeBackendKind, + CorpusPlacement, CutoffPolicy, DeviceInventory, FallbackReason, ModelComplexity, + ObservationRetention, PrecisionMode, VramController, VramProfile, WorkloadRequest, + require_cpu_gpu_parity, streamed_weighted_sum, +}; + +fn rmse(truth: &[f64], recovered: &[f64]) -> f64 { + let n = truth.len() as f64; + let sum_sq: f64 = truth + .iter() + .zip(recovered) + .map(|(left, right)| { + let residual = left - right; + residual * residual + }) + .sum(); + (sum_sq / n).sqrt() +} + +fn base_request(batch: u32, bytes_per_observation: u64) -> WorkloadRequest { + WorkloadRequest::new( + 1_024, + 64, + bytes_per_observation, + 1_048_576, + batch, + AdaptationPolicy { + corpus_placement: CorpusPlacement::StreamedMicroBatches, + observation_retention: ObservationRetention::KeepAll, + model_complexity: ModelComplexity::KeepSpecified, + cutoff_policy: CutoffPolicy::KeepCutoff, + }, + PrecisionMode::ReferenceF64, + ) + .expect("valid workload") +} + +#[test] +fn profiles_cover_the_adr_device_classes() { + let profiles = VramProfile::all(); + assert_eq!(profiles.map(VramProfile::gibibytes), [4, 6, 8, 12, 24]); + assert_eq!(VramProfile::Gib4.bytes(), 4 * (1 << 30)); + assert_eq!(VramProfile::Gib24.bytes(), 24 * (1 << 30)); +} + +#[test] +fn compensated_reference_recovers_cancellation_and_known_total() { + // CPU-reference evidence only; this crate has no GPU execution path yet. + let weights = [0.25_f64, 0.25, 0.25, 0.25]; + let values = [4.0_f64, 8.0, 12.0, 16.0]; + let recovered = streamed_weighted_sum(&weights, &values).expect("finite reference"); + let error = rmse(&[10.0], &[recovered]); + assert!(error < 1e-12, "CPU f64 RMSE {error} exceeded bound"); + + let cancellation = streamed_weighted_sum(&[1.0, 1.0, 1.0], &[1e16, 1.0, -1e16]) + .expect("compensated cancellation"); + assert!((cancellation - 1.0).abs() < 1e-15); +} + +#[test] +fn larger_vram_profiles_admit_larger_micro_batches() { + let request = base_request(1_024, 4_194_304); + let small = VramController::new( + DeviceInventory::gpu(VramProfile::Gib4, VramProfile::Gib4.bytes()).expect("4 GiB"), + 3, + ) + .expect("controller") + .plan(&request) + .expect("4 GiB plan"); + let large = VramController::new( + DeviceInventory::gpu(VramProfile::Gib24, VramProfile::Gib24.bytes()).expect("24 GiB"), + 3, + ) + .expect("controller") + .plan(&request) + .expect("24 GiB plan"); + + assert_eq!(small.backend(), ComputeBackendKind::GpuStreamed); + assert_eq!(large.backend(), ComputeBackendKind::GpuStreamed); + assert!(large.batch_size() > small.batch_size()); + assert_eq!(small.oom_retry_count(), 0); + assert_eq!(large.oom_retry_count(), 0); +} + +#[test] +fn each_oom_returns_a_smaller_gpu_plan_before_cpu_fallback() { + let controller = VramController::new( + DeviceInventory::gpu(VramProfile::Gib6, VramProfile::Gib6.bytes()).expect("6 GiB"), + 2, + ) + .expect("controller"); + let request = base_request(64, 1_048_576); + let initial = controller.plan(&request).expect("initial plan"); + let retry_one = controller + .recover_from_oom(&request, &initial) + .expect("first retry plan"); + assert_eq!(retry_one.backend(), ComputeBackendKind::GpuStreamed); + assert_eq!(retry_one.batch_size(), initial.batch_size() / 2); + assert_eq!(retry_one.oom_retry_count(), 1); + assert!(retry_one.predicted_peak_bytes() < initial.predicted_peak_bytes()); + + let retry_two = controller + .recover_from_oom(&request, &retry_one) + .expect("second retry plan"); + assert_eq!(retry_two.backend(), ComputeBackendKind::GpuStreamed); + assert_eq!(retry_two.batch_size(), retry_one.batch_size() / 2); + assert_eq!(retry_two.oom_retry_count(), 2); + + let fallback = controller + .recover_from_oom(&request, &retry_two) + .expect("bounded fallback"); + assert_eq!(fallback.backend(), ComputeBackendKind::CpuF64Reference); + assert_eq!( + fallback.fallback(), + Some(FallbackReason::OutOfMemoryRetryExhausted) + ); + // CPU f64 fallback is not VRAM-limited, so it restores the requested batch. + assert_eq!(fallback.batch_size(), request.requested_batch()); + assert_eq!(fallback.oom_retry_count(), 3); +} + +#[test] +fn streamed_cardinality_does_not_require_a_hypothetical_full_tensor() { + let request = WorkloadRequest::new( + u64::MAX, + u64::MAX, + 8, + 0, + 1, + AdaptationPolicy { + corpus_placement: CorpusPlacement::StreamedMicroBatches, + observation_retention: ObservationRetention::KeepAll, + model_complexity: ModelComplexity::KeepSpecified, + cutoff_policy: CutoffPolicy::KeepCutoff, + }, + PrecisionMode::ReferenceF64, + ) + .expect("streamed dimensions are independently representable"); + assert_eq!(request.document_count(), u64::MAX); + assert_eq!(request.topic_count(), u64::MAX); + + let controller = VramController::new( + DeviceInventory::gpu(VramProfile::Gib4, VramProfile::Gib4.bytes()).expect("4 GiB"), + 1, + ) + .expect("controller"); + let plan = controller.plan(&request).expect("streamed plan"); + assert_eq!(plan.backend(), ComputeBackendKind::GpuStreamed); + assert_eq!(plan.batch_size(), 1); + assert_eq!(plan.predicted_peak_bytes(), 8); +} + +#[test] +fn parity_rejects_negative_tolerance() { + assert_eq!( + require_cpu_gpu_parity(1.0, 1.0, -0.1), + Err(ComputeBackendError::InvalidTolerance) + ); +} + +fn forbidden_request( + placement: CorpusPlacement, + retention: ObservationRetention, + complexity: ModelComplexity, + cutoff: CutoffPolicy, + precision: PrecisionMode, +) -> WorkloadRequest { + WorkloadRequest::new( + 8, + 4, + 8, + 64, + 2, + AdaptationPolicy { + corpus_placement: placement, + observation_retention: retention, + model_complexity: complexity, + cutoff_policy: cutoff, + }, + precision, + ) + .expect("request") +} + +#[test] +fn forbidden_memory_adaptations_fail_closed() { + let controller = VramController::new( + DeviceInventory::gpu(VramProfile::Gib8, VramProfile::Gib8.bytes()).expect("8 GiB"), + 1, + ) + .expect("controller"); + + for (request, expected) in [ + ( + forbidden_request( + CorpusPlacement::FullCorpusOnDevice, + ObservationRetention::KeepAll, + ModelComplexity::KeepSpecified, + CutoffPolicy::KeepCutoff, + PrecisionMode::ReferenceF64, + ), + ComputeBackendError::FullCorpusTensorRefused, + ), + ( + forbidden_request( + CorpusPlacement::StreamedMicroBatches, + ObservationRetention::DropToFit, + ModelComplexity::KeepSpecified, + CutoffPolicy::KeepCutoff, + PrecisionMode::ReferenceF64, + ), + ComputeBackendError::ObservationDropForbidden, + ), + ( + forbidden_request( + CorpusPlacement::StreamedMicroBatches, + ObservationRetention::KeepAll, + ModelComplexity::ReduceToFit, + CutoffPolicy::KeepCutoff, + PrecisionMode::ReferenceF64, + ), + ComputeBackendError::ComplexityReductionForbidden, + ), + ( + forbidden_request( + CorpusPlacement::StreamedMicroBatches, + ObservationRetention::KeepAll, + ModelComplexity::KeepSpecified, + CutoffPolicy::MoveToFit, + PrecisionMode::ReferenceF64, + ), + ComputeBackendError::CutoffMutationForbidden, + ), + ( + forbidden_request( + CorpusPlacement::StreamedMicroBatches, + ObservationRetention::KeepAll, + ModelComplexity::KeepSpecified, + CutoffPolicy::KeepCutoff, + PrecisionMode::TransientMixed, + ), + ComputeBackendError::UnsupportedPrecision, + ), + ] { + assert_eq!(controller.plan(&request), Err(expected)); + } +} + +#[test] +fn telemetry_refuses_raw_source_text() { + let telemetry = AllocationTelemetry::new( + 1_024, + 256, + 1, + 0, + PrecisionMode::ReferenceF64, + Some(FallbackReason::InsufficientVram), + ); + assert_eq!( + telemetry.attach_source_text("secret document body"), + Err(ComputeBackendError::SourceTextInTelemetry) + ); +} diff --git a/crates/episode_membership/Cargo.toml b/crates/episode_membership/Cargo.toml new file mode 100644 index 000000000..dbe0ce0e4 --- /dev/null +++ b/crates/episode_membership/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "episode_membership" +description = "Episode membership cannot escape the episode event-time interval." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true +readme.workspace = true +keywords.workspace = true +categories.workspace = true +publish = false + +[lints] +workspace = true diff --git a/crates/episode_membership/src/error.rs b/crates/episode_membership/src/error.rs new file mode 100644 index 000000000..b45c3d752 --- /dev/null +++ b/crates/episode_membership/src/error.rs @@ -0,0 +1,55 @@ +//! Fail-closed episode-membership errors. + +use std::fmt; + +/// A fail-closed episode-membership error. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum EpisodeMembershipError { + /// A membership window started after it ended. + InvertedEventWindow, + /// A membership window escaped the episode interval. + MembershipEscapesEpisode, + /// A recovery slice was empty or length-mismatched. + InvalidEpisodePayload, +} + +impl fmt::Display for EpisodeMembershipError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let message = match self { + Self::InvertedEventWindow => "event-time window is inverted", + Self::MembershipEscapesEpisode => { + "episode membership cannot escape the episode interval" + } + Self::InvalidEpisodePayload => "invalid episode-membership payload", + }; + formatter.write_str(message) + } +} + +impl std::error::Error for EpisodeMembershipError {} + +#[cfg(test)] +mod tests { + use super::EpisodeMembershipError; + + #[test] + fn error_messages_are_stable() { + for (error, message) in [ + ( + EpisodeMembershipError::InvertedEventWindow, + "event-time window is inverted", + ), + ( + EpisodeMembershipError::MembershipEscapesEpisode, + "episode membership cannot escape the episode interval", + ), + ( + EpisodeMembershipError::InvalidEpisodePayload, + "invalid episode-membership payload", + ), + ] { + assert_eq!(error.to_string(), message); + } + } +} diff --git a/crates/episode_membership/src/lib.rs b/crates/episode_membership/src/lib.rs new file mode 100644 index 000000000..9b57d4146 --- /dev/null +++ b/crates/episode_membership/src/lib.rs @@ -0,0 +1,20 @@ +#![forbid(unsafe_code)] +#![deny(missing_docs)] +#![allow(clippy::cast_precision_loss)] +//! Episode membership cannot escape the episode event-time interval. +//! +//! A document may belong to an episode only while that episode is active +//! in event time. This is not subevent-versus-parent containment +//! (ADR 0003). + +mod error; +mod window; + +/// Fail-closed episode-membership errors. +pub use error::EpisodeMembershipError; +/// A closed event-time window with inclusive integer bounds. +pub use window::EventWindow; +/// Fraction of recovered containment flags that match known truth. +pub use window::identity_recovery_rate; +/// Refuse a membership window that starts before or ends after the episode. +pub use window::refuse_membership_outside_episode; diff --git a/crates/episode_membership/src/window.rs b/crates/episode_membership/src/window.rs new file mode 100644 index 000000000..afe8798b9 --- /dev/null +++ b/crates/episode_membership/src/window.rs @@ -0,0 +1,173 @@ +//! Event-time windows and episode-containment refusal. + +use crate::EpisodeMembershipError; + +/// A closed event-time window with inclusive integer bounds. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct EventWindow { + start: i64, + end: i64, +} + +impl EventWindow { + /// Construct a non-inverted event-time window. + /// + /// # Errors + /// + /// Returns [`EpisodeMembershipError::InvertedEventWindow`] when `end` is + /// earlier than `start`. + pub const fn new(start: i64, end: i64) -> Result { + if end < start { + return Err(EpisodeMembershipError::InvertedEventWindow); + } + Ok(Self { start, end }) + } + + /// Return the inclusive start instant. + #[must_use] + pub const fn start(self) -> i64 { + self.start + } + + /// Return the inclusive end instant. + #[must_use] + pub const fn end(self) -> i64 { + self.end + } +} + +/// Refuse a membership window that starts before or ends after the episode. +/// +/// Equal bounds are contained. This is membership containment, not +/// subevent-versus-parent event containment. +/// +/// # Errors +/// +/// Returns [`EpisodeMembershipError::MembershipEscapesEpisode`] when the +/// membership is not contained in `episode`. +pub fn refuse_membership_outside_episode( + membership: EventWindow, + episode: EventWindow, +) -> Result<(), EpisodeMembershipError> { + if membership.start() < episode.start() || membership.end() > episode.end() { + return Err(EpisodeMembershipError::MembershipEscapesEpisode); + } + Ok(()) +} + +/// Fraction of recovered containment flags that match known truth. +/// +/// The match tally accumulates in [`usize`] and converts to [`f64`] only at +/// the final division, so all-match inputs longer than [`u32::MAX`] neither +/// wrap the tally nor panic under overflow-checked builds. +/// +/// # Errors +/// +/// Returns [`EpisodeMembershipError::InvalidEpisodePayload`] when either +/// slice is empty or the lengths differ. +pub fn identity_recovery_rate( + truth: &[bool], + decided: &[bool], +) -> Result { + if truth.is_empty() || truth.len() != decided.len() { + return Err(EpisodeMembershipError::InvalidEpisodePayload); + } + let matches = count_matching_decisions(truth.iter().copied().zip(decided.iter().copied())); + Ok(recovery_rate_from_tally(matches, truth.len())) +} + +/// Count positions where a recovered decision equals its known-truth flag. +/// +/// Generic over any pair stream so contract tests can inject a small +/// synthetic iterator; the tally type is [`usize`], which keeps counting +/// exact beyond [`u32::MAX`]. +fn count_matching_decisions(pairs: I) -> usize +where + I: IntoIterator, +{ + pairs + .into_iter() + .filter(|(truth_flag, decided_flag)| truth_flag == decided_flag) + .count() +} + +/// Divide an exact match tally by the total length in [`f64`]. +/// +/// The tally parameter is [`usize`] on purpose: a mocked count source beyond +/// [`u32::MAX`] is representable, and conversion to [`f64`] happens exactly +/// once here instead of accumulating in [`u32`]. +const fn recovery_rate_from_tally(matches: usize, total: usize) -> f64 { + matches as f64 / total as f64 +} + +#[cfg(test)] +mod tests { + use super::{ + EventWindow, count_matching_decisions, identity_recovery_rate, recovery_rate_from_tally, + refuse_membership_outside_episode, + }; + use crate::EpisodeMembershipError; + + #[test] + fn local_branches_cover_windows_and_payloads() { + assert_eq!( + EventWindow::new(20, 10), + Err(EpisodeMembershipError::InvertedEventWindow) + ); + let episode = EventWindow::new(10, 20).expect("episode"); + assert_eq!(episode.start(), 10); + assert_eq!(episode.end(), 20); + let inner = EventWindow::new(11, 19).expect("inner"); + refuse_membership_outside_episode(inner, episode).expect("inner"); + refuse_membership_outside_episode(episode, episode).expect("equal"); + assert_eq!( + refuse_membership_outside_episode(EventWindow::new(9, 15).expect("early"), episode), + Err(EpisodeMembershipError::MembershipEscapesEpisode) + ); + assert_eq!( + refuse_membership_outside_episode(EventWindow::new(12, 21).expect("late"), episode), + Err(EpisodeMembershipError::MembershipEscapesEpisode) + ); + let matched = identity_recovery_rate(&[true], &[true]).expect("rate"); + assert!((matched - 1.0).abs() < f64::EPSILON); + assert_eq!( + identity_recovery_rate(&[], &[]), + Err(EpisodeMembershipError::InvalidEpisodePayload) + ); + assert_eq!( + identity_recovery_rate(&[true], &[]), + Err(EpisodeMembershipError::InvalidEpisodePayload) + ); + } + + /// Mocked count source reporting a tally beyond [`u32::MAX`]. + /// + /// The overflow contract is enforced twice: at compile time, because the + /// tally parameters of [`recovery_rate_from_tally`] are [`usize`] (a + /// revert to `u32` fails this assertion to compile), and at run time, + /// because the boundary value must divide exactly in [`f64`]. + const BEYOND_U32_MAX_MATCHES: usize = u32::MAX as usize + 3; + const BEYOND_U32_MAX_TOTAL: usize = 2 * (u32::MAX as usize) + 6; + + #[test] + fn match_counting_is_generic_over_injected_iterators() { + let injected = [(true, true), (false, true), (true, false), (false, false)]; + assert_eq!(count_matching_decisions(injected), 2); + let boundary_rate = recovery_rate_from_tally(BEYOND_U32_MAX_MATCHES, BEYOND_U32_MAX_TOTAL); + assert!((boundary_rate - 0.5).abs() < f64::EPSILON); + } + + #[test] + fn recovery_rate_agrees_with_reference_count_on_synthetic_stream() { + let total = 200_000_usize; + let truth: Vec = (0..total).map(|index| index % 3 == 0).collect(); + let decided: Vec = (0..total).map(|index| index % 4 == 0).collect(); + let expected_matches = truth + .iter() + .zip(decided.iter()) + .filter(|(truth_flag, decided_flag)| truth_flag == decided_flag) + .count(); + let rate = identity_recovery_rate(&truth, &decided).expect("rate"); + assert!((rate - expected_matches as f64 / total as f64).abs() < 1e-9); + } +} diff --git a/crates/episode_membership/tests/crate_contract.rs b/crates/episode_membership/tests/crate_contract.rs new file mode 100644 index 000000000..3642514bb --- /dev/null +++ b/crates/episode_membership/tests/crate_contract.rs @@ -0,0 +1,7 @@ +//! Integration contract for the `episode_membership` package identity. + +#[test] +fn package_identity_is_stable() { + let observed = std::hint::black_box(env!("CARGO_PKG_NAME")); + assert_eq!(observed, "episode_membership"); +} diff --git a/crates/episode_membership/tests/episode_membership_contract.rs b/crates/episode_membership/tests/episode_membership_contract.rs new file mode 100644 index 000000000..336e3a8c2 --- /dev/null +++ b/crates/episode_membership/tests/episode_membership_contract.rs @@ -0,0 +1,62 @@ +//! Episode membership cannot escape the episode event-time interval. + +use episode_membership::{ + EpisodeMembershipError, EventWindow, identity_recovery_rate, refuse_membership_outside_episode, +}; + +#[test] +fn membership_cannot_escape_the_episode_interval() { + let episode = EventWindow::new(10, 20).expect("episode"); + let contained = EventWindow::new(10, 20).expect("equal"); + let starts_early = EventWindow::new(9, 15).expect("early"); + let ends_late = EventWindow::new(12, 21).expect("late"); + refuse_membership_outside_episode(contained, episode).expect("contained"); + assert_eq!( + refuse_membership_outside_episode(starts_early, episode), + Err(EpisodeMembershipError::MembershipEscapesEpisode) + ); + assert_eq!( + refuse_membership_outside_episode(ends_late, episode), + Err(EpisodeMembershipError::MembershipEscapesEpisode) + ); + assert_eq!( + EventWindow::new(20, 10), + Err(EpisodeMembershipError::InvertedEventWindow) + ); +} + +#[test] +fn recovered_containment_matches_known_truth_better_than_accepting_every_membership() { + let truth = [true, false, false]; + let recovered = [true, false, false]; + let collapsed = [true, true, true]; + let recovered_rate = identity_recovery_rate(&truth, &recovered).expect("recovered"); + let collapsed_rate = identity_recovery_rate(&truth, &collapsed).expect("collapsed"); + let expected = { + let mut matches = 0_u32; + for (truth_flag, decided_flag) in truth.iter().zip(recovered.iter()) { + if truth_flag == decided_flag { + matches += 1; + } + } + f64::from(matches) / f64::from(u32::try_from(truth.len()).expect("len")) + }; + assert!((recovered_rate - expected).abs() < f64::EPSILON); + assert!(recovered_rate > collapsed_rate); +} + +#[test] +fn empty_or_mismatched_containment_payloads_fail_closed() { + assert_eq!( + identity_recovery_rate(&[], &[]), + Err(EpisodeMembershipError::InvalidEpisodePayload) + ); + assert_eq!( + identity_recovery_rate(&[true], &[]), + Err(EpisodeMembershipError::InvalidEpisodePayload) + ); + assert_eq!( + identity_recovery_rate(&[true, false], &[true]), + Err(EpisodeMembershipError::InvalidEpisodePayload) + ); +} diff --git a/crates/event_core/src/error.rs b/crates/event_core/src/error.rs index f1ce2533f..d862bf735 100644 --- a/crates/event_core/src/error.rs +++ b/crates/event_core/src/error.rs @@ -24,6 +24,28 @@ pub enum EventError { DetectionIsNotTransition, /// A CHRONOS prediction was treated as an observed or promoted fact. PredictionIsNotFact, + /// A TDT track assignment was treated as an event instance. + EventTrackIsNotEventInstance, + /// A TDT track assignment was treated as a state transition. + EventTrackIsNotStateTransition, + /// An unknown event-track label was supplied. + UnknownEventTrackLabel, + /// A CHRONOS schema prediction was treated as an event instance. + SchemaPredictionIsNotEventInstance, + /// A CHRONOS schema prediction was treated as a state transition. + SchemaPredictionIsNotStateTransition, + /// An unknown schema-slot occupancy label was supplied. + UnknownSchemaSlotLabel, + /// A TDT story segmentation was treated as an event instance. + StorySegmentationIsNotEventInstance, + /// A TDT story segmentation was treated as a state transition. + StorySegmentationIsNotStateTransition, + /// An unknown story-boundary label was supplied. + UnknownStoryBoundaryLabel, + /// A CHRONOS occurrence prediction was treated as an event instance. + PredictionIsNotEventInstance, + /// An unknown occurrence-truth label was supplied. + UnknownOccurrenceTruth, } impl fmt::Display for EventError { @@ -38,6 +60,25 @@ impl fmt::Display for EventError { Self::UnknownEventRole => "unknown event role", Self::DetectionIsNotTransition => "detection is not a state transition", Self::PredictionIsNotFact => "prediction is not an observed fact", + Self::EventTrackIsNotEventInstance => "event track is not an event instance", + Self::EventTrackIsNotStateTransition => "event track is not a state transition", + Self::UnknownEventTrackLabel => "unknown event track label", + Self::SchemaPredictionIsNotEventInstance => { + "schema prediction is not an event instance" + } + Self::SchemaPredictionIsNotStateTransition => { + "schema prediction is not a state transition" + } + Self::UnknownSchemaSlotLabel => "unknown schema slot label", + Self::StorySegmentationIsNotEventInstance => { + "story segmentation is not an event instance" + } + Self::StorySegmentationIsNotStateTransition => { + "story segmentation is not a state transition" + } + Self::UnknownStoryBoundaryLabel => "unknown story boundary label", + Self::PredictionIsNotEventInstance => "CHRONOS prediction is not an event instance", + Self::UnknownOccurrenceTruth => "unknown occurrence truth label", }; formatter.write_str(message) } @@ -79,6 +120,50 @@ mod tests { EventError::PredictionIsNotFact, "prediction is not an observed fact", ), + ( + EventError::EventTrackIsNotEventInstance, + "event track is not an event instance", + ), + ( + EventError::EventTrackIsNotStateTransition, + "event track is not a state transition", + ), + ( + EventError::UnknownEventTrackLabel, + "unknown event track label", + ), + ( + EventError::SchemaPredictionIsNotEventInstance, + "schema prediction is not an event instance", + ), + ( + EventError::SchemaPredictionIsNotStateTransition, + "schema prediction is not a state transition", + ), + ( + EventError::UnknownSchemaSlotLabel, + "unknown schema slot label", + ), + ( + EventError::StorySegmentationIsNotEventInstance, + "story segmentation is not an event instance", + ), + ( + EventError::StorySegmentationIsNotStateTransition, + "story segmentation is not a state transition", + ), + ( + EventError::UnknownStoryBoundaryLabel, + "unknown story boundary label", + ), + ( + EventError::PredictionIsNotEventInstance, + "CHRONOS prediction is not an event instance", + ), + ( + EventError::UnknownOccurrenceTruth, + "unknown occurrence truth label", + ), ] { assert_eq!(error.to_string(), message); } diff --git a/crates/event_core/src/lib.rs b/crates/event_core/src/lib.rs index 13050cb04..9a190cb1e 100644 --- a/crates/event_core/src/lib.rs +++ b/crates/event_core/src/lib.rs @@ -6,7 +6,10 @@ //! **versioned event instances** used for temporal state, multilevel membership, //! and scientific estimation. Mentions never silently become instances. TDT //! detections and CHRONOS predictions remain measurement or hypothesis -//! artifacts until independently promoted. +//! artifacts until independently promoted. Track assignments, story +//! segmentations, CHRONOS schema-slot predictions, and occurrence forecasts +//! remain measurement or hypothesis artifacts and cannot promote an instance +//! without an explicit evidence-backed promotion gate. mod confidence; mod error; @@ -14,8 +17,12 @@ mod identifier; mod instance; mod intelligence; mod mention; +mod prediction; mod registry; mod role; +mod schema; +mod segment; +mod track; /// Finite confidence on the closed unit interval. pub use confidence::EventConfidence; @@ -47,7 +54,69 @@ pub use intelligence::classify_tdt_story; pub use intelligence::first_story_detection_rates; /// Fallible textual event mention. pub use mention::EventMention; +/// One CHRONOS occurrence forecast that remains hypothetical. +pub use prediction::ChronosOccurrenceForecast; +/// Opaque CHRONOS occurrence-prediction identity. +pub use prediction::ChronosPredictionId; +/// Later-observed occurrence truth for a CHRONOS forecast. +pub use prediction::OccurrenceTruth; +/// Mean squared error of CHRONOS occurrence forecasts against later truth. +pub use prediction::chronos_prediction_brier_score; +/// Explicit refusal to treat a CHRONOS prediction as an event instance. +pub use prediction::refuse_prediction_as_instance; /// In-memory registry separating mentions from instances. pub use registry::EventRegistry; /// Typed event role kind. pub use role::EventRoleKind; +/// Opaque CHRONOS schema-prediction identity. +pub use schema::SchemaPredictionId; +/// Predicted or observed filler for one schema slot. +pub use schema::SchemaSlotAssignment; +/// Filled-versus-empty occupancy label. +pub use schema::SchemaSlotLabel; +/// Threshold a slot-occupancy probability into a fill label. +pub use schema::decide_schema_slot; +/// Explicit refusal to treat a schema prediction as an instance. +pub use schema::refuse_schema_prediction_as_instance; +/// Explicit refusal to treat a schema prediction as a state transition. +pub use schema::refuse_schema_prediction_as_transition; +/// Precision of recovered filled slots against known truth. +pub use schema::schema_slot_precision; +/// Recall of recovered filled slots against known truth. +pub use schema::schema_slot_recall; +/// TDT story-boundary versus continuation label. +pub use segment::StoryBoundaryLabel; +/// Ordered TDT story/event segmentation. +pub use segment::StorySegmentation; +/// Threshold a boundary probability into a detection label. +pub use segment::decide_story_boundary; +/// Explicit refusal to treat a story segmentation as an instance. +pub use segment::refuse_story_segmentation_as_instance; +/// Explicit refusal to treat a story segmentation as a state transition. +pub use segment::refuse_story_segmentation_as_transition; +/// Precision of recovered interior story boundaries against known truth. +pub use segment::story_boundary_precision; +/// Recall of recovered interior story boundaries against known truth. +pub use segment::story_boundary_recall; +/// Beeferman Pk against a known-truth segmentation. +pub use segment::story_pk; +/// Pevzner–Hearst `WindowDiff` against a known-truth segmentation. +pub use segment::story_window_diff; +/// Assignment of one mention to one hypothesized TDT track. +pub use track::EventTrackAssignment; +/// Opaque TDT track identity. +pub use track::EventTrackId; +/// TDT continue-versus-switch track label. +pub use track::EventTrackLabel; +/// Threshold a same-track probability into a continue/switch label. +pub use track::decide_track_continue; +/// Explicit refusal to treat a TDT track as an event instance. +pub use track::refuse_track_as_instance; +/// Explicit refusal to treat a TDT track as a state transition. +pub use track::refuse_track_as_transition; +/// Identity-switch rate among consecutive same-truth-track mentions. +pub use track::tracking_identity_switch_rate; +/// Precision of recovered same-track mention pairs against known truth. +pub use track::tracking_pair_precision; +/// Recall of recovered same-track mention pairs against known truth. +pub use track::tracking_pair_recall; diff --git a/crates/event_core/src/prediction.rs b/crates/event_core/src/prediction.rs new file mode 100644 index 000000000..d40191aa7 --- /dev/null +++ b/crates/event_core/src/prediction.rs @@ -0,0 +1,207 @@ +//! CHRONOS occurrence forecasts stay hypothetical until later evidence. + +use crate::{EventConfidence, EventError, EventInstanceId}; + +/// Opaque CHRONOS occurrence-prediction identity. +/// +/// A forecast is hypothesized future or schema-completion evidence. It is +/// never a promoted event instance. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct ChronosPredictionId(u32); + +impl ChronosPredictionId { + /// Reconstruct a prediction identity from a raw fixture or estimator label. + #[must_use] + pub const fn from_raw(raw: u32) -> Self { + Self(raw) + } + + /// Return the raw prediction label. + #[must_use] + pub const fn raw(self) -> u32 { + self.0 + } +} + +/// Later-observed occurrence truth for a CHRONOS forecast. +/// +/// Truth is recovered from later evidence. It does not rewrite the forecast +/// into an event instance. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum OccurrenceTruth { + /// Later evidence established that the predicted event occurred. + Occurred, + /// Later evidence established that the predicted event did not occur. + DidNotOccur, +} + +impl OccurrenceTruth { + /// Return the stable wire label name. + #[must_use] + pub const fn wire_name(self) -> &'static str { + match self { + Self::Occurred => "occurred", + Self::DidNotOccur => "did_not_occur", + } + } + + /// Parse a stable wire occurrence-truth label. + /// + /// # Errors + /// + /// Returns [`EventError::UnknownOccurrenceTruth`] for unrecognized names. + pub fn from_wire_name(name: &str) -> Result { + match name { + "occurred" => Ok(Self::Occurred), + "did_not_occur" => Ok(Self::DidNotOccur), + _ => Err(EventError::UnknownOccurrenceTruth), + } + } + + /// Return whether later evidence established occurrence. + #[must_use] + pub const fn occurred(self) -> bool { + matches!(self, Self::Occurred) + } + + /// Return the binary probability target used for Brier scoring. + /// + /// Occurred truth is `1.0`; non-occurrence is `0.0`. + #[must_use] + pub const fn as_probability_target(self) -> f64 { + match self { + Self::Occurred => 1.0, + Self::DidNotOccur => 0.0, + } + } +} + +/// One CHRONOS occurrence forecast that remains hypothetical. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ChronosOccurrenceForecast { + prediction_id: ChronosPredictionId, + probability: EventConfidence, +} + +impl ChronosOccurrenceForecast { + /// Bind a prediction identity to an occurrence probability. + #[must_use] + pub const fn new(prediction_id: ChronosPredictionId, probability: EventConfidence) -> Self { + Self { + prediction_id, + probability, + } + } + + /// Return the prediction identity. + #[must_use] + pub const fn prediction_id(self) -> ChronosPredictionId { + self.prediction_id + } + + /// Return the hypothesized occurrence probability. + #[must_use] + pub const fn probability(self) -> EventConfidence { + self.probability + } +} + +/// Explicit refusal to treat a CHRONOS occurrence prediction as an event instance. +/// +/// # Errors +/// +/// Always returns [`EventError::PredictionIsNotEventInstance`]. +pub fn refuse_prediction_as_instance( + _prediction: ChronosPredictionId, +) -> Result { + Err(EventError::PredictionIsNotEventInstance) +} + +/// Mean squared error of CHRONOS occurrence probabilities against later truth. +/// +/// # Errors +/// +/// Returns [`EventError::InvalidWirePayload`] when the slices are empty or +/// have unequal length. +pub fn chronos_prediction_brier_score( + forecasts: &[ChronosOccurrenceForecast], + outcomes: &[OccurrenceTruth], +) -> Result { + if forecasts.is_empty() || forecasts.len() != outcomes.len() { + return Err(EventError::InvalidWirePayload); + } + let mut square_sum = 0.0_f64; + for (forecast, outcome) in forecasts.iter().zip(outcomes) { + let residual = forecast.probability().value() - outcome.as_probability_target(); + square_sum += residual * residual; + } + mean_square(square_sum, forecasts.len()) +} + +fn mean_square(square_sum: f64, count: usize) -> Result { + let n = u32::try_from(count).map_err(|_| EventError::InvalidWirePayload)?; + if n == 0 { + return Err(EventError::InvalidWirePayload); + } + Ok(square_sum / f64::from(n)) +} + +#[cfg(test)] +mod tests { + use super::{ + ChronosOccurrenceForecast, ChronosPredictionId, OccurrenceTruth, + chronos_prediction_brier_score, refuse_prediction_as_instance, + }; + use crate::{EventConfidence, EventError}; + + #[test] + fn prediction_helpers_cover_local_branches() { + let prediction = ChronosPredictionId::from_raw(9); + assert_eq!(prediction.raw(), 9); + assert_eq!( + refuse_prediction_as_instance(prediction), + Err(EventError::PredictionIsNotEventInstance) + ); + assert_eq!(OccurrenceTruth::Occurred.wire_name(), "occurred"); + assert_eq!(OccurrenceTruth::DidNotOccur.wire_name(), "did_not_occur"); + assert_eq!( + OccurrenceTruth::from_wire_name("occurred").expect("parse"), + OccurrenceTruth::Occurred + ); + assert_eq!( + OccurrenceTruth::from_wire_name("did_not_occur").expect("parse"), + OccurrenceTruth::DidNotOccur + ); + assert_eq!( + OccurrenceTruth::from_wire_name("maybe"), + Err(EventError::UnknownOccurrenceTruth) + ); + assert!(OccurrenceTruth::Occurred.occurred()); + assert!(!OccurrenceTruth::DidNotOccur.occurred()); + assert!((OccurrenceTruth::Occurred.as_probability_target() - 1.0).abs() < f64::EPSILON); + assert!((OccurrenceTruth::DidNotOccur.as_probability_target() - 0.0).abs() < f64::EPSILON); + + let forecast = ChronosOccurrenceForecast::new( + prediction, + EventConfidence::new(0.25).expect("probability"), + ); + assert_eq!(forecast.prediction_id(), prediction); + assert!((forecast.probability().value() - 0.25).abs() < f64::EPSILON); + let miss = chronos_prediction_brier_score(&[forecast], &[OccurrenceTruth::Occurred]) + .expect("miss"); + assert!((miss - 0.5625).abs() < 1e-15); + assert_eq!( + chronos_prediction_brier_score(&[], &[OccurrenceTruth::Occurred]), + Err(EventError::InvalidWirePayload) + ); + assert_eq!( + super::mean_square(0.0, 0), + Err(EventError::InvalidWirePayload) + ); + assert_eq!( + super::mean_square(1.0, usize::MAX), + Err(EventError::InvalidWirePayload) + ); + assert!((super::mean_square(1.0, 2).expect("half") - 0.5).abs() < f64::EPSILON); + } +} diff --git a/crates/event_core/src/schema.rs b/crates/event_core/src/schema.rs new file mode 100644 index 000000000..1345ae842 --- /dev/null +++ b/crates/event_core/src/schema.rs @@ -0,0 +1,258 @@ +//! CHRONOS schema-slot predictions stay distinct from instances and transitions. + +use crate::{EventConfidence, EventError, EventInstanceId, EventRoleKind}; +use std::collections::BTreeSet; + +/// Opaque CHRONOS schema-prediction identity. +/// +/// A schema prediction is a hypothesized slot-fill. It is never a promoted +/// event instance and cannot create a forward state transition. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct SchemaPredictionId(u32); + +impl SchemaPredictionId { + /// Reconstruct a prediction identity from a raw fixture or estimator label. + #[must_use] + pub const fn from_raw(raw: u32) -> Self { + Self(raw) + } + + /// Return the raw prediction label. + #[must_use] + pub const fn raw(self) -> u32 { + self.0 + } +} + +/// CHRONOS filled-versus-empty occupancy for one schema slot. +/// +/// A fill decision is prediction evidence. It is never a promoted event +/// instance and cannot create a forward state transition by itself. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SchemaSlotLabel { + /// The slot is scored as occupied by a filler. + Filled, + /// The slot is scored as unoccupied. + Empty, +} + +impl SchemaSlotLabel { + /// Return the stable wire label name. + #[must_use] + pub const fn wire_name(self) -> &'static str { + match self { + Self::Filled => "filled", + Self::Empty => "empty", + } + } + + /// Parse a stable wire schema-slot occupancy label. + /// + /// # Errors + /// + /// Returns [`EventError::UnknownSchemaSlotLabel`] for unrecognized names. + pub fn from_wire_name(name: &str) -> Result { + match name { + "filled" => Ok(Self::Filled), + "empty" => Ok(Self::Empty), + _ => Err(EventError::UnknownSchemaSlotLabel), + } + } + + /// Return whether this label marks a filled slot. + #[must_use] + pub const fn is_filled(self) -> bool { + matches!(self, Self::Filled) + } + + /// Return the binary probability target used for RMSE. + /// + /// Filled truth is `1.0`; empty truth is `0.0`. + #[must_use] + pub const fn as_probability_target(self) -> f64 { + match self { + Self::Filled => 1.0, + Self::Empty => 0.0, + } + } +} + +/// Predicted or observed filler for one CHRONOS schema slot. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SchemaSlotAssignment { + role: EventRoleKind, + argument: String, +} + +impl SchemaSlotAssignment { + /// Bind a role to a hypothesized filler argument. + /// + /// # Errors + /// + /// Returns [`EventError::InvalidWirePayload`] when the argument is empty + /// or whitespace-only. + pub fn new(role: EventRoleKind, argument: impl Into) -> Result { + let argument = argument.into(); + let argument = argument.trim(); + if argument.is_empty() { + return Err(EventError::InvalidWirePayload); + } + Ok(Self { + role, + argument: argument.to_string(), + }) + } + + /// Return the typed role for this slot. + #[must_use] + pub const fn role(&self) -> EventRoleKind { + self.role + } + + /// Return the hypothesized filler argument. + #[must_use] + pub fn argument(&self) -> &str { + &self.argument + } +} + +/// Threshold a slot-occupancy probability into a filled/empty label. +/// +/// The threshold is inclusive: `probability >= threshold` fills the slot. +#[must_use] +pub fn decide_schema_slot( + probability: EventConfidence, + threshold: EventConfidence, +) -> SchemaSlotLabel { + if probability.value() >= threshold.value() { + SchemaSlotLabel::Filled + } else { + SchemaSlotLabel::Empty + } +} + +/// Explicit refusal to treat a CHRONOS schema prediction as an event instance. +/// +/// # Errors +/// +/// Always returns [`EventError::SchemaPredictionIsNotEventInstance`]. +pub fn refuse_schema_prediction_as_instance( + _prediction: SchemaPredictionId, +) -> Result { + Err(EventError::SchemaPredictionIsNotEventInstance) +} + +/// Explicit refusal to treat a CHRONOS schema prediction as a state transition. +/// +/// # Errors +/// +/// Always returns [`EventError::SchemaPredictionIsNotStateTransition`]. +pub fn refuse_schema_prediction_as_transition( + _prediction: SchemaPredictionId, +) -> Result<(), EventError> { + Err(EventError::SchemaPredictionIsNotStateTransition) +} + +/// Precision of recovered filled slots against known truth fills. +/// +/// # Errors +/// +/// Returns [`EventError::InvalidWirePayload`] when either fill set is empty +/// or a `(role, argument)` pair is duplicated. +pub fn schema_slot_precision( + truth: &[SchemaSlotAssignment], + recovered: &[SchemaSlotAssignment], +) -> Result { + let truth_slots = unique_slot_set(truth)?; + let recovered_slots = unique_slot_set(recovered)?; + counted_rate( + recovered_slots.intersection(&truth_slots).count(), + recovered_slots.len(), + ) +} + +/// Recall of recovered filled slots against known truth fills. +/// +/// # Errors +/// +/// Returns [`EventError::InvalidWirePayload`] when either fill set is empty +/// or a `(role, argument)` pair is duplicated. +pub fn schema_slot_recall( + truth: &[SchemaSlotAssignment], + recovered: &[SchemaSlotAssignment], +) -> Result { + let truth_slots = unique_slot_set(truth)?; + let recovered_slots = unique_slot_set(recovered)?; + counted_rate( + recovered_slots.intersection(&truth_slots).count(), + truth_slots.len(), + ) +} + +fn unique_slot_set( + assignments: &[SchemaSlotAssignment], +) -> Result, EventError> { + if assignments.is_empty() { + return Err(EventError::InvalidWirePayload); + } + let mut slots = BTreeSet::new(); + for assignment in assignments { + if !slots.insert((assignment.role(), assignment.argument().to_string())) { + return Err(EventError::InvalidWirePayload); + } + } + Ok(slots) +} + +fn counted_rate(numerator: usize, denominator: usize) -> Result { + let numerator = u32::try_from(numerator).map_err(|_| EventError::InvalidWirePayload)?; + let denominator = u32::try_from(denominator).map_err(|_| EventError::InvalidWirePayload)?; + if denominator == 0 { + return Err(EventError::InvalidWirePayload); + } + Ok(f64::from(numerator) / f64::from(denominator)) +} + +#[cfg(test)] +mod tests { + use super::{ + SchemaPredictionId, SchemaSlotAssignment, SchemaSlotLabel, counted_rate, + decide_schema_slot, refuse_schema_prediction_as_instance, + refuse_schema_prediction_as_transition, schema_slot_precision, schema_slot_recall, + }; + use crate::{EventConfidence, EventError, EventRoleKind}; + + fn filled(role: EventRoleKind, argument: &str) -> SchemaSlotAssignment { + SchemaSlotAssignment::new(role, argument).expect("slot") + } + + #[test] + fn schema_helpers_cover_local_branches() { + let prediction = SchemaPredictionId::from_raw(3); + assert_eq!( + refuse_schema_prediction_as_instance(prediction), + Err(EventError::SchemaPredictionIsNotEventInstance) + ); + assert_eq!( + refuse_schema_prediction_as_transition(prediction), + Err(EventError::SchemaPredictionIsNotStateTransition) + ); + let high = EventConfidence::new(0.8).expect("high"); + let low = EventConfidence::new(0.2).expect("low"); + assert_eq!(decide_schema_slot(high, low), SchemaSlotLabel::Filled); + assert_eq!(decide_schema_slot(low, high), SchemaSlotLabel::Empty); + let truth = [filled(EventRoleKind::Agent, "procurement office")]; + assert!((schema_slot_precision(&truth, &truth).expect("p") - 1.0).abs() < f64::EPSILON); + assert!((schema_slot_recall(&truth, &truth).expect("r") - 1.0).abs() < f64::EPSILON); + assert_eq!(counted_rate(0, 0), Err(EventError::InvalidWirePayload)); + assert_eq!( + counted_rate(usize::MAX, 1), + Err(EventError::InvalidWirePayload) + ); + assert_eq!( + counted_rate(1, usize::MAX), + Err(EventError::InvalidWirePayload) + ); + assert!((counted_rate(1, 2).expect("half") - 0.5).abs() < f64::EPSILON); + } +} diff --git a/crates/event_core/src/segment.rs b/crates/event_core/src/segment.rs new file mode 100644 index 000000000..dfa9ba369 --- /dev/null +++ b/crates/event_core/src/segment.rs @@ -0,0 +1,375 @@ +//! TDT story-segmentation scores stay distinct from instances and transitions. + +use crate::{EventConfidence, EventError, EventInstanceId}; +use std::collections::BTreeSet; + +/// TDT story-boundary versus continuation label. +/// +/// A boundary decision is detection evidence. It is never a promoted event +/// instance and cannot create a forward state transition by itself. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum StoryBoundaryLabel { + /// A new story starts after this unit. + Boundary, + /// The next unit continues the current story. + Continuation, +} + +impl StoryBoundaryLabel { + /// Return the stable wire label name. + #[must_use] + pub const fn wire_name(self) -> &'static str { + match self { + Self::Boundary => "boundary", + Self::Continuation => "continuation", + } + } + + /// Parse a stable wire story-boundary label. + /// + /// # Errors + /// + /// Returns [`EventError::UnknownStoryBoundaryLabel`] for unrecognized names. + pub fn from_wire_name(name: &str) -> Result { + match name { + "boundary" => Ok(Self::Boundary), + "continuation" => Ok(Self::Continuation), + _ => Err(EventError::UnknownStoryBoundaryLabel), + } + } + + /// Return whether this label marks a story boundary. + #[must_use] + pub const fn is_boundary(self) -> bool { + matches!(self, Self::Boundary) + } + + /// Return the binary probability target used for RMSE. + /// + /// Boundary truth is `1.0`; continuation truth is `0.0`. + #[must_use] + pub const fn as_probability_target(self) -> f64 { + match self { + Self::Boundary => 1.0, + Self::Continuation => 0.0, + } + } +} + +/// Ordered TDT story/event segmentation of a measurement-unit sequence. +/// +/// `boundary_after[i]` is true when a new story starts after unit `i`. There +/// are `unit_count - 1` interior candidate boundaries. The partition is +/// detection evidence, not a promoted event instance. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct StorySegmentation { + unit_count: u32, + boundary_after: Vec, +} + +impl StorySegmentation { + /// Construct a validated segmentation of `unit_count` ordered units. + /// + /// # Errors + /// + /// Returns [`EventError::InvalidWirePayload`] when fewer than two units + /// are supplied or `boundary_after` is not exactly `unit_count - 1` long. + pub fn new(unit_count: u32, boundary_after: Vec) -> Result { + if unit_count < 2 { + return Err(EventError::InvalidWirePayload); + } + let expected = (unit_count - 1) as usize; + if boundary_after.len() != expected { + return Err(EventError::InvalidWirePayload); + } + Ok(Self { + unit_count, + boundary_after, + }) + } + + /// Return the number of ordered measurement units. + #[must_use] + pub const fn unit_count(&self) -> u32 { + self.unit_count + } + + /// Return interior boundary decisions after each unit except the last. + #[must_use] + pub fn boundary_after(&self) -> &[bool] { + &self.boundary_after + } +} + +/// Threshold a boundary probability into a detection label. +/// +/// The threshold is inclusive: `probability >= threshold` is a boundary. +#[must_use] +pub fn decide_story_boundary( + probability: EventConfidence, + threshold: EventConfidence, +) -> StoryBoundaryLabel { + if probability.value() >= threshold.value() { + StoryBoundaryLabel::Boundary + } else { + StoryBoundaryLabel::Continuation + } +} + +/// Explicit refusal to treat a TDT story segmentation as an event instance. +/// +/// # Errors +/// +/// Always returns [`EventError::StorySegmentationIsNotEventInstance`]. +pub fn refuse_story_segmentation_as_instance( + _segmentation: &StorySegmentation, +) -> Result { + Err(EventError::StorySegmentationIsNotEventInstance) +} + +/// Explicit refusal to treat a TDT story segmentation as a state transition. +/// +/// # Errors +/// +/// Always returns [`EventError::StorySegmentationIsNotStateTransition`]. +pub fn refuse_story_segmentation_as_transition( + _segmentation: &StorySegmentation, +) -> Result<(), EventError> { + Err(EventError::StorySegmentationIsNotStateTransition) +} + +/// Precision of recovered interior story boundaries against known truth. +/// +/// # Errors +/// +/// Returns [`EventError::InvalidWirePayload`] when the sequences differ in +/// length or the recovered set contains no boundary. +pub fn story_boundary_precision( + truth: &StorySegmentation, + recovered: &StorySegmentation, +) -> Result { + let (truth_set, recovered_set) = aligned_boundary_sets(truth, recovered)?; + counted_rate( + truth_set.intersection(&recovered_set).count(), + recovered_set.len(), + ) +} + +/// Recall of recovered interior story boundaries against known truth. +/// +/// # Errors +/// +/// Returns [`EventError::InvalidWirePayload`] when the sequences differ in +/// length or the truth set contains no boundary. +pub fn story_boundary_recall( + truth: &StorySegmentation, + recovered: &StorySegmentation, +) -> Result { + let (truth_set, recovered_set) = aligned_boundary_sets(truth, recovered)?; + counted_rate( + truth_set.intersection(&recovered_set).count(), + truth_set.len(), + ) +} + +/// Pevzner–Hearst `WindowDiff` between a known-truth and recovered segmentation. +/// +/// The window counts interior boundaries in each aligned span of `window` +/// units. A window mismatches when the counts differ. +/// +/// # Errors +/// +/// Returns [`EventError::InvalidWirePayload`] when the sequences differ in +/// length or `window` is zero or at least the unit count. +pub fn story_window_diff( + truth: &StorySegmentation, + recovered: &StorySegmentation, + window: u32, +) -> Result { + window_probe(truth, recovered, window, |truth_count, recovered_count| { + truth_count != recovered_count + }) +} + +/// Beeferman Pk between a known-truth and recovered segmentation. +/// +/// Probe pairs `window` units apart disagree when one partition places them +/// in the same story and the other does not. +/// +/// # Errors +/// +/// Returns [`EventError::InvalidWirePayload`] when the sequences differ in +/// length or `window` is zero or at least the unit count. +pub fn story_pk( + truth: &StorySegmentation, + recovered: &StorySegmentation, + window: u32, +) -> Result { + window_probe(truth, recovered, window, |truth_count, recovered_count| { + (truth_count == 0) != (recovered_count == 0) + }) +} + +fn aligned_boundary_sets( + truth: &StorySegmentation, + recovered: &StorySegmentation, +) -> Result<(BTreeSet, BTreeSet), EventError> { + if truth.unit_count != recovered.unit_count { + return Err(EventError::InvalidWirePayload); + } + Ok(( + boundary_indices(&truth.boundary_after), + boundary_indices(&recovered.boundary_after), + )) +} + +fn boundary_indices(boundary_after: &[bool]) -> BTreeSet { + boundary_after + .iter() + .enumerate() + .filter_map(|(index, is_boundary)| is_boundary.then_some(index)) + .collect() +} + +fn window_probe( + truth: &StorySegmentation, + recovered: &StorySegmentation, + window: u32, + disagree: impl Fn(usize, usize) -> bool, +) -> Result { + if truth.unit_count != recovered.unit_count || window == 0 || window >= truth.unit_count { + return Err(EventError::InvalidWirePayload); + } + let probe_count = (truth.unit_count - window) as usize; + let window = window as usize; + let truth_prefix = boundary_prefix_counts(&truth.boundary_after); + let recovered_prefix = boundary_prefix_counts(&recovered.boundary_after); + let mut disagreements = 0_usize; + for start in 0..probe_count { + let end = start + window; + if disagree( + usize::try_from(truth_prefix[end] - truth_prefix[start]) + .map_err(|_| EventError::InvalidWirePayload)?, + usize::try_from(recovered_prefix[end] - recovered_prefix[start]) + .map_err(|_| EventError::InvalidWirePayload)?, + ) { + disagreements += 1; + } + } + counted_rate(disagreements, probe_count) +} + +fn boundary_prefix_counts(boundary_after: &[bool]) -> Vec { + let mut prefix = Vec::with_capacity(boundary_after.len() + 1); + prefix.push(0); + for &is_boundary in boundary_after { + let next = prefix.last().copied().unwrap_or(0) + u32::from(is_boundary); + prefix.push(next); + } + prefix +} + +fn counted_rate(numerator: usize, denominator: usize) -> Result { + let numerator = u32::try_from(numerator).map_err(|_| EventError::InvalidWirePayload)?; + let denominator = u32::try_from(denominator).map_err(|_| EventError::InvalidWirePayload)?; + if denominator == 0 { + return Err(EventError::InvalidWirePayload); + } + Ok(f64::from(numerator) / f64::from(denominator)) +} + +#[cfg(test)] +mod tests { + use super::{ + StoryBoundaryLabel, StorySegmentation, counted_rate, decide_story_boundary, + refuse_story_segmentation_as_instance, refuse_story_segmentation_as_transition, + story_boundary_precision, story_boundary_recall, story_pk, story_window_diff, + }; + use crate::{EventConfidence, EventError}; + + #[test] + fn segmentation_helpers_cover_local_branches() { + let story = StorySegmentation::new(4, vec![false, true, false]).expect("story"); + assert_eq!(story.unit_count(), 4); + assert_eq!(story.boundary_after(), &[false, true, false]); + assert_eq!( + refuse_story_segmentation_as_instance(&story), + Err(EventError::StorySegmentationIsNotEventInstance) + ); + assert_eq!( + refuse_story_segmentation_as_transition(&story), + Err(EventError::StorySegmentationIsNotStateTransition) + ); + let high = EventConfidence::new(0.8).expect("high"); + let low = EventConfidence::new(0.2).expect("low"); + assert_eq!( + decide_story_boundary(high, low), + StoryBoundaryLabel::Boundary + ); + assert_eq!( + decide_story_boundary(low, high), + StoryBoundaryLabel::Continuation + ); + assert!((story_boundary_precision(&story, &story).expect("p") - 1.0).abs() < f64::EPSILON); + assert!((story_boundary_recall(&story, &story).expect("r") - 1.0).abs() < f64::EPSILON); + assert!(story_window_diff(&story, &story, 2).expect("wd").abs() < f64::EPSILON); + assert!(story_pk(&story, &story, 2).expect("pk").abs() < f64::EPSILON); + assert_eq!( + StorySegmentation::new(1, vec![]), + Err(EventError::InvalidWirePayload) + ); + assert_eq!( + StorySegmentation::new(4, vec![true]), + Err(EventError::InvalidWirePayload) + ); + let other = StorySegmentation::new(5, vec![false, true, false, false]).expect("other"); + assert_eq!( + story_boundary_precision(&story, &other), + Err(EventError::InvalidWirePayload) + ); + assert_eq!( + story_window_diff(&story, &other, 2), + Err(EventError::InvalidWirePayload) + ); + assert_eq!( + story_pk(&story, &story, 0), + Err(EventError::InvalidWirePayload) + ); + assert_eq!( + story_window_diff(&story, &story, 4), + Err(EventError::InvalidWirePayload) + ); + assert_eq!( + story_boundary_precision( + &story, + &StorySegmentation::new(4, vec![false, false, false]).expect("empty") + ), + Err(EventError::InvalidWirePayload) + ); + assert_eq!( + story_boundary_recall( + &StorySegmentation::new(4, vec![false, false, false]).expect("empty"), + &story + ), + Err(EventError::InvalidWirePayload) + ); + assert_eq!( + counted_rate(0, usize::MAX), + Err(EventError::InvalidWirePayload) + ); + assert_eq!(counted_rate(1, 0), Err(EventError::InvalidWirePayload)); + assert!((counted_rate(1, 2).expect("half") - 0.5).abs() < f64::EPSILON); + assert_eq!( + StoryBoundaryLabel::from_wire_name("cut"), + Err(EventError::UnknownStoryBoundaryLabel) + ); + assert_eq!(StoryBoundaryLabel::Boundary.wire_name(), "boundary"); + assert_eq!(StoryBoundaryLabel::Continuation.wire_name(), "continuation"); + assert!(StoryBoundaryLabel::Boundary.is_boundary()); + assert!(!StoryBoundaryLabel::Continuation.is_boundary()); + assert!((StoryBoundaryLabel::Boundary.as_probability_target() - 1.0).abs() < f64::EPSILON); + assert!( + (StoryBoundaryLabel::Continuation.as_probability_target() - 0.0).abs() < f64::EPSILON + ); + } +} diff --git a/crates/event_core/src/track.rs b/crates/event_core/src/track.rs new file mode 100644 index 000000000..d0e4ebcf7 --- /dev/null +++ b/crates/event_core/src/track.rs @@ -0,0 +1,484 @@ +//! TDT track assignments stay distinct from instances and transitions. + +use crate::{EventConfidence, EventError, EventInstanceId, EventMentionId}; +use std::collections::{BTreeMap, BTreeSet}; + +/// Opaque TDT track identity. +/// +/// A track is a hypothesized cluster of mentions over time. It is never a +/// promoted event instance and cannot create a forward state transition. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct EventTrackId(u32); + +impl EventTrackId { + /// Reconstruct a track identity from a raw fixture or estimator label. + #[must_use] + pub const fn from_raw(raw: u32) -> Self { + Self(raw) + } + + /// Return the raw track label. + #[must_use] + pub const fn raw(self) -> u32 { + self.0 + } +} + +/// TDT continue-versus-switch label for a mention relative to the prior track. +/// +/// A continue/switch decision is tracking evidence. It is never a promoted +/// event instance and cannot create a forward state transition by itself. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum EventTrackLabel { + /// The mention is scored as continuing the previous track. + Continue, + /// The mention is scored as a switch onto a different track. + Switch, +} + +impl EventTrackLabel { + /// Return the stable wire label name. + #[must_use] + pub const fn wire_name(self) -> &'static str { + match self { + Self::Continue => "continue", + Self::Switch => "switch", + } + } + + /// Parse a stable wire track label. + /// + /// # Errors + /// + /// Returns [`EventError::UnknownEventTrackLabel`] for unrecognized names. + pub fn from_wire_name(name: &str) -> Result { + match name { + "continue" => Ok(Self::Continue), + "switch" => Ok(Self::Switch), + _ => Err(EventError::UnknownEventTrackLabel), + } + } + + /// Return whether this label continues the previous track. + #[must_use] + pub const fn is_continue(self) -> bool { + matches!(self, Self::Continue) + } + + /// Return the binary probability target used for RMSE. + /// + /// Continue truth is `1.0`; switch truth is `0.0`. + #[must_use] + pub const fn as_probability_target(self) -> f64 { + match self { + Self::Continue => 1.0, + Self::Switch => 0.0, + } + } +} + +/// Assignment of one mention to one hypothesized TDT track. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct EventTrackAssignment { + mention_id: EventMentionId, + track_id: EventTrackId, +} + +impl EventTrackAssignment { + /// Bind a mention to a hypothesized track. + #[must_use] + pub const fn new(mention_id: EventMentionId, track_id: EventTrackId) -> Self { + Self { + mention_id, + track_id, + } + } + + /// Return the assigned mention identity. + #[must_use] + pub const fn mention_id(self) -> EventMentionId { + self.mention_id + } + + /// Return the hypothesized track identity. + #[must_use] + pub const fn track_id(self) -> EventTrackId { + self.track_id + } +} + +/// Threshold a same-track probability into a continue/switch label. +/// +/// The threshold is inclusive: `probability >= threshold` continues the track. +#[must_use] +pub fn decide_track_continue( + probability: EventConfidence, + threshold: EventConfidence, +) -> EventTrackLabel { + if probability.value() >= threshold.value() { + EventTrackLabel::Continue + } else { + EventTrackLabel::Switch + } +} + +/// Explicit refusal to treat a TDT track as an event instance. +/// +/// # Errors +/// +/// Always returns [`EventError::EventTrackIsNotEventInstance`]. +pub fn refuse_track_as_instance(_track: EventTrackId) -> Result { + Err(EventError::EventTrackIsNotEventInstance) +} + +/// Explicit refusal to treat a TDT track as a state transition. +/// +/// # Errors +/// +/// Always returns [`EventError::EventTrackIsNotStateTransition`]. +pub fn refuse_track_as_transition(_track: EventTrackId) -> Result<(), EventError> { + Err(EventError::EventTrackIsNotStateTransition) +} + +/// Precision of recovered same-track mention pairs against known truth. +/// +/// # Errors +/// +/// Returns [`EventError::InvalidWirePayload`] when assignments are empty, +/// mention identities collide, lengths differ, mention identity sets disagree +/// between truth and recovered, or either pair set is empty. +pub fn tracking_pair_precision( + truth: &[EventTrackAssignment], + recovered: &[EventTrackAssignment], +) -> Result { + let (truth_pairs, recovered_pairs) = aligned_pair_sets(truth, recovered)?; + counted_rate( + recovered_pairs.intersection(&truth_pairs).count(), + recovered_pairs.len(), + ) +} + +/// Recall of recovered same-track mention pairs against known truth. +/// +/// # Errors +/// +/// Returns [`EventError::InvalidWirePayload`] when assignments are empty, +/// mention identities collide, lengths differ, mention identity sets disagree +/// between truth and recovered, or either pair set is empty. +pub fn tracking_pair_recall( + truth: &[EventTrackAssignment], + recovered: &[EventTrackAssignment], +) -> Result { + let (truth_pairs, recovered_pairs) = aligned_pair_sets(truth, recovered)?; + counted_rate( + recovered_pairs.intersection(&truth_pairs).count(), + truth_pairs.len(), + ) +} + +/// Identity-switch rate among consecutive mentions that share a truth track. +/// +/// # Errors +/// +/// Returns [`EventError::InvalidWirePayload`] when streams are empty, lengths +/// differ, mention identities collide or disagree, or no consecutive truth +/// pair stays on the same track. +pub fn tracking_identity_switch_rate( + truth: &[EventTrackAssignment], + recovered: &[EventTrackAssignment], +) -> Result { + if truth.is_empty() || truth.len() != recovered.len() { + return Err(EventError::InvalidWirePayload); + } + let truth_map = unique_assignment_map(truth)?; + let recovered_map = unique_assignment_map(recovered)?; + let mut stay_count = 0_u32; + let mut switch_count = 0_u32; + for window in truth.windows(2) { + let left = window[0].mention_id(); + let right = window[1].mention_id(); + if truth_map.get(&left) != truth_map.get(&right) { + continue; + } + stay_count += 1; + let recovered_left = recovered_map + .get(&left) + .ok_or(EventError::InvalidWirePayload)?; + let recovered_right = recovered_map + .get(&right) + .ok_or(EventError::InvalidWirePayload)?; + if recovered_left != recovered_right { + switch_count += 1; + } + } + if stay_count == 0 { + return Err(EventError::InvalidWirePayload); + } + Ok(f64::from(switch_count) / f64::from(stay_count)) +} + +fn unique_assignment_map( + assignments: &[EventTrackAssignment], +) -> Result, EventError> { + if assignments.is_empty() { + return Err(EventError::InvalidWirePayload); + } + let mut map = BTreeMap::new(); + for assignment in assignments { + if map + .insert(assignment.mention_id(), assignment.track_id()) + .is_some() + { + return Err(EventError::InvalidWirePayload); + } + } + Ok(map) +} + +/// Same-track mention-pair set derived from one unique assignment map. +type SameTrackPairSet = BTreeSet<(EventMentionId, EventMentionId)>; + +fn same_track_pairs( + map: &BTreeMap, +) -> Result { + let mut pairs = BTreeSet::new(); + let mentions: Vec = map.keys().copied().collect(); + for (index, left) in mentions.iter().enumerate() { + for right in mentions.iter().skip(index + 1) { + if map.get(left) == map.get(right) { + pairs.insert((*left, *right)); + } + } + } + if pairs.is_empty() { + return Err(EventError::InvalidWirePayload); + } + Ok(pairs) +} + +/// Build both same-track pair sets only after the two sides describe the +/// identical mention identity universe. +/// +/// Equal-length slices carrying different mention identifier sets would +/// otherwise yield disjoint pair sets and silently report a zero rate; this +/// helper refuses such payloads before any rate is computed. +/// +/// # Errors +/// +/// Returns [`EventError::InvalidWirePayload`] when either side is empty, +/// carries duplicate mention identities, or the mention identity key sets of +/// truth and recovered differ. +fn aligned_pair_sets( + truth: &[EventTrackAssignment], + recovered: &[EventTrackAssignment], +) -> Result<(SameTrackPairSet, SameTrackPairSet), EventError> { + let truth_map = unique_assignment_map(truth)?; + let recovered_map = unique_assignment_map(recovered)?; + if truth_map.len() != recovered_map.len() + || !truth_map + .keys() + .zip(recovered_map.keys()) + .all(|(truth_key, recovered_key)| truth_key == recovered_key) + { + return Err(EventError::InvalidWirePayload); + } + Ok(( + same_track_pairs(&truth_map)?, + same_track_pairs(&recovered_map)?, + )) +} + +fn counted_rate(numerator: usize, denominator: usize) -> Result { + let numerator = u32::try_from(numerator).map_err(|_| EventError::InvalidWirePayload)?; + let denominator = u32::try_from(denominator).map_err(|_| EventError::InvalidWirePayload)?; + if denominator == 0 { + return Err(EventError::InvalidWirePayload); + } + Ok(f64::from(numerator) / f64::from(denominator)) +} + +#[cfg(test)] +mod tests { + use super::{ + EventTrackAssignment, EventTrackId, EventTrackLabel, counted_rate, decide_track_continue, + refuse_track_as_instance, refuse_track_as_transition, tracking_identity_switch_rate, + tracking_pair_precision, tracking_pair_recall, + }; + use crate::{EventConfidence, EventError, EventMentionId}; + + fn assigned(mention_id: EventMentionId, track: u32) -> EventTrackAssignment { + EventTrackAssignment::new(mention_id, EventTrackId::from_raw(track)) + } + + #[test] + fn track_helpers_cover_local_branches() { + let track = EventTrackId::from_raw(3); + assert_eq!( + refuse_track_as_instance(track), + Err(EventError::EventTrackIsNotEventInstance) + ); + assert_eq!( + refuse_track_as_transition(track), + Err(EventError::EventTrackIsNotStateTransition) + ); + let high = EventConfidence::new(0.8).expect("high"); + let low = EventConfidence::new(0.2).expect("low"); + assert_eq!(decide_track_continue(high, low), EventTrackLabel::Continue); + assert_eq!(decide_track_continue(low, high), EventTrackLabel::Switch); + let left = EventMentionId::new(); + let right = EventMentionId::new(); + let truth = [assigned(left, 1), assigned(right, 1)]; + assert!((tracking_pair_precision(&truth, &truth).expect("p") - 1.0).abs() < f64::EPSILON); + assert!((tracking_pair_recall(&truth, &truth).expect("r") - 1.0).abs() < f64::EPSILON); + assert!( + (tracking_identity_switch_rate(&truth, &truth).expect("s") - 0.0).abs() < f64::EPSILON + ); + let switched = [assigned(left, 1), assigned(right, 2)]; + assert!( + (tracking_identity_switch_rate(&truth, &switched).expect("sw") - 1.0).abs() + < f64::EPSILON + ); + assert_eq!( + counted_rate(0, usize::MAX), + Err(EventError::InvalidWirePayload) + ); + assert_eq!( + counted_rate(usize::MAX, 1), + Err(EventError::InvalidWirePayload) + ); + assert_eq!(counted_rate(1, 0), Err(EventError::InvalidWirePayload)); + cover_fail_closed_assignment_streams(left, right); + } + + #[test] + fn pair_metrics_refuse_disjoint_mention_sets_at_equal_length() { + let left = EventMentionId::new(); + let right = EventMentionId::new(); + let stranger_a = EventMentionId::new(); + let stranger_b = EventMentionId::new(); + let truth = [assigned(left, 1), assigned(right, 1)]; + let recovered = [assigned(stranger_a, 1), assigned(stranger_b, 1)]; + assert_eq!(truth.len(), recovered.len()); + assert_eq!( + tracking_pair_precision(&truth, &recovered), + Err(EventError::InvalidWirePayload) + ); + assert_eq!( + tracking_pair_recall(&truth, &recovered), + Err(EventError::InvalidWirePayload) + ); + } + + #[test] + fn pair_metrics_keep_values_for_identical_mention_sets() { + let first = EventMentionId::new(); + let second = EventMentionId::new(); + let third = EventMentionId::new(); + let fourth = EventMentionId::new(); + let fifth = EventMentionId::new(); + // Truth pairs: {(first,second), (third,fourth)}. + let truth = [ + assigned(first, 1), + assigned(second, 1), + assigned(third, 2), + assigned(fourth, 2), + assigned(fifth, 3), + ]; + // Recovered pairs over the identical mention universe: + // {(first,second), (fourth,fifth)} -> half of each set overlaps. + let recovered = [ + assigned(first, 1), + assigned(second, 1), + assigned(third, 3), + assigned(fourth, 2), + assigned(fifth, 2), + ]; + let precision = tracking_pair_precision(&truth, &recovered).expect("precision"); + let recall = tracking_pair_recall(&truth, &recovered).expect("recall"); + assert!((precision - 0.5).abs() < f64::EPSILON); + assert!((recall - 0.5).abs() < f64::EPSILON); + } + + fn cover_fail_closed_assignment_streams(left: EventMentionId, right: EventMentionId) { + let truth = [assigned(left, 1), assigned(right, 1)]; + let switched = [assigned(left, 1), assigned(right, 2)]; + let third = EventMentionId::new(); + let fourth = EventMentionId::new(); + let three = [assigned(left, 1), assigned(right, 1), assigned(third, 2)]; + let four = [ + assigned(left, 1), + assigned(right, 1), + assigned(third, 2), + assigned(fourth, 2), + ]; + assert_eq!( + tracking_pair_precision(&truth, &[]), + Err(EventError::InvalidWirePayload) + ); + assert_eq!( + tracking_identity_switch_rate(&truth, &[]), + Err(EventError::InvalidWirePayload) + ); + assert_eq!( + unique_missing_recovered_switch(), + Err(EventError::InvalidWirePayload) + ); + assert_eq!( + tracking_pair_precision(&three, &four), + Err(EventError::InvalidWirePayload) + ); + assert_eq!( + tracking_pair_recall(&three, &four), + Err(EventError::InvalidWirePayload) + ); + assert_eq!( + tracking_pair_recall(&truth, &switched), + Err(EventError::InvalidWirePayload) + ); + assert_eq!( + tracking_identity_switch_rate(&[assigned(left, 1), assigned(left, 2)], &truth), + Err(EventError::InvalidWirePayload) + ); + assert_eq!( + tracking_identity_switch_rate(&truth, &[assigned(left, 1), assigned(left, 1)]), + Err(EventError::InvalidWirePayload) + ); + assert_eq!( + tracking_identity_switch_rate( + &truth, + &[ + assigned(EventMentionId::new(), 1), + assigned(EventMentionId::new(), 1) + ] + ), + Err(EventError::InvalidWirePayload) + ); + assert_eq!( + tracking_identity_switch_rate(&[], &[]), + Err(EventError::InvalidWirePayload) + ); + assert!( + (tracking_identity_switch_rate(&three, &three).expect("changed") - 0.0).abs() + < f64::EPSILON + ); + assert_eq!( + tracking_identity_switch_rate(&[assigned(left, 1)], &[assigned(left, 1)]), + Err(EventError::InvalidWirePayload) + ); + } + + fn unique_missing_recovered_switch() -> Result { + let left = EventMentionId::new(); + let right = EventMentionId::new(); + let extra = EventMentionId::new(); + let truth = [ + EventTrackAssignment::new(left, EventTrackId::from_raw(1)), + EventTrackAssignment::new(right, EventTrackId::from_raw(1)), + ]; + let recovered = [ + EventTrackAssignment::new(left, EventTrackId::from_raw(1)), + EventTrackAssignment::new(extra, EventTrackId::from_raw(1)), + ]; + tracking_identity_switch_rate(&truth, &recovered) + } +} diff --git a/crates/event_core/tests/prediction_calibration_contract.rs b/crates/event_core/tests/prediction_calibration_contract.rs new file mode 100644 index 000000000..fde98e1c9 --- /dev/null +++ b/crates/event_core/tests/prediction_calibration_contract.rs @@ -0,0 +1,59 @@ +//! CHRONOS occurrence forecasts stay hypothetical and recover Brier scores. + +use event_core::{ + ChronosOccurrenceForecast, ChronosPredictionId, EventConfidence, EventError, OccurrenceTruth, + chronos_prediction_brier_score, refuse_prediction_as_instance, +}; + +fn forecast(raw: u32, probability: f64) -> ChronosOccurrenceForecast { + ChronosOccurrenceForecast::new( + ChronosPredictionId::from_raw(raw), + EventConfidence::new(probability).expect("probability"), + ) +} + +#[test] +fn chronos_prediction_cannot_be_cast_to_an_instance() { + let prediction = ChronosPredictionId::from_raw(7); + assert_eq!( + refuse_prediction_as_instance(prediction), + Err(EventError::PredictionIsNotEventInstance) + ); +} + +#[test] +fn perfect_occurrence_forecasts_recover_zero_brier() { + let forecasts = [forecast(1, 1.0), forecast(2, 0.0), forecast(3, 1.0)]; + let outcomes = [ + OccurrenceTruth::Occurred, + OccurrenceTruth::DidNotOccur, + OccurrenceTruth::Occurred, + ]; + let score = chronos_prediction_brier_score(&forecasts, &outcomes).expect("brier"); + assert!(score.abs() < 1e-15, "perfect Brier {score}"); +} + +#[test] +fn calibrated_forecasts_beat_overconfident_always_occur_and_mismatches_fail_closed() { + let calibrated = [forecast(1, 0.8), forecast(2, 0.2), forecast(3, 0.7)]; + let overconfident = [forecast(1, 1.0), forecast(2, 1.0), forecast(3, 1.0)]; + let outcomes = [ + OccurrenceTruth::Occurred, + OccurrenceTruth::DidNotOccur, + OccurrenceTruth::Occurred, + ]; + let calibrated_brier = chronos_prediction_brier_score(&calibrated, &outcomes).expect("cal"); + let naive_brier = chronos_prediction_brier_score(&overconfident, &outcomes).expect("naive"); + assert!( + calibrated_brier < naive_brier, + "calibrated Brier {calibrated_brier} must be below always-occur Brier {naive_brier}" + ); + assert_eq!( + chronos_prediction_brier_score(&calibrated, &[OccurrenceTruth::Occurred]), + Err(EventError::InvalidWirePayload) + ); + assert_eq!( + chronos_prediction_brier_score(&[], &[]), + Err(EventError::InvalidWirePayload) + ); +} diff --git a/crates/event_core/tests/schema_slot_contract.rs b/crates/event_core/tests/schema_slot_contract.rs new file mode 100644 index 000000000..6afa450bb --- /dev/null +++ b/crates/event_core/tests/schema_slot_contract.rs @@ -0,0 +1,180 @@ +//! CHRONOS schema-slot predictions are not instances; accuracy is computed from truth. + +use event_core::{ + EventConfidence, EventError, EventRoleKind, SchemaPredictionId, SchemaSlotAssignment, + SchemaSlotLabel, decide_schema_slot, refuse_schema_prediction_as_instance, + refuse_schema_prediction_as_transition, schema_slot_precision, schema_slot_recall, +}; + +fn computed_rmse(truth: &[f64], recovered: &[f64]) -> f64 { + assert_eq!(truth.len(), recovered.len()); + let n = f64::from(u32::try_from(truth.len()).expect("tiny fixture")); + let sse: f64 = truth + .iter() + .zip(recovered) + .map(|(truth_value, recovered_value)| { + let residual = truth_value - recovered_value; + residual * residual + }) + .sum(); + (sse / n).sqrt() +} + +fn slot(role: EventRoleKind, argument: &str) -> SchemaSlotAssignment { + SchemaSlotAssignment::new(role, argument).expect("slot") +} + +#[test] +fn schema_prediction_cannot_be_cast_to_an_instance_or_transition() { + let prediction = SchemaPredictionId::from_raw(1); + assert_eq!( + refuse_schema_prediction_as_instance(prediction), + Err(EventError::SchemaPredictionIsNotEventInstance) + ); + assert_eq!( + refuse_schema_prediction_as_transition(prediction), + Err(EventError::SchemaPredictionIsNotStateTransition) + ); +} + +#[test] +fn slot_precision_and_recall_are_computed_from_known_truth_fills() { + let truth = [ + slot(EventRoleKind::Agent, "procurement office"), + slot(EventRoleKind::Product, "contract award"), + ]; + let calibrated = [ + slot(EventRoleKind::Agent, "procurement office"), + slot(EventRoleKind::Product, "contract award"), + slot(EventRoleKind::Place, "seoul"), + ]; + let always_fill = [ + slot(EventRoleKind::Agent, "procurement office"), + slot(EventRoleKind::Product, "contract award"), + slot(EventRoleKind::Place, "seoul"), + slot(EventRoleKind::Patient, "vendor"), + slot(EventRoleKind::Factor, "budget"), + slot(EventRoleKind::Instrument, "tender"), + ]; + + let calibrated_precision = schema_slot_precision(&truth, &calibrated).expect("precision"); + let naive_precision = schema_slot_precision(&truth, &always_fill).expect("naive p"); + let calibrated_recall = schema_slot_recall(&truth, &calibrated).expect("recall"); + let naive_recall = schema_slot_recall(&truth, &always_fill).expect("naive r"); + + assert!((calibrated_precision - (2.0 / 3.0)).abs() < 1.0e-12); + assert!((naive_precision - (1.0 / 3.0)).abs() < 1.0e-12); + assert!((calibrated_recall - 1.0).abs() < f64::EPSILON); + assert!((naive_recall - 1.0).abs() < f64::EPSILON); +} + +#[test] +fn calibrated_slot_occupancy_scores_have_lower_rmse_than_always_fill() { + let fixture = [ + ( + SchemaSlotLabel::Filled, + EventConfidence::new(0.90).expect("score"), + ), + ( + SchemaSlotLabel::Filled, + EventConfidence::new(0.85).expect("score"), + ), + ( + SchemaSlotLabel::Empty, + EventConfidence::new(0.15).expect("score"), + ), + ( + SchemaSlotLabel::Empty, + EventConfidence::new(0.10).expect("score"), + ), + ( + SchemaSlotLabel::Empty, + EventConfidence::new(0.20).expect("score"), + ), + ( + SchemaSlotLabel::Filled, + EventConfidence::new(0.88).expect("score"), + ), + ]; + let truth: Vec = fixture + .iter() + .map(|(label, _)| label.as_probability_target()) + .collect(); + let calibrated: Vec = fixture + .iter() + .map(|(_, confidence)| confidence.value()) + .collect(); + let always_fill = vec![1.0_f64; fixture.len()]; + let calibrated_rmse = computed_rmse(&truth, &calibrated); + let naive_rmse = computed_rmse(&truth, &always_fill); + assert!((calibrated_rmse - 0.141_067_359_796_658_94).abs() < 1.0e-12); + assert!((naive_rmse - std::f64::consts::FRAC_1_SQRT_2).abs() < 1.0e-12); + assert!( + calibrated_rmse < naive_rmse, + "computed calibrated RMSE {calibrated_rmse} must be below always-fill RMSE {naive_rmse}" + ); +} + +#[test] +fn assignment_helpers_fail_closed_on_empty_duplicate_and_blank_arguments() { + let one = [slot(EventRoleKind::Agent, "procurement office")]; + assert_eq!( + schema_slot_precision(&[], &[]), + Err(EventError::InvalidWirePayload) + ); + assert_eq!( + schema_slot_recall(&[], &one), + Err(EventError::InvalidWirePayload) + ); + assert_eq!( + schema_slot_precision(&one, &[]), + Err(EventError::InvalidWirePayload) + ); + let duplicate = [ + slot(EventRoleKind::Agent, "procurement office"), + slot(EventRoleKind::Agent, "procurement office"), + ]; + assert_eq!( + schema_slot_recall(&duplicate, &duplicate), + Err(EventError::InvalidWirePayload) + ); + assert_eq!( + SchemaSlotAssignment::new(EventRoleKind::Agent, " "), + Err(EventError::InvalidWirePayload) + ); + assert!((schema_slot_precision(&one, &one).expect("singleton") - 1.0).abs() < f64::EPSILON); +} + +#[test] +fn labels_round_trip_and_threshold_is_inclusive() { + assert_eq!(SchemaSlotLabel::Filled.wire_name(), "filled"); + assert_eq!(SchemaSlotLabel::Empty.wire_name(), "empty"); + assert_eq!( + SchemaSlotLabel::from_wire_name("filled").expect("parse"), + SchemaSlotLabel::Filled + ); + assert_eq!( + SchemaSlotLabel::from_wire_name("empty").expect("parse"), + SchemaSlotLabel::Empty + ); + assert_eq!( + SchemaSlotLabel::from_wire_name("maybe_slot"), + Err(EventError::UnknownSchemaSlotLabel) + ); + assert!(SchemaSlotLabel::Filled.is_filled()); + assert!(!SchemaSlotLabel::Empty.is_filled()); + assert!((SchemaSlotLabel::Filled.as_probability_target() - 1.0).abs() < f64::EPSILON); + assert!((SchemaSlotLabel::Empty.as_probability_target() - 0.0).abs() < f64::EPSILON); + + let half = EventConfidence::new(0.5).expect("half"); + assert_eq!(decide_schema_slot(half, half), SchemaSlotLabel::Filled); + assert_eq!( + decide_schema_slot(EventConfidence::new(0.49).expect("below"), half), + SchemaSlotLabel::Empty + ); + + let assigned = slot(EventRoleKind::Place, "seoul"); + assert_eq!(assigned.role(), EventRoleKind::Place); + assert_eq!(assigned.argument(), "seoul"); + assert_eq!(SchemaPredictionId::from_raw(7).raw(), 7); +} diff --git a/crates/event_core/tests/story_segmentation_contract.rs b/crates/event_core/tests/story_segmentation_contract.rs new file mode 100644 index 000000000..66baa31c3 --- /dev/null +++ b/crates/event_core/tests/story_segmentation_contract.rs @@ -0,0 +1,164 @@ +//! TDT story segments are not instances; `WindowDiff` and `Pk` come from truth. + +use event_core::{ + EventConfidence, EventError, StoryBoundaryLabel, StorySegmentation, decide_story_boundary, + refuse_story_segmentation_as_instance, refuse_story_segmentation_as_transition, + story_boundary_precision, story_boundary_recall, story_pk, story_window_diff, +}; + +fn computed_rmse(truth: &[f64], recovered: &[f64]) -> f64 { + assert_eq!(truth.len(), recovered.len()); + let n = f64::from(u32::try_from(truth.len()).expect("tiny fixture")); + let sse: f64 = truth + .iter() + .zip(recovered) + .map(|(truth_value, recovered_value)| { + let residual = truth_value - recovered_value; + residual * residual + }) + .sum(); + (sse / n).sqrt() +} + +fn segmentation(unit_count: u32, boundary_after: &[bool]) -> StorySegmentation { + StorySegmentation::new(unit_count, boundary_after.to_vec()).expect("segmentation") +} + +#[test] +fn story_segmentation_cannot_be_cast_to_an_instance_or_transition() { + let story = segmentation(4, &[false, true, false]); + assert_eq!( + refuse_story_segmentation_as_instance(&story), + Err(EventError::StorySegmentationIsNotEventInstance) + ); + assert_eq!( + refuse_story_segmentation_as_transition(&story), + Err(EventError::StorySegmentationIsNotStateTransition) + ); +} + +#[test] +fn window_diff_and_pk_are_computed_from_known_truth_boundaries() { + let truth = segmentation( + 10, + &[false, false, false, false, true, false, false, false, false], + ); + let calibrated = segmentation( + 10, + &[false, false, false, true, false, false, false, false, false], + ); + let always_cut = segmentation(10, &[true, true, true, true, true, true, true, true, true]); + + let calibrated_wd = story_window_diff(&truth, &calibrated, 3).expect("window-diff"); + let naive_wd = story_window_diff(&truth, &always_cut, 3).expect("naive window-diff"); + let calibrated_pk = story_pk(&truth, &calibrated, 3).expect("pk"); + let naive_pk = story_pk(&truth, &always_cut, 3).expect("naive pk"); + + assert!((calibrated_wd - (2.0 / 7.0)).abs() < 1.0e-12); + assert!((naive_wd - 1.0).abs() < f64::EPSILON); + assert!((calibrated_pk - (2.0 / 7.0)).abs() < 1.0e-12); + assert!((naive_pk - (4.0 / 7.0)).abs() < 1.0e-12); + assert!( + story_window_diff(&truth, &truth, 3) + .expect("identity") + .abs() + < f64::EPSILON + ); + assert!(story_pk(&truth, &truth, 3).expect("identity pk").abs() < f64::EPSILON); +} + +#[test] +fn boundary_precision_and_recall_are_computed_from_known_truth() { + let truth = segmentation(8, &[false, false, true, false, false, true, false]); + let calibrated = segmentation(8, &[false, false, true, false, false, false, false]); + let always_cut = segmentation(8, &[true, true, true, true, true, true, true]); + + let calibrated_precision = story_boundary_precision(&truth, &calibrated).expect("precision"); + let naive_precision = story_boundary_precision(&truth, &always_cut).expect("naive precision"); + let calibrated_recall = story_boundary_recall(&truth, &calibrated).expect("recall"); + let naive_recall = story_boundary_recall(&truth, &always_cut).expect("naive recall"); + + assert!((calibrated_precision - 1.0).abs() < f64::EPSILON); + assert!((naive_precision - (2.0 / 7.0)).abs() < 1.0e-12); + assert!((calibrated_recall - 0.5).abs() < f64::EPSILON); + assert!((naive_recall - 1.0).abs() < f64::EPSILON); +} + +#[test] +fn calibrated_boundary_scores_have_lower_rmse_than_always_cut() { + let truth = [0.0_f64, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0]; + let calibrated = [0.10_f64, 0.15, 0.90, 0.20, 0.05, 0.85, 0.10]; + let always_cut = [1.0_f64, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]; + let calibrated_rmse = computed_rmse(&truth, &calibrated); + let naive_rmse = computed_rmse(&truth, &always_cut); + assert!( + calibrated_rmse < naive_rmse, + "computed calibrated RMSE {calibrated_rmse} must be below always-cut RMSE {naive_rmse}" + ); +} + +#[test] +fn segmentation_helpers_fail_closed_on_empty_mismatched_and_oversize_windows() { + assert_eq!( + StorySegmentation::new(1, vec![]), + Err(EventError::InvalidWirePayload) + ); + assert_eq!( + StorySegmentation::new(4, vec![true]), + Err(EventError::InvalidWirePayload) + ); + let truth = segmentation(4, &[false, true, false]); + let recovered = segmentation(5, &[false, true, false, false]); + assert_eq!( + story_window_diff(&truth, &recovered, 2), + Err(EventError::InvalidWirePayload) + ); + assert_eq!( + story_window_diff(&truth, &truth, 0), + Err(EventError::InvalidWirePayload) + ); + assert_eq!( + story_window_diff(&truth, &truth, 4), + Err(EventError::InvalidWirePayload) + ); + assert_eq!( + story_boundary_precision(&truth, &segmentation(4, &[false, false, false])), + Err(EventError::InvalidWirePayload) + ); + assert_eq!( + story_boundary_recall(&segmentation(4, &[false, false, false]), &truth), + Err(EventError::InvalidWirePayload) + ); +} + +#[test] +fn labels_round_trip_and_threshold_is_inclusive() { + assert_eq!(StoryBoundaryLabel::Boundary.wire_name(), "boundary"); + assert_eq!(StoryBoundaryLabel::Continuation.wire_name(), "continuation"); + assert_eq!( + StoryBoundaryLabel::from_wire_name("boundary").expect("parse"), + StoryBoundaryLabel::Boundary + ); + assert_eq!( + StoryBoundaryLabel::from_wire_name("continuation").expect("parse"), + StoryBoundaryLabel::Continuation + ); + assert_eq!( + StoryBoundaryLabel::from_wire_name("cut"), + Err(EventError::UnknownStoryBoundaryLabel) + ); + assert!(StoryBoundaryLabel::Boundary.is_boundary()); + assert!(!StoryBoundaryLabel::Continuation.is_boundary()); + assert!((StoryBoundaryLabel::Boundary.as_probability_target() - 1.0).abs() < f64::EPSILON); + assert!((StoryBoundaryLabel::Continuation.as_probability_target() - 0.0).abs() < f64::EPSILON); + + let half = EventConfidence::new(0.5).expect("half"); + assert_eq!( + decide_story_boundary(half, half), + StoryBoundaryLabel::Boundary + ); + assert_eq!( + decide_story_boundary(EventConfidence::new(0.49).expect("below"), half), + StoryBoundaryLabel::Continuation + ); +} diff --git a/crates/event_core/tests/tracking_contract.rs b/crates/event_core/tests/tracking_contract.rs new file mode 100644 index 000000000..b22500d30 --- /dev/null +++ b/crates/event_core/tests/tracking_contract.rs @@ -0,0 +1,211 @@ +//! TDT tracks are not instances; pair P/R and switch rate come from truth. + +use event_core::{ + EventConfidence, EventError, EventMentionId, EventTrackAssignment, EventTrackId, + EventTrackLabel, decide_track_continue, refuse_track_as_instance, refuse_track_as_transition, + tracking_identity_switch_rate, tracking_pair_precision, tracking_pair_recall, +}; + +fn computed_rmse(truth: &[f64], recovered: &[f64]) -> f64 { + assert_eq!(truth.len(), recovered.len()); + let n = f64::from(u32::try_from(truth.len()).expect("tiny fixture")); + let sse: f64 = truth + .iter() + .zip(recovered) + .map(|(truth_value, recovered_value)| { + let residual = truth_value - recovered_value; + residual * residual + }) + .sum(); + (sse / n).sqrt() +} + +fn mention() -> EventMentionId { + EventMentionId::new() +} + +fn assignment(mention_id: EventMentionId, track: u32) -> EventTrackAssignment { + EventTrackAssignment::new(mention_id, EventTrackId::from_raw(track)) +} + +#[test] +fn event_track_cannot_be_cast_to_an_instance_or_transition() { + let track = EventTrackId::from_raw(1); + assert_eq!( + refuse_track_as_instance(track), + Err(EventError::EventTrackIsNotEventInstance) + ); + assert_eq!( + refuse_track_as_transition(track), + Err(EventError::EventTrackIsNotStateTransition) + ); +} + +#[test] +fn pair_precision_and_recall_are_computed_from_known_truth_assignments() { + let a = mention(); + let b = mention(); + let c = mention(); + let d = mention(); + let truth = [ + assignment(a, 1), + assignment(b, 1), + assignment(c, 2), + assignment(d, 2), + ]; + let calibrated = [ + assignment(a, 1), + assignment(b, 1), + assignment(c, 2), + assignment(d, 3), + ]; + let always_one_track = [ + assignment(a, 1), + assignment(b, 1), + assignment(c, 1), + assignment(d, 1), + ]; + + let calibrated_precision = tracking_pair_precision(&truth, &calibrated).expect("precision"); + let naive_precision = tracking_pair_precision(&truth, &always_one_track).expect("naive p"); + let calibrated_recall = tracking_pair_recall(&truth, &calibrated).expect("recall"); + let naive_recall = tracking_pair_recall(&truth, &always_one_track).expect("naive r"); + + assert!( + calibrated_precision > naive_precision, + "computed precision {calibrated_precision} must exceed always-one-track precision {naive_precision}" + ); + assert!(calibrated_recall <= naive_recall); +} + +#[test] +fn identity_switch_rate_is_lower_for_stable_tracks_than_always_switch() { + let a = mention(); + let b = mention(); + let c = mention(); + let d = mention(); + let truth = [ + assignment(a, 1), + assignment(b, 1), + assignment(c, 2), + assignment(d, 2), + ]; + let stable = truth; + let always_switch = [ + assignment(a, 1), + assignment(b, 2), + assignment(c, 3), + assignment(d, 4), + ]; + + let stable_rate = tracking_identity_switch_rate(&truth, &stable).expect("stable"); + let switch_rate = tracking_identity_switch_rate(&truth, &always_switch).expect("switch"); + assert!( + stable_rate < switch_rate, + "computed switch rate {stable_rate} must be below always-switch rate {switch_rate}" + ); +} + +#[test] +fn calibrated_same_track_scores_have_lower_rmse_than_always_one_track() { + let truth = [1.0_f64, 1.0, 0.0, 0.0, 0.0, 1.0]; + let calibrated = [0.90_f64, 0.85, 0.15, 0.10, 0.20, 0.88]; + let always_one = [1.0_f64, 1.0, 1.0, 1.0, 1.0, 1.0]; + let calibrated_rmse = computed_rmse(&truth, &calibrated); + let naive_rmse = computed_rmse(&truth, &always_one); + assert!( + calibrated_rmse < naive_rmse, + "computed calibrated RMSE {calibrated_rmse} must be below always-one-track RMSE {naive_rmse}" + ); +} + +#[test] +fn assignment_helpers_fail_closed_on_empty_mismatch_duplicate_and_missing_pairs() { + let a = mention(); + let b = mention(); + let one = [assignment(a, 1)]; + let two = [assignment(a, 1), assignment(b, 1)]; + assert_eq!( + tracking_pair_precision(&[], &[]), + Err(EventError::InvalidWirePayload) + ); + assert_eq!( + tracking_pair_recall(&one, &two), + Err(EventError::InvalidWirePayload) + ); + assert_eq!( + tracking_pair_precision(&one, &one), + Err(EventError::InvalidWirePayload) + ); + let duplicate = [assignment(a, 1), assignment(a, 2)]; + assert_eq!( + tracking_pair_recall(&duplicate, &duplicate), + Err(EventError::InvalidWirePayload) + ); + assert_eq!( + tracking_identity_switch_rate(&one, &one), + Err(EventError::InvalidWirePayload) + ); + assert_eq!( + tracking_identity_switch_rate(&[], &[]), + Err(EventError::InvalidWirePayload) + ); + assert_eq!( + tracking_identity_switch_rate(&two, &one), + Err(EventError::InvalidWirePayload) + ); + let a2 = mention(); + let b2 = mention(); + let c2 = mention(); + let d2 = mention(); + let three = [assignment(a2, 1), assignment(b2, 1), assignment(c2, 2)]; + let four = [ + assignment(a2, 1), + assignment(b2, 1), + assignment(c2, 2), + assignment(d2, 2), + ]; + assert_eq!( + tracking_pair_precision(&three, &four), + Err(EventError::InvalidWirePayload) + ); + assert_eq!( + tracking_pair_recall(&three, &four), + Err(EventError::InvalidWirePayload) + ); +} + +#[test] +fn labels_round_trip_and_threshold_is_inclusive() { + assert_eq!(EventTrackLabel::Continue.wire_name(), "continue"); + assert_eq!(EventTrackLabel::Switch.wire_name(), "switch"); + assert_eq!( + EventTrackLabel::from_wire_name("continue").expect("parse"), + EventTrackLabel::Continue + ); + assert_eq!( + EventTrackLabel::from_wire_name("switch").expect("parse"), + EventTrackLabel::Switch + ); + assert_eq!( + EventTrackLabel::from_wire_name("same_track"), + Err(EventError::UnknownEventTrackLabel) + ); + assert!(EventTrackLabel::Continue.is_continue()); + assert!(!EventTrackLabel::Switch.is_continue()); + assert!((EventTrackLabel::Continue.as_probability_target() - 1.0).abs() < f64::EPSILON); + assert!((EventTrackLabel::Switch.as_probability_target() - 0.0).abs() < f64::EPSILON); + + let half = EventConfidence::new(0.5).expect("half"); + assert_eq!(decide_track_continue(half, half), EventTrackLabel::Continue); + assert_eq!( + decide_track_continue(EventConfidence::new(0.49).expect("below"), half), + EventTrackLabel::Switch + ); + + let mention_id = mention(); + let assigned = assignment(mention_id, 7); + assert_eq!(assigned.mention_id(), mention_id); + assert_eq!(assigned.track_id(), EventTrackId::from_raw(7)); + assert_eq!(EventTrackId::from_raw(7).raw(), 7); +} diff --git a/crates/persistence_postgres/src/entity_sql.rs b/crates/persistence_postgres/src/entity_sql.rs index 021593435..fcb5c196a 100644 --- a/crates/persistence_postgres/src/entity_sql.rs +++ b/crates/persistence_postgres/src/entity_sql.rs @@ -7,8 +7,8 @@ use uuid::Uuid; /// One append-only entity target that membership rows may reference. /// /// Maps to `entity_record` after migration `0006`. The type label is a -/// fail-closed contextual code (`author`, `department`, `customer`) and is -/// not a direct identity string. +/// fail-closed ASCII snake-case contextual code (`author`, `department`, +/// `customer`) and is not a direct identity string. #[derive(Clone, Debug, Eq, PartialEq)] pub struct EntityRecord { /// Primary key for this entity identity. @@ -29,6 +29,9 @@ impl EntityRecord { /// # Errors /// /// Returns [`PersistenceError::InvalidEntityRecord`] when the type code is + /// empty, longer than 128 bytes, or contains a character outside the ASCII + /// letters, digits, and underscore allowlist used by the rendered SQL + /// transport. /// empty, longer than 128 bytes, or contains a character outside lowercase /// ASCII letters, digits, and underscores. pub fn validate(&self) -> Result<(), PersistenceError> { @@ -43,6 +46,8 @@ impl EntityRecord { /// Returns [`PersistenceError::InvalidEntityRecord`] before any SQL is produced. pub fn insert_entity_record_sql(record: &EntityRecord) -> Result { record.validate()?; + // The current SqlSession contract accepts rendered SQL, so the label is + // restricted to an SQL-literal-safe identifier token before interpolation. Ok(format!( "INSERT INTO entity_record (\ entity_record_id, tenant_record_id, entity_type_code, \ @@ -74,6 +79,9 @@ pub fn select_entity_record_by_id_sql(entity_record_id: Uuid) -> String { fn validate_entity_label(value: &str) -> Result<(), PersistenceError> { if value.is_empty() || value.len() > 128 + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') || value .chars() .any(|ch| !(ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_')) diff --git a/crates/persistence_postgres/src/lib.rs b/crates/persistence_postgres/src/lib.rs index fd31245b1..a6038d280 100644 --- a/crates/persistence_postgres/src/lib.rs +++ b/crates/persistence_postgres/src/lib.rs @@ -15,6 +15,11 @@ //! contracts chain immutable run identities to those manifests. Typed //! membership-assignment SQL (migration `0006`) replaces the polymorphic 0001 stub so documents //! can belong to multiple entities and projects without atomistic collapse. +//! Typed `text_segment` SQL persists exact UTF-8 byte spans and cutoff-eligible +//! document lookups so segment-level membership is not raw SQL. +//! Entity and project target SQL refuse empty, oversized, or hostile labels +//! before `INSERT`, so membership foreign keys cannot be seeded from raw +//! attacker-controlled type or status strings. //! Entity and project target SQL refuse empty, oversized, or hostile labels //! before `INSERT`, so membership foreign keys cannot be seeded from raw //! attacker-controlled type or status strings. Typed `text_segment` SQL diff --git a/crates/persistence_postgres/src/project_sql.rs b/crates/persistence_postgres/src/project_sql.rs index de688ea04..1ea7daf26 100644 --- a/crates/persistence_postgres/src/project_sql.rs +++ b/crates/persistence_postgres/src/project_sql.rs @@ -7,8 +7,8 @@ use uuid::Uuid; /// One append-only project target that membership rows may reference. /// /// Maps to `project_record` after migration `0006`. The status label is a -/// fail-closed contextual code (`active`, `closed`) and is not a project -/// display name. +/// fail-closed ASCII snake-case contextual code (`active`, `closed`) and is +/// not a project display name. #[derive(Clone, Debug, Eq, PartialEq)] pub struct ProjectRecord { /// Primary key for this project identity. @@ -29,6 +29,9 @@ impl ProjectRecord { /// # Errors /// /// Returns [`PersistenceError::InvalidProjectRecord`] when the status code + /// is empty, longer than 128 bytes, or contains a character outside the + /// ASCII letters, digits, and underscore allowlist used by the rendered + /// SQL transport. /// is empty, longer than 128 bytes, or contains a character outside /// lowercase ASCII letters, digits, and underscores. pub fn validate(&self) -> Result<(), PersistenceError> { @@ -43,6 +46,8 @@ impl ProjectRecord { /// Returns [`PersistenceError::InvalidProjectRecord`] before any SQL is produced. pub fn insert_project_record_sql(record: &ProjectRecord) -> Result { record.validate()?; + // The current SqlSession contract accepts rendered SQL, so the status is + // restricted to an SQL-literal-safe identifier token before interpolation. Ok(format!( "INSERT INTO project_record (\ project_record_id, tenant_record_id, project_status_code, \ @@ -74,6 +79,9 @@ pub fn select_project_record_by_id_sql(project_record_id: Uuid) -> String { fn validate_project_label(value: &str) -> Result<(), PersistenceError> { if value.is_empty() || value.len() > 128 + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') || value .chars() .any(|ch| !(ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_')) diff --git a/crates/persistence_postgres/tests/entity_project_sql_contract.rs b/crates/persistence_postgres/tests/entity_project_sql_contract.rs index 61dc6870a..e897005b9 100644 --- a/crates/persistence_postgres/tests/entity_project_sql_contract.rs +++ b/crates/persistence_postgres/tests/entity_project_sql_contract.rs @@ -104,6 +104,13 @@ fn empty_oversized_and_hostile_entity_labels_fail_closed() { insert_entity_record_sql(&oversized), Err(PersistenceError::InvalidEntityRecord) ); + + let mut non_identifier = entity(); + non_identifier.entity_type_code = "author role".into(); + assert_eq!( + insert_entity_record_sql(&non_identifier), + Err(PersistenceError::InvalidEntityRecord) + ); } #[test] @@ -149,4 +156,11 @@ fn empty_oversized_and_hostile_project_labels_fail_closed() { insert_project_record_sql(&oversized), Err(PersistenceError::InvalidProjectRecord) ); + + let mut non_identifier = project(); + non_identifier.project_status_code = "active$".into(); + assert_eq!( + insert_project_record_sql(&non_identifier), + Err(PersistenceError::InvalidProjectRecord) + ); } diff --git a/crates/persistence_postgres/tests/live_postgres.rs b/crates/persistence_postgres/tests/live_postgres.rs index 5e576fbe3..df4fdce98 100644 --- a/crates/persistence_postgres/tests/live_postgres.rs +++ b/crates/persistence_postgres/tests/live_postgres.rs @@ -13,8 +13,8 @@ use persistence_postgres::{ ModelRunRecord, PersistenceError, ProjectRecord, ReproducibilityManifestRecord, RetentionPolicyRecord, SqlSession, TextSegmentRecord, apply_sql_batch, assume_app_runtime_role_sql, clear_session_tenant_sql, insert_entity_record_sql, - open_live_sqlx_pool, require_live_sqlx_config, reset_app_runtime_role_sql, - select_active_analysis_document_sql, set_session_tenant_sql, + insert_project_record_sql, open_live_sqlx_pool, require_live_sqlx_config, + reset_app_runtime_role_sql, select_active_analysis_document_sql, set_session_tenant_sql, }; use std::sync::mpsc; use std::sync::{Arc, Barrier}; @@ -891,13 +891,40 @@ fn seed_membership_targets( let wrong_tenant_entity = live_entity(entity_a, tenant_record_id, "author", available, system); let wrong_tenant_sql = insert_entity_record_sql(&wrong_tenant_entity).expect("render entity insert"); + assert!( + repo.session_mut() + .execute( + &insert_entity_record_sql(&live_entity( + entity_a, + tenant_record_id, + "author", + available, + system, + )) + .expect("render wrong-tenant entity insert"), + ) + .is_err(), + "wrong tenant GUC must reject raw entity_record insert under FORCE RLS" + ); + assert!( + repo.session_mut() + .execute( + &insert_project_record_sql(&live_project( + project, + tenant_record_id, + "active", + available, + system, + )) + .expect("render wrong-tenant project insert"), + ) + .is_err(), + "wrong tenant GUC must reject raw project_record insert under FORCE RLS" + ); assert!( repo.session_mut().execute(&wrong_tenant_sql).is_err(), "raw wrong-tenant SQL must reject entity_record insert under FORCE RLS" ); - repo.session_mut() - .execute(&set_session_tenant_sql(tenant_record_id)) - .expect("bind membership tenant GUC"); assert_eq!( repo.insert_entity_record(&live_entity( entity_a, diff --git a/crates/relation_graph/src/error.rs b/crates/relation_graph/src/error.rs index 234197e47..7f1684d78 100644 --- a/crates/relation_graph/src/error.rs +++ b/crates/relation_graph/src/error.rs @@ -22,6 +22,8 @@ pub enum RelationError { InvalidWirePayload, /// A wire payload used a schema version this crate does not support. UnsupportedWireVersion, + /// An association, precedence, or provenance edge was treated as causation. + CausalClaimNotIdentified, } impl fmt::Display for RelationError { @@ -35,6 +37,7 @@ impl fmt::Display for RelationError { Self::DuplicateRelationEdge => "duplicate relation edge", Self::InvalidWirePayload => "invalid relation wire payload", Self::UnsupportedWireVersion => "unsupported relation wire version", + Self::CausalClaimNotIdentified => "causal claim is not identified", }; formatter.write_str(message) } @@ -72,6 +75,10 @@ mod tests { RelationError::UnsupportedWireVersion, "unsupported relation wire version", ), + ( + RelationError::CausalClaimNotIdentified, + "causal claim is not identified", + ), ] { assert_eq!(error.to_string(), message); } diff --git a/crates/relation_graph/src/kind.rs b/crates/relation_graph/src/kind.rs index aedc1d7d5..7eac2630e 100644 --- a/crates/relation_graph/src/kind.rs +++ b/crates/relation_graph/src/kind.rs @@ -45,6 +45,16 @@ pub enum RelationKind { } impl RelationKind { + /// Return whether this kind may carry an identified causal claim. + /// + /// `Causes` and `IntervenesOn` are the only vocabulary members that may be + /// described as causal. Temporal precedence, enabling, production, and + /// provenance remain non-causal until a later identified design. + #[must_use] + pub const fn is_identified_causal_claim(self) -> bool { + matches!(self, Self::Causes | Self::IntervenesOn) + } + /// Return whether this kind is a forward state-transition edge. #[must_use] pub const fn is_transition_edge(self) -> bool { @@ -112,11 +122,33 @@ impl RelationKind { } } +/// Refuse treating association, precedence, or provenance as causation. +/// +/// # Errors +/// +/// Returns [`RelationError::CausalClaimNotIdentified`] unless `kind` is +/// [`RelationKind::Causes`] or [`RelationKind::IntervenesOn`]. +pub fn refuse_association_as_cause(kind: RelationKind) -> Result<(), RelationError> { + if kind.is_identified_causal_claim() { + Ok(()) + } else { + Err(RelationError::CausalClaimNotIdentified) + } +} + #[cfg(test)] mod tests { use super::RelationKind; use crate::RelationError; + #[test] + fn identified_causal_kinds_are_only_causes_and_intervention() { + assert!(RelationKind::Causes.is_identified_causal_claim()); + assert!(RelationKind::IntervenesOn.is_identified_causal_claim()); + assert!(!RelationKind::LeadsTo.is_identified_causal_claim()); + super::refuse_association_as_cause(RelationKind::Causes).expect("causes"); + } + #[test] fn transition_vocabulary_matches_erd_contract() { for kind in [ diff --git a/crates/relation_graph/src/lib.rs b/crates/relation_graph/src/lib.rs index d349f9a02..a398f09d1 100644 --- a/crates/relation_graph/src/lib.rs +++ b/crates/relation_graph/src/lib.rs @@ -28,6 +28,8 @@ pub use identifier::RelationEdgeId; pub use identifier::RelationEndpointId; /// Closed relation vocabulary with derived transition classification. pub use kind::RelationKind; +/// Refuse treating association or precedence as causation. +pub use kind::refuse_association_as_cause; /// Observed versus inferred relation evidence status. pub use provenance::RelationEvidenceStatus; /// Validate forward-only event-time order for transition edges. diff --git a/crates/relation_graph/tests/causal_identification_contract.rs b/crates/relation_graph/tests/causal_identification_contract.rs new file mode 100644 index 000000000..3aaff5005 --- /dev/null +++ b/crates/relation_graph/tests/causal_identification_contract.rs @@ -0,0 +1,32 @@ +//! Association and temporal precedence are not causal identification. + +use relation_graph::{RelationError, RelationKind, refuse_association_as_cause}; + +#[test] +fn identified_causal_vocabulary_is_allowed_and_associations_are_not() { + refuse_association_as_cause(RelationKind::Causes).expect("causes"); + refuse_association_as_cause(RelationKind::IntervenesOn).expect("intervention"); + + for kind in [ + RelationKind::LeadsTo, + RelationKind::Enables, + RelationKind::References, + RelationKind::Summarizes, + RelationKind::Revises, + RelationKind::Translates, + RelationKind::RetrospectivelyReports, + RelationKind::Supports, + RelationKind::Contradicts, + RelationKind::OutcomeOf, + RelationKind::InputTo, + RelationKind::ProcessTo, + RelationKind::Produces, + RelationKind::TransitionsTo, + ] { + assert_eq!( + refuse_association_as_cause(kind), + Err(RelationError::CausalClaimNotIdentified), + "{kind:?} must not be treated as identified causation" + ); + } +} diff --git a/crates/temporal_core/src/eligibility.rs b/crates/temporal_core/src/eligibility.rs new file mode 100644 index 000000000..131741704 --- /dev/null +++ b/crates/temporal_core/src/eligibility.rs @@ -0,0 +1,79 @@ +//! Interval-aware historical eligibility against a knowledge cutoff. + +use crate::{ + AvailableTime, KnowledgeCutoff, TemporalBoundary, TemporalCertainty, TemporalError, + TemporalInterval, +}; + +/// Decide whether an availability interval is fully eligible under `knowledge_cutoff`. +/// +/// Evidence may enter a historical analysis only when every possible +/// availability instant is at or before the cutoff. Unknown availability and +/// open-ended upper bounds fail closed because they can extend past the cutoff. +/// Event time and document time cannot be substituted: the interval is typed as +/// [`AvailableTime`]. +/// +/// ```compile_fail,E0308 +/// use temporal_core::{ +/// EventTime, KnowledgeCutoff, TemporalInterval, TemporalPrecision, +/// evaluate_historical_eligibility, +/// }; +/// +/// let event = TemporalInterval::exact( +/// EventTime::parse_rfc3339("2026-01-01T00:00:00Z")?, +/// TemporalPrecision::Second, +/// )?; +/// let cutoff = KnowledgeCutoff::parse_rfc3339("2026-06-01T00:00:00Z")?; +/// evaluate_historical_eligibility(&event, &cutoff)?; +/// # Ok::<(), temporal_core::TemporalError>(()) +/// ``` +/// +/// # Errors +/// +/// Returns [`TemporalError::UncertainAvailability`] when the interval cannot +/// prove an upper bound, or [`TemporalError::IneligibleAtCutoff`] when the +/// latest possible availability is after the cutoff. +pub fn evaluate_historical_eligibility( + availability: &TemporalInterval, + knowledge_cutoff: &KnowledgeCutoff, +) -> Result<(), TemporalError> { + if matches!(availability.certainty(), TemporalCertainty::Unknown) { + return Err(TemporalError::UncertainAvailability); + } + + let latest = match availability.upper() { + TemporalBoundary::Unbounded => return Err(TemporalError::UncertainAvailability), + TemporalBoundary::Included(value) => value.instant().as_nanosecond(), + TemporalBoundary::Excluded(value) => value.instant().as_nanosecond() - 1, + }; + if latest <= knowledge_cutoff.instant().as_nanosecond() { + Ok(()) + } else { + Err(TemporalError::IneligibleAtCutoff) + } +} + +#[cfg(test)] +mod tests { + use super::evaluate_historical_eligibility; + use crate::{ + AvailableTime, KnowledgeCutoff, TemporalBoundary, TemporalInterval, TemporalPrecision, + }; + + fn available(stamp: &str) -> AvailableTime { + AvailableTime::parse_rfc3339(stamp).expect("available") + } + + #[test] + fn excluded_upper_one_nanosecond_after_cutoff_is_eligible() { + let cutoff = KnowledgeCutoff::parse_rfc3339("2026-06-01T00:00:00Z").expect("cutoff"); + let just_after = available("2026-06-01T00:00:00.000000001Z"); + let interval = TemporalInterval::bounded( + TemporalBoundary::Unbounded, + TemporalBoundary::Excluded(just_after), + TemporalPrecision::Nanosecond, + ) + .expect("interval"); + assert_eq!(evaluate_historical_eligibility(&interval, &cutoff), Ok(())); + } +} diff --git a/crates/temporal_core/src/error.rs b/crates/temporal_core/src/error.rs index d97eb8fd4..d3778bde2 100644 --- a/crates/temporal_core/src/error.rs +++ b/crates/temporal_core/src/error.rs @@ -24,6 +24,10 @@ pub enum TemporalError { UnsupportedWireVersion, /// A JSON wire record declared a different nominal clock type. ClockTypeMismatch, + /// Availability is unknown or open-ended and can extend past the cutoff. + UncertainAvailability, + /// The latest possible availability instant is after the knowledge cutoff. + IneligibleAtCutoff, } impl fmt::Display for TemporalError { @@ -40,6 +44,8 @@ impl fmt::Display for TemporalError { Self::InvalidWirePayload => "invalid temporal wire payload", Self::UnsupportedWireVersion => "unsupported temporal wire version", Self::ClockTypeMismatch => "temporal clock type mismatch", + Self::UncertainAvailability => "uncertain availability fails closed at cutoff", + Self::IneligibleAtCutoff => "availability is ineligible at knowledge cutoff", }; formatter.write_str(message) } diff --git a/crates/temporal_core/src/lib.rs b/crates/temporal_core/src/lib.rs index ede257114..8980da4cb 100644 --- a/crates/temporal_core/src/lib.rs +++ b/crates/temporal_core/src/lib.rs @@ -25,8 +25,13 @@ //! relations. Relation sets support inverse and complete composition, while a //! resource-bounded path-consistency reasoner preserves direct assertions, //! derived narrowing, and conservative supporting-assertion provenance. +//! +//! Historical eligibility requires the entire [`AvailableTime`] interval to +//! fall at or before [`KnowledgeCutoff`]. Unknown or open-ended availability +//! fails closed and cannot be replaced by event or document time. mod clock; +mod eligibility; mod error; mod instant; mod interval; @@ -48,6 +53,8 @@ pub use clock::KnowledgeCutoff; pub use clock::SystemTime; /// A sealed nominal TEPP clock over one absolute instant representation. pub use clock::TemporalClock; +/// Decide whether an availability interval is fully eligible at a cutoff. +pub use eligibility::evaluate_historical_eligibility; /// A fail-closed temporal-domain validation error. pub use error::TemporalError; /// An absolute UTC instant represented to nanosecond precision. diff --git a/crates/temporal_core/tests/eligibility_contract.rs b/crates/temporal_core/tests/eligibility_contract.rs new file mode 100644 index 000000000..261599b87 --- /dev/null +++ b/crates/temporal_core/tests/eligibility_contract.rs @@ -0,0 +1,128 @@ +//! Interval-aware historical eligibility against a knowledge cutoff. + +use temporal_core::{ + AvailableTime, KnowledgeCutoff, TemporalBoundary, TemporalError, TemporalInterval, + TemporalPrecision, evaluate_historical_eligibility, +}; + +fn available(stamp: &str) -> AvailableTime { + AvailableTime::parse_rfc3339(stamp).expect("available") +} + +fn cutoff(stamp: &str) -> KnowledgeCutoff { + KnowledgeCutoff::parse_rfc3339(stamp).expect("cutoff") +} + +fn exact(stamp: &str) -> TemporalInterval { + TemporalInterval::exact(available(stamp), TemporalPrecision::Second).expect("exact") +} + +/// Independently compute the latest representable availability nanosecond. +/// +/// The production gate must agree with this comparison: eligible iff the +/// latest possible availability instant is `<=` the cutoff. Unknown or +/// open-ended availability has no latest instant and must fail closed. +fn latest_possible_ns( + availability: &TemporalInterval, +) -> Result { + if !availability.is_known() { + return Err(TemporalError::UncertainAvailability); + } + match availability.upper() { + TemporalBoundary::Unbounded => Err(TemporalError::UncertainAvailability), + TemporalBoundary::Included(value) => Ok(value.instant().as_nanosecond()), + TemporalBoundary::Excluded(value) => Ok(value.instant().as_nanosecond() - 1), + } +} + +fn expected_decision( + availability: &TemporalInterval, + knowledge_cutoff: &KnowledgeCutoff, +) -> Result<(), TemporalError> { + match latest_possible_ns(availability) { + Ok(latest) if latest <= knowledge_cutoff.instant().as_nanosecond() => Ok(()), + Ok(_) => Err(TemporalError::IneligibleAtCutoff), + Err(error) => Err(error), + } +} + +#[test] +fn computed_latest_instant_agrees_with_the_eligibility_gate() { + let cut = cutoff("2026-06-01T00:00:00Z"); + let closed = |start: &str, end: &str| { + TemporalInterval::bounded( + TemporalBoundary::Included(available(start)), + TemporalBoundary::Included(available(end)), + TemporalPrecision::Second, + ) + .expect("closed") + }; + let upper_open = |end: &str| { + TemporalInterval::bounded( + TemporalBoundary::Unbounded, + TemporalBoundary::Excluded(available(end)), + TemporalPrecision::Second, + ) + .expect("upper open") + }; + let lower_open = |start: &str| { + TemporalInterval::bounded( + TemporalBoundary::Included(available(start)), + TemporalBoundary::Unbounded, + TemporalPrecision::Second, + ) + .expect("lower open") + }; + + let cases = [ + exact("2026-06-01T00:00:00Z"), + exact("2026-05-01T00:00:00Z"), + exact("2026-06-01T00:00:01Z"), + closed("2026-01-01T00:00:00Z", "2026-06-01T00:00:00Z"), + closed("2026-01-01T00:00:00Z", "2026-06-01T00:00:01Z"), + upper_open("2026-06-01T00:00:00Z"), + upper_open("2026-06-01T00:00:00.000000001Z"), + upper_open("2026-06-01T00:00:00.000000002Z"), + lower_open("2026-01-01T00:00:00Z"), + TemporalInterval::::unknown(), + ]; + + for availability in cases { + assert_eq!( + evaluate_historical_eligibility(&availability, &cut), + expected_decision(&availability, &cut) + ); + } +} + +#[test] +fn unknown_and_open_ended_availability_fail_closed() { + let cut = cutoff("2026-06-01T00:00:00Z"); + assert_eq!( + evaluate_historical_eligibility(&TemporalInterval::unknown(), &cut), + Err(TemporalError::UncertainAvailability) + ); + let open_upper = TemporalInterval::bounded( + TemporalBoundary::Included(available("2026-01-01T00:00:00Z")), + TemporalBoundary::Unbounded, + TemporalPrecision::Day, + ) + .expect("open upper"); + assert_eq!( + evaluate_historical_eligibility(&open_upper, &cut), + Err(TemporalError::UncertainAvailability) + ); +} + +#[test] +fn exact_availability_after_cutoff_is_ineligible() { + let cut = cutoff("2026-06-01T00:00:00Z"); + assert_eq!( + evaluate_historical_eligibility(&exact("2026-06-01T00:00:00Z"), &cut), + Ok(()) + ); + assert_eq!( + evaluate_historical_eligibility(&exact("2026-06-01T00:00:01Z"), &cut), + Err(TemporalError::IneligibleAtCutoff) + ); +} diff --git a/crates/temporal_core/tests/error_contract.rs b/crates/temporal_core/tests/error_contract.rs index 25b3d316b..838cbb440 100644 --- a/crates/temporal_core/tests/error_contract.rs +++ b/crates/temporal_core/tests/error_contract.rs @@ -38,6 +38,14 @@ fn every_temporal_error_has_a_stable_content_redacting_message() { TemporalError::ClockTypeMismatch, "temporal clock type mismatch", ), + ( + TemporalError::UncertainAvailability, + "uncertain availability fails closed at cutoff", + ), + ( + TemporalError::IneligibleAtCutoff, + "availability is ineligible at knowledge cutoff", + ), ]; for (error, expected) in cases { diff --git a/crates/validation_core/Cargo.toml b/crates/validation_core/Cargo.toml index 5f718f8ed..9824f91af 100644 --- a/crates/validation_core/Cargo.toml +++ b/crates/validation_core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "validation_core" -description = "Recovery, calibration, graph, and Monte Carlo validation metrics." +description = "Recovery, calibration, graph, Monte Carlo, and exact-head claim-promotion metrics." version.workspace = true edition.workspace = true rust-version.workspace = true diff --git a/crates/validation_core/src/claim.rs b/crates/validation_core/src/claim.rs new file mode 100644 index 000000000..9e6d3adc4 --- /dev/null +++ b/crates/validation_core/src/claim.rs @@ -0,0 +1,426 @@ +//! Exact-head claim promotion gates for ADR 0014 authorities. + +use crate::ValidationError; +use crate::accept_within_standard_errors; +use crate::rmse_standard_error; +use crate::root_mean_square_error; + +/// Four claim authorities separated by ADR 0014. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub enum ClaimAuthority { + /// Accepted PRD/ADR design authority. + DecisionAccepted, + /// Source integrated on the exact protected head with passing tests. + ImplementedMain, + /// Implementation plus claim-specific computed recovery evidence. + ScientificallySupported, + /// One exact protected head satisfying every release gate together. + Released, +} + +impl ClaimAuthority { + /// Stable wire name for this authority. + #[must_use] + pub const fn wire_name(self) -> &'static str { + match self { + Self::DecisionAccepted => "decision_accepted", + Self::ImplementedMain => "implemented_main", + Self::ScientificallySupported => "scientifically_supported", + Self::Released => "released", + } + } + + fn required_kinds(self) -> &'static [ClaimEvidenceKind] { + match self { + Self::DecisionAccepted => &[], + Self::ImplementedMain => &[ClaimEvidenceKind::ExactHeadTests], + Self::ScientificallySupported => &[ + ClaimEvidenceKind::ExactHeadTests, + ClaimEvidenceKind::ScientificRecovery, + ], + Self::Released => &[ + ClaimEvidenceKind::ExactHeadTests, + ClaimEvidenceKind::ScientificRecovery, + ClaimEvidenceKind::SecuritySupplyChain, + ClaimEvidenceKind::QualifyingReview, + ClaimEvidenceKind::OperationalReadiness, + ClaimEvidenceKind::SbomProvenance, + ], + } + } +} + +/// Kind of evidence offered for a promotion request. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum ClaimEvidenceKind { + /// Exact-head unit/integration tests on the candidate commit. + ExactHeadTests, + /// Claim-specific recovery or calibration evidence. + ScientificRecovery, + /// Security and supply-chain gates on the same head. + SecuritySupplyChain, + /// Qualifying independent review, not self-approval. + QualifyingReview, + /// Operational readiness on the same head. + OperationalReadiness, + /// SBOM and provenance bound to the same head. + SbomProvenance, + /// A queued or in-progress check. + QueuedCheck, + /// Evidence collected on a predecessor or other commit. + PredecessorHead, + /// Model or LLM narrative treated as authority. + LlmJudgment, + /// A required test that was skipped or ignored. + SkippedRequired, +} + +impl ClaimEvidenceKind { + /// Stable wire name for this evidence kind. + #[must_use] + pub const fn wire_name(self) -> &'static str { + match self { + Self::ExactHeadTests => "exact_head_tests", + Self::ScientificRecovery => "scientific_recovery", + Self::SecuritySupplyChain => "security_supply_chain", + Self::QualifyingReview => "qualifying_review", + Self::OperationalReadiness => "operational_readiness", + Self::SbomProvenance => "sbom_provenance", + Self::QueuedCheck => "queued_check", + Self::PredecessorHead => "predecessor_head", + Self::LlmJudgment => "llm_judgment", + Self::SkippedRequired => "skipped_required", + } + } + + /// Whether this kind may ever promote a claim. + #[must_use] + pub const fn is_promotable(self) -> bool { + !matches!( + self, + Self::QueuedCheck | Self::PredecessorHead | Self::LlmJudgment | Self::SkippedRequired + ) + } +} + +/// One evidence item offered for promotion. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ClaimEvidence { + kind: ClaimEvidenceKind, + passed: bool, +} + +impl ClaimEvidence { + /// Construct one evidence item. + #[must_use] + pub const fn new(kind: ClaimEvidenceKind, passed: bool) -> Self { + Self { kind, passed } + } + + /// Return the evidence kind. + #[must_use] + pub const fn kind(self) -> ClaimEvidenceKind { + self.kind + } + + /// Return whether the presented evidence is marked passing. + #[must_use] + pub const fn passed(self) -> bool { + self.passed + } +} + +/// A request to promote one claim authority on a candidate head. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct PromotionRequest<'evidence> { + target: ClaimAuthority, + candidate_head: [u8; 20], + protected_head: [u8; 20], + evidence: &'evidence [ClaimEvidence], +} + +impl<'evidence> PromotionRequest<'evidence> { + /// Parse commit identities and bind the offered evidence. + /// + /// Both heads are validated as exact forty-character hexadecimal Git + /// commit SHAs at construction, so a request can never bind an + /// unparseable identity. Evidence truthfulness remains adapter-trust + /// based: only trusted CI and repository adapters may construct requests, + /// because [`ClaimEvidence::passed`] flags cannot be independently proven + /// inside this crate. + /// + /// # Errors + /// + /// Returns [`ValidationError::InvalidInput`] when either head is not a + /// forty-character hexadecimal Git commit SHA. + pub fn new( + target: ClaimAuthority, + candidate_head: &str, + protected_head: &str, + evidence: &'evidence [ClaimEvidence], + ) -> Result { + Ok(Self { + target, + candidate_head: parse_commit_head(candidate_head)?, + protected_head: parse_commit_head(protected_head)?, + evidence, + }) + } + + /// Requested claim authority. + #[must_use] + pub const fn target(self) -> ClaimAuthority { + self.target + } + + /// Candidate commit identity. + #[must_use] + pub const fn candidate_head(self) -> [u8; 20] { + self.candidate_head + } + + /// Protected-main commit identity. + #[must_use] + pub const fn protected_head(self) -> [u8; 20] { + self.protected_head + } + + /// Offered evidence slice. + #[must_use] + pub const fn evidence(self) -> &'evidence [ClaimEvidence] { + self.evidence + } +} + +/// A claim that passed every required exact-head gate. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct PromotedClaim { + authority: ClaimAuthority, + bound_head: [u8; 20], +} + +impl PromotedClaim { + /// Bind a promoted authority to one commit identity. + /// + /// Crate-internal on purpose: only the validated promotion flows in this + /// module ([`promote_claim`] and [`promote_scientific_recovery`]) may mint + /// a promoted claim, so external callers cannot bypass the exact-head + /// evidence gates by direct construction. + #[must_use] + pub(crate) const fn new(authority: ClaimAuthority, bound_head: [u8; 20]) -> Self { + Self { + authority, + bound_head, + } + } + + /// Promoted authority. + #[must_use] + pub const fn authority(self) -> ClaimAuthority { + self.authority + } + + /// Exact commit the promotion is bound to. + #[must_use] + pub const fn bound_head(self) -> [u8; 20] { + self.bound_head + } +} + +/// Parse a forty-character hexadecimal Git commit SHA. +/// +/// # Errors +/// +/// Returns [`ValidationError::InvalidInput`] when the value is not exactly +/// forty hexadecimal characters. +pub fn parse_commit_head(value: &str) -> Result<[u8; 20], ValidationError> { + let bytes = value.as_bytes(); + if bytes.len() != 40 { + return Err(ValidationError::InvalidInput); + } + let mut decoded = [0_u8; 20]; + for (index, pair) in bytes.as_chunks::<2>().0.iter().enumerate() { + decoded[index] = (hex_nibble(pair[0])? << 4) | hex_nibble(pair[1])?; + } + Ok(decoded) +} + +fn hex_nibble(value: u8) -> Result { + match value { + b'0'..=b'9' => Ok(value - b'0'), + b'a'..=b'f' => Ok(value - b'a' + 10), + b'A'..=b'F' => Ok(value - b'A' + 10), + _ => Err(ValidationError::InvalidInput), + } +} + +/// Promote a claim only when exact-head evidence satisfies ADR 0014. +/// +/// Design authority may bind a non-protected head. Implementation, scientific, +/// and release authorities require the candidate to equal the protected head +/// and every required gate to be present with at least one passing item and no +/// failing item. Queued, predecessor, skipped-required, and LLM evidence fail +/// closed. +/// +/// # Errors +/// +/// Returns a claim-specific [`ValidationError`] when heads differ, required +/// evidence is missing, a required evidence kind carries a failing item, or +/// unusable evidence is present. +pub fn promote_claim(request: &PromotionRequest<'_>) -> Result { + for item in request.evidence { + match item.kind { + ClaimEvidenceKind::QueuedCheck => { + return Err(ValidationError::ClaimQueuedEvidence); + } + ClaimEvidenceKind::PredecessorHead => { + return Err(ValidationError::ClaimPredecessorHead); + } + ClaimEvidenceKind::LlmJudgment => { + return Err(ValidationError::ClaimLlmJudgment); + } + ClaimEvidenceKind::SkippedRequired => { + return Err(ValidationError::ClaimSkippedRequired); + } + ClaimEvidenceKind::ExactHeadTests + | ClaimEvidenceKind::ScientificRecovery + | ClaimEvidenceKind::SecuritySupplyChain + | ClaimEvidenceKind::QualifyingReview + | ClaimEvidenceKind::OperationalReadiness + | ClaimEvidenceKind::SbomProvenance => {} + } + } + if request.target != ClaimAuthority::DecisionAccepted + && request.candidate_head != request.protected_head + { + return Err(ValidationError::ClaimHeadMismatch); + } + for required in request.target.required_kinds() { + let mut any_passed = false; + for item in request.evidence { + if item.kind != *required { + continue; + } + if item.passed { + any_passed = true; + } else { + return Err(ValidationError::ClaimEvidenceFailed); + } + } + if !any_passed { + return Err(ValidationError::ClaimEvidenceMissing); + } + } + Ok(PromotedClaim::new(request.target, request.candidate_head)) +} + +/// Promote a scientific claim from computed RMSE, not a hardcoded threshold. +/// +/// The candidate must equal the protected head. RMSE is accepted only when it +/// lies within `se_multiplier` standard errors of exact recovery. +/// +/// # Errors +/// +/// Returns head, input, configuration, or recovery-rejection errors. +pub fn promote_scientific_recovery( + candidate_head: &str, + protected_head: &str, + truth: &[f64], + recovered: &[f64], + se_multiplier: f64, +) -> Result { + let candidate = parse_commit_head(candidate_head)?; + let protected = parse_commit_head(protected_head)?; + if candidate != protected { + return Err(ValidationError::ClaimHeadMismatch); + } + let rmse = root_mean_square_error(truth, recovered)?; + let rmse_se = rmse_standard_error(truth, recovered)?; + if !accept_within_standard_errors(rmse, 0.0, rmse_se, se_multiplier)? { + return Err(ValidationError::ClaimRecoveryRejected); + } + Ok(PromotedClaim::new( + ClaimAuthority::ScientificallySupported, + candidate, + )) +} + +#[cfg(test)] +mod tests { + use super::{ + ClaimAuthority, ClaimEvidence, ClaimEvidenceKind, PromotionRequest, parse_commit_head, + promote_claim, promote_scientific_recovery, + }; + use crate::ValidationError; + + const HEAD: &str = "0123456789abcdef0123456789abcdef01234567"; + + #[test] + fn wire_names_and_accessors_cover_every_variant() { + assert_eq!( + ClaimAuthority::ImplementedMain.wire_name(), + "implemented_main" + ); + assert_eq!( + ClaimAuthority::ScientificallySupported.wire_name(), + "scientifically_supported" + ); + for kind in [ + ClaimEvidenceKind::ExactHeadTests, + ClaimEvidenceKind::ScientificRecovery, + ClaimEvidenceKind::SecuritySupplyChain, + ClaimEvidenceKind::QualifyingReview, + ClaimEvidenceKind::OperationalReadiness, + ClaimEvidenceKind::SbomProvenance, + ClaimEvidenceKind::QueuedCheck, + ClaimEvidenceKind::PredecessorHead, + ClaimEvidenceKind::LlmJudgment, + ClaimEvidenceKind::SkippedRequired, + ] { + assert!(!kind.wire_name().is_empty()); + assert_eq!( + kind.is_promotable(), + !matches!( + kind, + ClaimEvidenceKind::QueuedCheck + | ClaimEvidenceKind::PredecessorHead + | ClaimEvidenceKind::LlmJudgment + | ClaimEvidenceKind::SkippedRequired + ) + ); + } + let evidence = ClaimEvidence::new(ClaimEvidenceKind::ExactHeadTests, true); + assert_eq!(evidence.kind(), ClaimEvidenceKind::ExactHeadTests); + assert!(evidence.passed()); + let evidence_row = [evidence]; + let request = + PromotionRequest::new(ClaimAuthority::DecisionAccepted, HEAD, HEAD, &evidence_row) + .expect("request"); + assert_eq!(request.target(), ClaimAuthority::DecisionAccepted); + assert_eq!(request.candidate_head(), parse_commit_head(HEAD).unwrap()); + assert_eq!(request.protected_head(), parse_commit_head(HEAD).unwrap()); + assert_eq!(request.evidence(), evidence_row.as_slice()); + let promoted = promote_claim(&request).expect("design promotion"); + assert_eq!(promoted.authority(), ClaimAuthority::DecisionAccepted); + assert_eq!(promoted.bound_head(), parse_commit_head(HEAD).unwrap()); + assert_eq!( + parse_commit_head("0123456789abcdef0123456789abcdef0123456g"), + Err(ValidationError::InvalidInput) + ); + assert_eq!( + promote_scientific_recovery(HEAD, HEAD, &[1.0, 2.0], &[1.0, 2.0], -1.0), + Err(ValidationError::InvalidConfiguration) + ); + let extra = [ + ClaimEvidence::new(ClaimEvidenceKind::SecuritySupplyChain, true), + ClaimEvidence::new(ClaimEvidenceKind::ExactHeadTests, true), + ]; + let extra_request = + PromotionRequest::new(ClaimAuthority::ImplementedMain, HEAD, HEAD, &extra) + .expect("extra"); + assert_eq!( + promote_claim(&extra_request).expect("ok").authority(), + ClaimAuthority::ImplementedMain + ); + } +} diff --git a/crates/validation_core/src/error.rs b/crates/validation_core/src/error.rs index 89c2563a0..2e8789186 100644 --- a/crates/validation_core/src/error.rs +++ b/crates/validation_core/src/error.rs @@ -10,6 +10,22 @@ pub enum ValidationError { InvalidInput, /// Acceptance thresholds or Monte Carlo settings were inconsistent. InvalidConfiguration, + /// Candidate and protected heads are not the same exact commit. + ClaimHeadMismatch, + /// A required exact-head gate is absent. + ClaimEvidenceMissing, + /// Required exact-head evidence of a kind was presented and failed. + ClaimEvidenceFailed, + /// A queued or in-progress check was treated as passing evidence. + ClaimQueuedEvidence, + /// Predecessor-head or stale evidence was treated as current-head proof. + ClaimPredecessorHead, + /// An LLM judgment was treated as scientific or implementation authority. + ClaimLlmJudgment, + /// A skipped required test was treated as passing evidence. + ClaimSkippedRequired, + /// Computed recovery did not fall within the configured SE gate. + ClaimRecoveryRejected, } impl fmt::Display for ValidationError { @@ -17,6 +33,14 @@ impl fmt::Display for ValidationError { let message = match self { Self::InvalidInput => "invalid validation input", Self::InvalidConfiguration => "invalid validation configuration", + Self::ClaimHeadMismatch => "claim candidate head is not the protected head", + Self::ClaimEvidenceMissing => "required claim evidence is missing", + Self::ClaimEvidenceFailed => "required claim evidence failed", + Self::ClaimQueuedEvidence => "queued checks cannot promote a claim", + Self::ClaimPredecessorHead => "predecessor-head evidence cannot promote a claim", + Self::ClaimLlmJudgment => "llm judgment cannot promote a claim", + Self::ClaimSkippedRequired => "skipped required tests cannot promote a claim", + Self::ClaimRecoveryRejected => "computed recovery does not support the claim", }; formatter.write_str(message) } @@ -38,5 +62,37 @@ mod tests { ValidationError::InvalidConfiguration.to_string(), "invalid validation configuration" ); + assert_eq!( + ValidationError::ClaimHeadMismatch.to_string(), + "claim candidate head is not the protected head" + ); + assert_eq!( + ValidationError::ClaimEvidenceMissing.to_string(), + "required claim evidence is missing" + ); + assert_eq!( + ValidationError::ClaimEvidenceFailed.to_string(), + "required claim evidence failed" + ); + assert_eq!( + ValidationError::ClaimQueuedEvidence.to_string(), + "queued checks cannot promote a claim" + ); + assert_eq!( + ValidationError::ClaimPredecessorHead.to_string(), + "predecessor-head evidence cannot promote a claim" + ); + assert_eq!( + ValidationError::ClaimLlmJudgment.to_string(), + "llm judgment cannot promote a claim" + ); + assert_eq!( + ValidationError::ClaimSkippedRequired.to_string(), + "skipped required tests cannot promote a claim" + ); + assert_eq!( + ValidationError::ClaimRecoveryRejected.to_string(), + "computed recovery does not support the claim" + ); } } diff --git a/crates/validation_core/src/lib.rs b/crates/validation_core/src/lib.rs index cdd48fe7e..8c4637fb7 100644 --- a/crates/validation_core/src/lib.rs +++ b/crates/validation_core/src/lib.rs @@ -3,14 +3,17 @@ // Recovery metrics intentionally cast small finite sample sizes to `f64`. #![allow(clippy::cast_precision_loss)] #![allow(clippy::cast_sign_loss)] -//! Recovery, calibration, graph, and Monte Carlo validation metrics. +//! Recovery, calibration, graph, Monte Carlo, and claim-promotion metrics. //! //! TEPP scientific acceptance requires realistic synthetic truth recovery: //! parameter match counts, RMSE, bias, interval coverage with Wilson bounds, //! temporal-order accuracy, relation precision/recall, and SE-aware Monte Carlo -//! acceptance gates. Metrics are pure `f64` CPU reference implementations. +//! acceptance gates. ADR 0014 claim authorities are promoted only by exact-head +//! evidence; queued, predecessor, skipped, and LLM judgments fail closed. +//! Metrics are pure `f64` CPU reference implementations. mod bias; +mod claim; mod coverage; mod error; mod graph_metrics; @@ -25,6 +28,22 @@ mod temporal_order; pub use bias::bias_standard_error; /// Mean signed bias. pub use bias::mean_bias; +/// Four ADR 0014 claim authorities. +pub use claim::ClaimAuthority; +/// One evidence item offered for promotion. +pub use claim::ClaimEvidence; +/// Kind of evidence offered for a promotion request. +pub use claim::ClaimEvidenceKind; +/// A claim bound to one exact commit after every required gate passed. +pub use claim::PromotedClaim; +/// Exact-head promotion request. +pub use claim::PromotionRequest; +/// Parse a forty-character hexadecimal Git commit SHA. +pub use claim::parse_commit_head; +/// Promote a claim only when exact-head evidence satisfies ADR 0014. +pub use claim::promote_claim; +/// Promote a scientific claim from computed RMSE, not a hardcoded threshold. +pub use claim::promote_scientific_recovery; /// Empirical interval coverage. pub use coverage::interval_coverage; /// Wilson bounds for coverage proportions. diff --git a/crates/validation_core/tests/claim_promotion_contract.rs b/crates/validation_core/tests/claim_promotion_contract.rs new file mode 100644 index 000000000..fc22605de --- /dev/null +++ b/crates/validation_core/tests/claim_promotion_contract.rs @@ -0,0 +1,319 @@ +//! ADR 0014 claim authorities cannot be promoted from unusable evidence. + +use validation_core::{ + ClaimAuthority, ClaimEvidence, ClaimEvidenceKind, PromotionRequest, ValidationError, + parse_commit_head, promote_claim, promote_scientific_recovery, rmse_standard_error, + root_mean_square_error, +}; + +const PROTECTED_HEAD: &str = "b2a3f879ca61daefa534f122647074666d5604bc"; +const OTHER_HEAD: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + +fn implemented_main_evidence() -> [ClaimEvidence; 1] { + [ClaimEvidence::new(ClaimEvidenceKind::ExactHeadTests, true)] +} + +fn scientifically_supported_evidence() -> [ClaimEvidence; 2] { + [ + ClaimEvidence::new(ClaimEvidenceKind::ExactHeadTests, true), + ClaimEvidence::new(ClaimEvidenceKind::ScientificRecovery, true), + ] +} + +fn released_evidence() -> [ClaimEvidence; 6] { + [ + ClaimEvidence::new(ClaimEvidenceKind::ExactHeadTests, true), + ClaimEvidence::new(ClaimEvidenceKind::ScientificRecovery, true), + ClaimEvidence::new(ClaimEvidenceKind::SecuritySupplyChain, true), + ClaimEvidence::new(ClaimEvidenceKind::QualifyingReview, true), + ClaimEvidence::new(ClaimEvidenceKind::OperationalReadiness, true), + ClaimEvidence::new(ClaimEvidenceKind::SbomProvenance, true), + ] +} + +fn request<'evidence>( + target: ClaimAuthority, + candidate_head: &str, + evidence: &'evidence [ClaimEvidence], +) -> PromotionRequest<'evidence> { + PromotionRequest::new(target, candidate_head, PROTECTED_HEAD, evidence).expect("request") +} + +#[test] +fn commit_heads_are_forty_hex_bytes() { + let parsed = parse_commit_head(PROTECTED_HEAD).expect("head"); + assert_eq!(parsed.len(), 20); + assert_eq!(parse_commit_head(""), Err(ValidationError::InvalidInput)); + assert_eq!( + parse_commit_head("not-a-commit-sha"), + Err(ValidationError::InvalidInput) + ); + assert_eq!( + parse_commit_head("B2A3F879CA61DAEFA534F122647074666D5604BC"), + parse_commit_head(PROTECTED_HEAD) + ); + assert_eq!( + parse_commit_head("b2a3f879ca61daefa534f122647074666d5604bg"), + Err(ValidationError::InvalidInput) + ); +} + +#[test] +fn decision_accepted_does_not_require_implementation_evidence() { + let promoted = + promote_claim(&request(ClaimAuthority::DecisionAccepted, OTHER_HEAD, &[])).expect("design"); + assert_eq!(promoted.authority(), ClaimAuthority::DecisionAccepted); + assert_eq!( + promoted.bound_head(), + parse_commit_head(OTHER_HEAD).unwrap() + ); + assert_eq!( + ClaimAuthority::DecisionAccepted.wire_name(), + "decision_accepted" + ); +} + +#[test] +fn implemented_main_requires_exact_protected_head_and_tests() { + let promoted = promote_claim(&request( + ClaimAuthority::ImplementedMain, + PROTECTED_HEAD, + &implemented_main_evidence(), + )) + .expect("implemented"); + assert_eq!(promoted.authority(), ClaimAuthority::ImplementedMain); + assert_eq!( + promoted.bound_head(), + parse_commit_head(PROTECTED_HEAD).unwrap() + ); + + assert_eq!( + promote_claim(&request( + ClaimAuthority::ImplementedMain, + OTHER_HEAD, + &implemented_main_evidence(), + )), + Err(ValidationError::ClaimHeadMismatch) + ); + assert_eq!( + promote_claim(&request( + ClaimAuthority::ImplementedMain, + PROTECTED_HEAD, + &[] + )), + Err(ValidationError::ClaimEvidenceMissing) + ); + assert_eq!( + promote_claim(&request( + ClaimAuthority::ImplementedMain, + PROTECTED_HEAD, + &[ClaimEvidence::new(ClaimEvidenceKind::ExactHeadTests, false)], + )), + Err(ValidationError::ClaimEvidenceFailed) + ); +} + +#[test] +fn unusable_evidence_kinds_never_promote() { + let cases = [ + ( + ClaimEvidenceKind::QueuedCheck, + ValidationError::ClaimQueuedEvidence, + ), + ( + ClaimEvidenceKind::PredecessorHead, + ValidationError::ClaimPredecessorHead, + ), + ( + ClaimEvidenceKind::LlmJudgment, + ValidationError::ClaimLlmJudgment, + ), + ( + ClaimEvidenceKind::SkippedRequired, + ValidationError::ClaimSkippedRequired, + ), + ]; + for (kind, expected) in cases { + assert!(!kind.is_promotable()); + assert!(!kind.wire_name().is_empty()); + let evidence = [ + ClaimEvidence::new(ClaimEvidenceKind::ExactHeadTests, true), + ClaimEvidence::new(kind, true), + ]; + assert_eq!( + promote_claim(&request( + ClaimAuthority::ImplementedMain, + PROTECTED_HEAD, + &evidence, + )), + Err(expected) + ); + } +} + +#[test] +fn scientific_and_release_authorities_require_their_gates() { + assert_eq!( + promote_claim(&request( + ClaimAuthority::ScientificallySupported, + PROTECTED_HEAD, + &implemented_main_evidence(), + )), + Err(ValidationError::ClaimEvidenceMissing) + ); + let scientific = promote_claim(&request( + ClaimAuthority::ScientificallySupported, + PROTECTED_HEAD, + &scientifically_supported_evidence(), + )) + .expect("scientific"); + assert_eq!( + scientific.authority(), + ClaimAuthority::ScientificallySupported + ); + + assert_eq!( + promote_claim(&request( + ClaimAuthority::Released, + PROTECTED_HEAD, + &scientifically_supported_evidence(), + )), + Err(ValidationError::ClaimEvidenceMissing) + ); + let released = promote_claim(&request( + ClaimAuthority::Released, + PROTECTED_HEAD, + &released_evidence(), + )) + .expect("released"); + assert_eq!(released.authority(), ClaimAuthority::Released); + assert_eq!(ClaimAuthority::Released.wire_name(), "released"); + assert_eq!( + ClaimEvidenceKind::ScientificRecovery.wire_name(), + "scientific_recovery" + ); +} + +#[test] +fn scientific_recovery_uses_computed_rmse_not_hardcoded_thresholds() { + let truth = [0.70, 0.55, 0.40, -0.20, 0.85]; + let recovered = [0.72, 0.53, 0.41, -0.18, 0.84]; + let rmse = root_mean_square_error(&truth, &recovered).expect("rmse"); + let rmse_se = rmse_standard_error(&truth, &recovered).expect("se"); + let computed_k = (rmse / rmse_se) + 1.0; + let promoted = promote_scientific_recovery( + PROTECTED_HEAD, + PROTECTED_HEAD, + &truth, + &recovered, + computed_k, + ) + .expect("promote"); + assert_eq!( + promoted.authority(), + ClaimAuthority::ScientificallySupported + ); + assert!(rmse.is_finite()); + assert!(rmse_se.is_finite() && rmse_se > 0.0); + promote_scientific_recovery(PROTECTED_HEAD, PROTECTED_HEAD, &truth, &truth, 3.0) + .expect("exact"); + + let biased = [1.70, 1.55, 1.40, 0.80, 1.85]; + assert_eq!( + promote_scientific_recovery(PROTECTED_HEAD, PROTECTED_HEAD, &truth, &biased, 3.0), + Err(ValidationError::ClaimRecoveryRejected) + ); + assert_eq!( + promote_scientific_recovery(OTHER_HEAD, PROTECTED_HEAD, &truth, &recovered, 3.0), + Err(ValidationError::ClaimHeadMismatch) + ); + assert_eq!( + promote_scientific_recovery(PROTECTED_HEAD, PROTECTED_HEAD, &[], &[], 3.0), + Err(ValidationError::InvalidInput) + ); +} + +#[test] +fn promoted_claim_and_request_reject_invalid_heads() { + assert_eq!( + PromotionRequest::new(ClaimAuthority::DecisionAccepted, "bad", PROTECTED_HEAD, &[],).err(), + Some(ValidationError::InvalidInput) + ); + // Promoted claims can no longer be minted directly: `PromotedClaim::new` + // is crate-internal, so the only external path to a promoted claim is the + // validated `promote_claim` / `promote_scientific_recovery` flow. + let promoted = promote_claim(&request( + ClaimAuthority::DecisionAccepted, + PROTECTED_HEAD, + &[], + )) + .expect("design"); + assert_eq!(promoted.authority(), ClaimAuthority::DecisionAccepted); + assert_eq!( + promoted.bound_head(), + parse_commit_head(PROTECTED_HEAD).unwrap() + ); +} + +#[test] +fn required_evidence_refuses_co_present_failing_items() { + let mixed_passing_first = [ + ClaimEvidence::new(ClaimEvidenceKind::ExactHeadTests, true), + ClaimEvidence::new(ClaimEvidenceKind::ExactHeadTests, false), + ]; + assert_eq!( + promote_claim(&request( + ClaimAuthority::ImplementedMain, + PROTECTED_HEAD, + &mixed_passing_first, + )), + Err(ValidationError::ClaimEvidenceFailed) + ); + let mixed_failing_first = [ + ClaimEvidence::new(ClaimEvidenceKind::ExactHeadTests, false), + ClaimEvidence::new(ClaimEvidenceKind::ExactHeadTests, true), + ]; + assert_eq!( + promote_claim(&request( + ClaimAuthority::ImplementedMain, + PROTECTED_HEAD, + &mixed_failing_first, + )), + Err(ValidationError::ClaimEvidenceFailed) + ); +} + +#[test] +fn required_evidence_refuses_failing_only_items() { + assert_eq!( + promote_claim(&request( + ClaimAuthority::ImplementedMain, + PROTECTED_HEAD, + &[ClaimEvidence::new(ClaimEvidenceKind::ExactHeadTests, false)], + )), + Err(ValidationError::ClaimEvidenceFailed) + ); +} + +#[test] +fn required_evidence_accepts_passing_only_items() { + let promoted = promote_claim(&request( + ClaimAuthority::ImplementedMain, + PROTECTED_HEAD, + &implemented_main_evidence(), + )) + .expect("passing only"); + assert_eq!(promoted.authority(), ClaimAuthority::ImplementedMain); + // A failing item of a non-required kind must not block promotion of a + // kind it does not gate. + let unrelated_failure = [ + ClaimEvidence::new(ClaimEvidenceKind::ExactHeadTests, true), + ClaimEvidence::new(ClaimEvidenceKind::SecuritySupplyChain, false), + ]; + promote_claim(&request( + ClaimAuthority::ImplementedMain, + PROTECTED_HEAD, + &unrelated_failure, + )) + .expect("unrelated failure does not gate"); +} diff --git a/docs/PRIVACY_DATA_GOVERNANCE.md b/docs/PRIVACY_DATA_GOVERNANCE.md index c7ae877e0..84279d265 100644 --- a/docs/PRIVACY_DATA_GOVERNANCE.md +++ b/docs/PRIVACY_DATA_GOVERNANCE.md @@ -86,3 +86,4 @@ Required tests include cross-tenant denial, expired-purpose denial, re-identific Required tests include cross-tenant denial, expired-purpose denial, re-identification-boundary checks, export authorization, provider payload minimization, raw-source log absence, deletion/retention behavior, audit replay, and derived-sensitive-data classification. Privacy controls must be tested with realistic author/customer/project/multiple-membership cases rather than only anonymous fixtures. The in-memory `provider_receipt` crate is the current disclosure-audit gate; persistence of receipts remains accepted-target. Required tests include cross-tenant denial, expired-purpose denial, re-identification-boundary checks, export authorization, provider payload minimization, raw-source log absence, deletion/retention behavior, audit replay, and derived-sensitive-data classification. Privacy controls must be tested with realistic author/customer/project/multiple-membership cases rather than only anonymous fixtures. The in-memory `operational_log` crate is the current source-separation gate: `try_record` is the only recording API and inspects source text, source identity, and blanket-mask intent; a source-identity `&str` cannot become an analytical subject. `persistence_postgres` `audit_event` inserts call the same gate before SQL is rendered. Live HTTP and provider adapters remain accepted-target. Required tests include cross-tenant denial, expired-purpose denial, re-identification-boundary checks, export authorization, provider payload minimization, raw-source log absence, deletion/retention behavior, audit replay, and derived-sensitive-data classification. Privacy controls must be tested with realistic author/customer/project/multiple-membership cases rather than only anonymous fixtures. The in-memory `derived_sensitivity` crate is the current inheritance gate; persistence of classifications remains accepted-target. +Required tests include cross-tenant denial, expired-purpose denial, re-identification-boundary checks, export authorization, provider payload minimization, raw-source log absence, deletion/retention behavior, audit replay, and derived-sensitive-data classification. Privacy controls must be tested with realistic author/customer/project/multiple-membership cases rather than only anonymous fixtures. The in-memory `provider_receipt` crate is the current disclosure-audit gate; persistence of receipts remains accepted-target. \ No newline at end of file diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 42ba64b02..4d3df9ce0 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -1,18 +1,43 @@ # TEPP Requirements, Research, and Evidence Traceability -**Status:** Accepted cross-cutting traceability baseline +**Status:** Accepted cross-cutting traceability baseline **Last reviewed:** 2026-08-24 The full APA 7th standards/literature register remains `docs/research/standards-and-literature.md`. This matrix links durable requirements to their owning decisions and implementation/evidence maturity without duplicating the bibliography. | Requirement / decision | Canonical basis | Source/evidence boundary | Maturity | |---|---|---|---| +| immutable source evidence and exact spans | PRD; Architecture; ADR 0008 | `evidence_core`, Task 2 tests/doctoring; `persistence_postgres` source-artifact SQL insert/lookup plus idempotent retry (#40 implemented-main); typed `text_segment` byte-span SQL (active PR) | implemented-main | | immutable source evidence and exact spans | PRD; Architecture; ADR 0008 | `evidence_core`, Task 2 tests/doctoring; `persistence_postgres` source-artifact SQL insert/lookup plus idempotent retry (#40 implemented-main); `payload_bound` inbound identity/provenance/size/depth on the active PR | partial | | Rust numerical authority / CPU `f64` reference | ADR 0001 | current workspace foundation; future estimators | partial | | Rust workspace/quality foundation | ADR 0007 | workspace/CI/repository contract | implemented-main | +| six distinct clocks and uncertain intervals | PRD; ADR 0002; ISO 24617-1:2012; Hobbs & Pan (2017) | merged PR #8 `temporal_core` on protected main; PR #5 historical lineage only | implemented-main | +| Allen relation algebra/bounded closure | ADR 0002; Allen (1983) | merged PR #9 `temporal_core` path-consistency on protected main | implemented-main | +| forward-only transition subgraph | PRD; ADR 0002/0003 | `relation_graph` on protected main | implemented-main | +| event ontology/evidence mentions | PRD; ADR 0003 | `event_core` mention/instance separation on protected main; `persistence_postgres` mention SQL implemented-main refuses mention-as-instance; event-instance SQL (#39 implemented-main) refuses inverted windows; Brier calibration on the active PR; full intelligence stack remaining | partial | +| no unidentified causal language from association/precedence | ADR 0002/0003; research | `relation_graph` causal-identification gate on the active PR | active-PR | +| event ontology/evidence mentions | PRD; ADR 0003 | `event_core` mention/instance separation on protected main; `persistence_postgres` mention SQL implemented-main refuses mention-as-instance; event-instance SQL (#39 implemented-main) refuses inverted windows; full intelligence stack remaining | partial | +| event ontology/evidence mentions | PRD; ADR 0003 | `event_core` mention/instance separation on protected main; `subevent_containment` parent-window gate on the active PR; event-instance SQL implemented-main; full intelligence stack remaining | active-PR | +| six distinct clocks and uncertain intervals | PRD; ADR 0002 | PR #8 `temporal_core` on protected main; PR #5 historical only | implemented-main | +| six distinct clocks and uncertain intervals | PRD; ADR 0002 | PR #8 `temporal_core` on protected main; `available_clock` availability-vs-event/system identity on the active PR | active-PR | +| six distinct clocks and uncertain intervals | PRD; ADR 0002 | PR #8 `temporal_core` on protected main; `cutoff_clock` cutoff-vs-event/system/availability identity on the active PR | active-PR | +| six distinct clocks and uncertain intervals | PRD; ADR 0002 | PR #8 `temporal_core` on protected main; `assertion_clock` assertion-vs-event/system/document/available identity on the active PR | active-PR | +| six distinct clocks and uncertain intervals | PRD; ADR 0002 | PR #8 `temporal_core` on protected main; `event_clock` event-vs-assertion/system/document/available identity on the active PR | active-PR | | six distinct clocks and uncertain intervals | PRD; ADR 0002 | PR #8 `temporal_core` on protected main; `system_clock` system-vs-other-clock identity on the active PR | active-PR | -| Allen relation algebra/bounded closure | ADR 0002; temporal research | PR #9 `temporal_core` path-consistency on protected main | implemented-main | +| forward-only transition subgraph | PRD; ADR 0002/0003 | `relation_graph` on protected main; `citation_edge` provenance-vs-transition gate on the active PR | active-PR | +| forward-only transition subgraph | PRD; ADR 0002/0003 | `relation_graph` on protected main; `support_edge` evidential-vs-transition gate on the active PR | active-PR | +| forward-only transition subgraph | PRD; ADR 0002/0003 | `relation_graph` on protected main; `outcome_order` IPO event-time order on the active PR | partial | +| forward-only transition subgraph | PRD; ADR 0002/0003 | `relation_graph` on protected main; `summarizes_edge` summary-versus-source identity on the active PR | partial | | forward-only transition subgraph | PRD; ADR 0002/0003 | `relation_graph` on protected main; `copy_identity` copy-versus-source identity on the active PR | partial | +| time-varying cross-classified multiple membership | PRD; ADR 0003 | `membership_core` network on protected main; `inferred_status` inferred-versus-observed identity on the active PR; multilevel estimators remaining | partial | +| time-varying cross-classified multiple membership | PRD; ADR 0003 | `membership_core` network on protected main; `episode_membership` containment on the active PR; multilevel estimators remaining | partial | +| time-varying cross-classified multiple membership | PRD; ADR 0003 | `membership_core` network on protected main; `location_membership` location-versus-entity/language identity on the active PR; multilevel estimators remaining | partial | +| leakage-safe availability/cutoff snapshots | PRD; ADR 0002/0013 | `corpus_split` on protected main | implemented-main | +| interval-aware historical eligibility (`available_time` fully ≤ cutoff) | ADR 0002 | `temporal_core` `evaluate_historical_eligibility` on the active PR; unknown/open-ended availability fails closed | active-PR | +| recovery metrics (RMSE, bias, coverage, graph, temporal order, Monte Carlo SE gates) | PRD; Test Strategy; ADR 0007/0014 | `validation_core` on protected main (PR #19); SE-aware Monte Carlo gates included | implemented-main | +| PostgreSQL bitemporal/lineage persistence | ADR 0013; Architecture/ERD | `persistence_postgres` migration contracts, in-memory adapters, live SQL session/document SQL port, tenant RLS (`0002` + session GUC/role helpers), `DATABASE_URL` SQLx gate, optional `live-sqlx` `PgPool` driver, exact-head live PostgreSQL CI with isolation proof, append-only immutability triggers (`0004`), temporal interval ordering CHECKs (`0005`), typed membership assignment (`0006` implemented-main), event-relation/mention/instance SQL (#37–#39 implemented-main), source-artifact SQL (#40 implemented-main), audit-event SQL (#41 implemented-main), concurrent document-write stress (#43 implemented-main), and backup/restore integrity revalidation (#44 implemented-main); remaining physical ERD constraints | partial | +| PostgreSQL bitemporal/lineage persistence | ADR 0013; Architecture/ERD | `persistence_postgres` migration contracts, in-memory adapters, live SQL session/document SQL port, tenant RLS (`0002` + session GUC/role helpers), `DATABASE_URL` SQLx gate, optional `live-sqlx` `PgPool` driver, exact-head live PostgreSQL CI with isolation proof, append-only immutability triggers (`0004`), temporal interval ordering CHECKs (`0005`), typed membership assignment (`0006` implemented-main), event-relation/mention/instance SQL (#37–#39 implemented-main), source-artifact SQL (#40 implemented-main), audit-event SQL (#41 implemented-main), concurrent document-write stress (#43 implemented-main), backup/restore integrity revalidation (#44 implemented-main), entity/project target SQL (this PR); remaining physical ERD constraints | partial | +| PostgreSQL bitemporal/lineage persistence | ADR 0013; Architecture/ERD | `persistence_postgres` on protected main as before; `revision_order` later-revision system-time gate on the active PR; remaining physical ERD constraints | partial | | event ontology/evidence mentions | PRD; ADR 0003 | `event_core` mention/instance separation on protected main; `persistence_postgres` mention SQL implemented-main refuses mention-as-instance; event-instance SQL (#39 implemented-main) refuses inverted windows; full intelligence stack remaining | partial | | time-varying cross-classified multiple membership | PRD; ADR 0003 | `membership_core` network on protected main; `location_membership` location-versus-entity/language identity on the active PR; `membership_target` language/episode/template/department/opportunity-pool identity on PR #131; multilevel estimators remaining | partial | @@ -27,11 +52,25 @@ The full APA 7th standards/literature register remains `docs/research/standards- | TRSL-TM temporal/relational topic posterior and backend compatibility | ADR 0012; ADR 0004 | future `topic_measurement` | accepted-target | | global P0 topic identity with activity/dormancy/reactivation | ADR 0012 | future topic lineage/activity state | accepted-target | | no default stopword deletion / no TF-IDF-BM25 inferential weighting | ADR 0004/0012; PRD/TRD | future semantic/method-source model | accepted-target | +| no default stopword deletion / no TF-IDF-BM25 inferential weighting | ADR 0004/0012; PRD/TRD | `stopword_deletion` default-list refusal on the active PR; TF-IDF/BM25 inferential-weight refusal remains accepted-target | partial | +| report template/section/copied/style/modality method effects | ADR 0004/0012; PRD/TRD | simulation truth factors implemented; estimator-side method model remains future | partial | +| report template/section/copied/style/modality method effects | ADR 0004/0012; PRD/TRD | simulation truth factors implemented; `style_source` style-versus-unique-content identity on the active PR; estimator-side method model remains future | partial | +| report template/section/copied/style/modality method effects | ADR 0004/0012; PRD/TRD | simulation truth factors implemented; `modality_source` modality-versus-unique-content identity on the active PR; estimator-side method model remains future | partial | +| report template/section/copied/style/modality method effects | ADR 0004/0012; PRD/TRD | simulation truth factors implemented; `corpus_background` background-versus-unique-content identity on the active PR; estimator-side method model remains future | partial | | report template/section/copied/style/modality method effects | ADR 0004/0012; PRD/TRD | simulation truth factors implemented; `prompt_source` prompt-versus-unique-content identity on the active PR; estimator-side method model remains future | partial | | candidate K statistical/Pareto gates + blinded LLM review | ADR 0012; research | future `model_selection` | accepted-target | | compositional topic correlation / stable clustering | ADR 0005/0012; research | future `network_analysis` | accepted-target | | posterior ESEM / longitudinal invariance / DSEM | ADR 0005 | `psychometric_fit` ESEM loading and DSEM lag gates on the active PR; `psychometric_core` input gates remain #49; invariance/multilevel remain accepted-target | active-PR | -| CPU bounded multithreading + GPU/VRAM streaming/parity | ADR 0001/0006 | future `compute_backend` | accepted-target | +| CPU bounded multithreading + GPU/VRAM streaming/parity | ADR 0001/0006 | `compute_backend` CPU `f64` reference, bounded planning, and VRAM-budget refusal are active; full GPU streaming and CPU/GPU parity remain future | partial | +| TDT detection/tracking vs CHRONOS schema/prediction/temporal consistency | ADR 0016; PRD/research | `prediction_contradiction` bounded Allen promotion gate on the active PR (`refuse_promotion` requires coverage; `refuse_contradiction_or_adjacency` is not promotion authority; remaining TDT/CHRONOS tasks stay accepted-target) | active-PR | +| TDT detection/tracking vs CHRONOS schema/prediction/temporal consistency | ADR 0016; PRD/research | `event_core` TDT tracking pair precision/recall and identity-switch rate on the active PR; remaining TDT/CHRONOS stack and any future `event_intelligence` crate remain accepted-target | active-PR | +| TDT detection/tracking vs CHRONOS schema/prediction/temporal consistency | ADR 0016; PRD/research | `event_core` CHRONOS schema-slot precision/recall against known truth, `refuse_schema_prediction_as_instance` and `refuse_schema_prediction_as_transition`, label-target-derived calibrated occupancy RMSE `0.1410673598` versus always-fill `0.7071067812` in `schema_slot_contract.rs` on the active PR; remaining TDT detection/tracking, symbolic temporal consistency, and any future `event_intelligence` crate remain accepted-target | active-PR | +| TDT detection/tracking vs CHRONOS schema/prediction/temporal consistency | ADR 0016; PRD/research | `event_core` TDT story-segmentation `WindowDiff`/`Pk` on the active PR; remaining TDT/CHRONOS stack and any future `event_intelligence` crate remain accepted-target | active-PR | +| TDT detection/tracking vs CHRONOS schema/prediction/temporal consistency | ADR 0016; PRD/research | `event_core` occurrence-prediction Brier calibration on the active PR; remaining detection/schema/temporal-consistency stack future | active-PR | +| evidence-bounded LLM interpretation | ADR 0010/0012; PRD | `tepp_api` router plus future `interpretation_gateway` | partial | +| adaptive direct/verify/committee/conductor test-time compute | ADR 0010; `docs/LLM_ORCHESTRATION.md` | `tepp_api::route_orchestration`, ablation record, and credential-free contextual-orchestrator binding on protected main; live execution and learned conductor calibration remain future | partial | +| purpose-bound PII handling without blanket masking | ADR 0009; `docs/PRIVACY_DATA_GOVERNANCE.md` | `tepp_api` export authorization, elevated re-identification, and provider-payload minimization are implemented-main; migration `0007` retention/deletion/legal-hold SQL contracts are implemented-main; `encrypted_mapping` AES-256-GCM envelope is active on this PR; deployment/provider evidence remains accepted-target | active-PR | +| tenant/purpose/role/lifetime access and identity separation | ADR 0009; Threat Model | `tepp_api` time-bounded `PurposeGrant` + cross-tenant denial implemented-main; persistent `access_grant` storage remaining | partial | | TDT detection/tracking vs CHRONOS schema/prediction/temporal consistency | ADR 0016; PRD/research | future `event_intelligence` | accepted-target | | evidence-bounded LLM interpretation | ADR 0010/0012; PRD | future `interpretation_gateway` | accepted-target | | adaptive direct/verify/committee/conductor test-time compute | ADR 0010; `docs/LLM_ORCHESTRATION.md` | `tepp_api::route_orchestration` + ablation record on the active PR; live contextual-orchestrator execution remaining | partial | @@ -44,7 +83,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | autonomous model proposal separated from verification/publication/review/merge | ADR 0015 | future safe OpenCode/NVIDIA autonomous-development workflow | accepted-target | | contextual-orchestrator execution boundary | ADR 0010/0011 | credential-free `bind_contextual_orchestrator` on the active PR; live HTTP remaining | partial | | foundation validation / release-readiness ledger | ADR 0014; Test Strategy | PR #24 `docs/validation/temporal-event-foundation.md` on protected main | implemented-main | -| scientific claim promotion separated from design/implementation/release | ADR 0014; ADR policy | documentation/CI/domain validation/release evidence | partial | +| scientific claim promotion separated from design/implementation/release | ADR 0014; ADR policy | `validation_core` exact-head promotion gates on this PR; documentation/CI/domain validation remain; full package/image release bundle remaining | partial | | CSAP/SOC 2/ISO/NIST assurance readiness | `docs/COMPLIANCE_READINESS.md`; research register | repository controls + future deployment evidence | accepted-target / deployment-owned | | threat-model controls and scientific-integrity security | `SECURITY.md`; `docs/THREAT_MODEL.md` | deterministic security/privacy/scientific validation gates | partial | | accessible bitemporal/network/drift/invariance views | PRD/UML | future `visual_analytics`; Figma in approved visual phase | accepted-target | diff --git a/docs/adr/0002-six-clock-temporal-semantics.md b/docs/adr/0002-six-clock-temporal-semantics.md index cfe4c7ed2..dd281f6a0 100644 --- a/docs/adr/0002-six-clock-temporal-semantics.md +++ b/docs/adr/0002-six-clock-temporal-semantics.md @@ -1,7 +1,7 @@ # ADR 0002 — Six-clock temporal semantics and leakage prevention **Decision status:** Accepted -**Implementation maturity:** partial — typed clocks, interval algebra, event/assertion/system/cutoff/availability identity, complete document clocks, revision ordering, retrospective/provenance-vs-transition gates, bounded payload validation, and strict input-process-outcome ordering are implemented-main; downstream graph/split enforcement remains accepted-target. +**Implementation maturity:** partial — typed clocks and Allen interval algebra are implemented-main on protected `main` (PR #8/#9); interval-aware availability/cutoff eligibility, six-clock identity, document completeness, revision ordering, provenance/support/retrospective gates, and input-process-outcome ordering are covered by this active consolidation PR; downstream graph/split enforcement and estimator integration remain accepted-target. **Date:** 2026-08-05 **Supersedes:** None. ADR 0013 owns persistence/split representation; ADR 0016 owns event-intelligence reasoning above these temporal primitives. diff --git a/docs/adr/0003-relational-event-multiple-membership.md b/docs/adr/0003-relational-event-multiple-membership.md index af94a6378..9293ae5bf 100644 --- a/docs/adr/0003-relational-event-multiple-membership.md +++ b/docs/adr/0003-relational-event-multiple-membership.md @@ -1,6 +1,8 @@ # ADR 0003 — Relational event ontology and time-varying multiple membership **Decision status:** Accepted +**Implementation maturity:** partial — membership networks, event mention/instance separation, and the protected-main forward-transition foundation are implemented-main; `support_edge`, `outcome_order`, `retrospective_edge`, `inferred_status`, `copy_identity`, `summarizes_edge`, `subevent_containment`, `location_membership`, `episode_membership`, and typed target kinds are covered by this active consolidation PR; full multilevel/MMMC estimators and remaining persistence remain accepted-target. +**Date:** 2026-08-05 **Implementation maturity:** partial — membership network, event mention/instance separation, inferred/evidential/retrospective status gates, summary/source identity separation, template-copy/source identity separation, typed forward-only relation graph, strict input-process-outcome ordering, nested ICC refusal, and subevent parent-window containment are implemented-main; full multilevel/MMMC estimators and remaining persistence remain accepted-target. **Date:** 2026-08-24 **Supersedes:** None. ADR 0016 owns TDT/CHRONOS event-intelligence task semantics; this ADR remains authoritative for ontology, relation, role, and membership structure. diff --git a/docs/adr/0004-shared-multilingual-latent-space.md b/docs/adr/0004-shared-multilingual-latent-space.md index 328f28ac6..814998433 100644 --- a/docs/adr/0004-shared-multilingual-latent-space.md +++ b/docs/adr/0004-shared-multilingual-latent-space.md @@ -1,5 +1,15 @@ # ADR 0004 — Shared multilingual latent semantic space +**Decision status:** Accepted +**Implementation maturity:** partial — default stopword-deletion refusal is `stopword_deletion` on the active PR; shared-space estimators, language profiles, and TF-IDF/BM25 inferential-weight refusal remain accepted-target +**Implementation maturity:** accepted-target — style-versus-unique-content identity in `style_source` on the active PR; shared-space estimators remain accepted-target +**Implementation maturity:** accepted-target — modality-versus-unique-content identity in `modality_source` on the active PR; shared-space estimators remain accepted-target +**Implementation maturity:** accepted-target — corpus-background-versus-unique-content identity in `corpus_background` on the active PR; shared-space estimators remain accepted-target +**Implementation maturity:** accepted-target — prompt-versus-unique-content identity in `prompt_source` on the active PR; shared-space estimators remain accepted-target +**Implementation maturity:** accepted-target — corpus-background-versus-unique-content identity in `corpus_background` on the active PR; shared-space estimators remain accepted-target +**Implementation maturity:** accepted-target — modality-versus-unique-content identity in `modality_source` on the active PR; shared-space estimators remain accepted-target +**Implementation maturity:** accepted-target — copied-versus-unique-content identity in `copied_text` on the active PR; shared-space estimators remain accepted-target +**Implementation maturity:** accepted-target — style-versus-unique-content identity in `style_source` on the active PR; shared-space estimators remain accepted-target **Decision status:** Accepted **Implementation maturity:** accepted-target — prompt-versus-unique-content identity in `prompt_source` on the active PR; shared-space estimators remain accepted-target **Implementation maturity:** accepted-target — corpus-background-versus-unique-content identity in `corpus_background` on the active PR; shared-space estimators remain accepted-target diff --git a/docs/adr/0006-vram-gpu-nvidia-orchestration.md b/docs/adr/0006-vram-gpu-nvidia-orchestration.md index b1b8d1aeb..f1d978620 100644 --- a/docs/adr/0006-vram-gpu-nvidia-orchestration.md +++ b/docs/adr/0006-vram-gpu-nvidia-orchestration.md @@ -1,7 +1,7 @@ # ADR 0006 — VRAM-adaptive GPU compute and model-credential boundary **Decision status:** Accepted -**Implementation maturity:** accepted-target +**Implementation maturity:** active-PR — VRAM profiles, safety reserve, peak prediction, micro-batch autotune, typed OOM with bounded CPU `f64` fallback, and forbidden-adaptation refusal are implemented on the active PR and are not implemented-main until exact-head checks, review, and protected-main integration complete; live GPU kernels, mixed-precision device lanes, and hardware parity remain accepted-target **Date:** 2026-08-05 **Supersession:** LLM orchestration-selection and test-time-compute policy is superseded by ADR 0010. Autonomous development/review/merge authority separation is governed by ADR 0015. This ADR remains authoritative for GPU/VRAM execution and the model-credential boundary. diff --git a/docs/adr/0009-purpose-bound-pii-governance.md b/docs/adr/0009-purpose-bound-pii-governance.md index a48e2cd05..b4420f4ac 100644 --- a/docs/adr/0009-purpose-bound-pii-governance.md +++ b/docs/adr/0009-purpose-bound-pii-governance.md @@ -12,6 +12,8 @@ **Implementation maturity:** partial — persistence retention/deletion/legal-hold (migration `0007`) and purpose-bound provider-payload minimization (expired-purpose denial, log/source separation, separately authorized re-identification) are implemented-main; deployment/provider-region evidence remains accepted-target **Date:** 2026-08-10 +**Implementation maturity:** partial — persistence retention/deletion/legal-hold (migration `0007`) and purpose-bound provider-payload minimization are implemented-main; untrusted-intake grant presence in `intake_authorization` is on the active PR and is not implemented-main until exact-head checks, review, and protected-main integration complete; deployment/provider-region evidence remains accepted-target +**Date:** 2026-08-10 **Supersedes:** None. ## Context diff --git a/docs/adr/0011-standalone-modular-msa-boundary.md b/docs/adr/0011-standalone-modular-msa-boundary.md index 40e047613..9c64d3046 100644 --- a/docs/adr/0011-standalone-modular-msa-boundary.md +++ b/docs/adr/0011-standalone-modular-msa-boundary.md @@ -6,6 +6,8 @@ **Decision status:** Accepted **Implementation maturity:** partial — Rust crates are independently usable; naruon HTTP interchange and loopback live listener (`POST /v1/analysis-runs` and `/v1/exports`, fail-closed table-access, NIM/proxy headers, RFC 3339 cutoff, stream deadline) are on the active PR (not implemented-main); production TLS/`$PORT` and remaining persistence integrations remain accepted-target **Date:** 2026-08-10 +**Implementation maturity:** partial — Rust crates are independently usable; naruon HTTP interchange and loopback live listener (`POST /v1/analysis-runs` and `/v1/exports`, fail-closed table-access, NIM/proxy headers, RFC 3339 cutoff, stream deadline) are on the active PR (not implemented-main); production TLS/`$PORT` and remaining persistence integrations remain accepted-target +**Date:** 2026-08-10 **Supersedes:** The broad cross-service ownership wording in ADR 0001. ADR 0001 remains authoritative for Rust-first numerical architecture. ## Context diff --git a/docs/adr/0012-temporal-relational-shared-latent-topic-measurement.md b/docs/adr/0012-temporal-relational-shared-latent-topic-measurement.md index 2438bbd5d..1c36dac33 100644 --- a/docs/adr/0012-temporal-relational-shared-latent-topic-measurement.md +++ b/docs/adr/0012-temporal-relational-shared-latent-topic-measurement.md @@ -2,6 +2,23 @@ **Decision status:** Accepted **Implementation maturity:** partial — `corpus_background`, `topic_lineage`, `network_analysis`, `model_selection`, `stopword_deletion`, `style_source`, `copied_text`, and `modality_source` implement bounded identity and recovery gates; the TRSL-TM estimator, method effects, global topic identity, and backend interchange remain accepted-target. +**Date:** 2026-08-12 +**Decision status:** Accepted +**Implementation maturity:** accepted-target — prompt-versus-unique-content identity in `prompt_source` on the active PR; estimator-side method model remains accepted-target +**Implementation maturity:** accepted-target — corpus-background-versus-unique-content identity in `corpus_background` on the active PR; estimator-side method model remains accepted-target +**Implementation maturity:** accepted-target — modality-versus-unique-content identity in `modality_source` on the active PR; estimator-side method model remains accepted-target +**Implementation maturity:** accepted-target — copied-versus-unique-content identity in `copied_text` on the active PR; estimator-side method model remains accepted-target +**Implementation maturity:** accepted-target — style-versus-unique-content identity in `style_source` on the active PR; estimator-side method model remains accepted-target +**Implementation maturity:** partial — default stopword-deletion refusal is `stopword_deletion` on the active PR; topic estimator, global topic identity, method-effect model, and TF-IDF/BM25 inferential-weight refusal remain accepted-target +**Implementation maturity:** active-PR — `topic_lineage` keeps one P0 identity across active/dormant/reactivated states; remaining TRSL-TM estimator, method effects, and backend interchange remain accepted-target +**Implementation maturity:** active-PR — `network_analysis` refuses raw-simplex Euclidean geometry and scores cluster pair precision/recall; remaining TRSL-TM estimator, global topic identity, method effects, and backend interchange remain accepted-target +**Implementation maturity:** active-PR — `model_selection` statistical/Pareto candidate-`K` gates and known-`K` RMSE live in the new crate; remaining TRSL-TM estimator, global topic identity, method effects, and backend interchange remain accepted-target +**Implementation maturity:** partial — default stopword-deletion refusal is `stopword_deletion` on the active PR; topic estimator, global topic identity, method-effect model, and TF-IDF/BM25 inferential-weight refusal remain accepted-target +**Implementation maturity:** accepted-target — style-versus-unique-content identity in `style_source` on the active PR; estimator-side method model remains accepted-target +**Implementation maturity:** accepted-target — modality-versus-unique-content identity in `modality_source` on the active PR; estimator-side method model remains accepted-target +**Implementation maturity:** accepted-target — corpus-background-versus-unique-content identity in `corpus_background` on the active PR; estimator-side method model remains accepted-target +**Implementation maturity:** accepted-target — prompt-versus-unique-content identity in `prompt_source` on the active PR; estimator-side method model remains accepted-target +**Date:** 2026-08-12 **Date:** 2026-08-24 **Supersedes:** None; refines ADR 0004 and ADR 0005 without replacing their multilingual and psychometric authorities. diff --git a/docs/adr/0013-bitemporal-persistence-reproducibility-and-split-authority.md b/docs/adr/0013-bitemporal-persistence-reproducibility-and-split-authority.md index 69d74041d..95172cf10 100644 --- a/docs/adr/0013-bitemporal-persistence-reproducibility-and-split-authority.md +++ b/docs/adr/0013-bitemporal-persistence-reproducibility-and-split-authority.md @@ -1,5 +1,13 @@ # ADR 0013 — Bitemporal persistence, reproducibility manifests, and split authority +**Decision status:** Accepted +**Implementation maturity:** partial — migration contracts, cutoff eligibility, in-memory bitemporal adapters, live SQL session/migration port, document SQL contracts, `DATABASE_URL` SQLx gate, optional `live-sqlx` `PgPool` open/execute driver, exact-head live PostgreSQL CI, tenant RLS (`tepp_app_runtime` + session GUC), append-only reproducibility-manifest SQL insert/lookup, model-run / model-artifact / corpus-split-manifest chain (migration `0003`), append-only immutability triggers (migration `0004`), temporal interval ordering CHECK constraints (migration `0005`), typed membership-assignment storage (migration `0006`), event-relation/mention/instance SQL, source-artifact SQL, audit-event action-code validation, concurrent document-write stress, and backup/restore integrity revalidation implemented-main; remaining physical ERD and DR-runbook depth accepted-target +**Implementation maturity:** partial — migration contracts, cutoff eligibility, in-memory bitemporal adapters, live SQL session/migration port, document SQL contracts, `DATABASE_URL` SQLx gate, optional `live-sqlx` `PgPool` open/execute driver, exact-head live PostgreSQL CI, tenant RLS (`tepp_app_runtime` + session GUC), append-only reproducibility-manifest SQL insert/lookup, model-run / model-artifact / corpus-split-manifest chain (migration `0003`), append-only immutability triggers (migration `0004`), temporal interval ordering CHECK constraints (migration `0005`), typed membership-assignment storage (migration `0006`), event-relation/mention/instance SQL, source-artifact SQL, audit-event action-code validation, concurrent document-write stress, and backup/restore integrity revalidation implemented-main; typed `text_segment` SQL on the active PR +**Implementation maturity:** partial — migration contracts, cutoff eligibility, in-memory bitemporal adapters, live SQL session/migration port, document SQL contracts, `DATABASE_URL` SQLx gate, optional `live-sqlx` `PgPool` open/execute driver, exact-head live PostgreSQL CI, tenant RLS (`tepp_app_runtime` + session GUC), append-only reproducibility-manifest SQL insert/lookup, model-run / model-artifact / corpus-split-manifest chain (migration `0003`), append-only immutability triggers (migration `0004`), temporal interval ordering CHECK constraints (migration `0005`), typed membership-assignment storage (migration `0006`), event-relation/mention/instance SQL, source-artifact SQL, audit-event action-code validation, and concurrent document-write stress implemented-main; backup/restore integrity revalidation on the active PR +**Implementation maturity:** partial — migration contracts, cutoff eligibility, in-memory bitemporal adapters, live SQL session/migration port, document SQL contracts, `DATABASE_URL` SQLx gate, optional `live-sqlx` `PgPool` open/execute driver, exact-head live PostgreSQL CI, tenant RLS (`tepp_app_runtime` + session GUC), append-only reproducibility-manifest SQL insert/lookup, model-run / model-artifact / corpus-split-manifest chain (migration `0003`), append-only immutability triggers (migration `0004`), temporal interval ordering CHECK constraints (migration `0005`), typed membership-assignment storage (migration `0006`), event-relation/mention/instance SQL, source-artifact SQL, audit-event action-code validation, concurrent document-write stress, and backup/restore integrity revalidation implemented-main; entity/project target SQL on this PR +**Date:** 2026-08-12 +**Decision status:** Accepted +**Implementation maturity:** partial — migration contracts, cutoff eligibility, in-memory bitemporal adapters, live SQL session/migration port, document SQL contracts, `DATABASE_URL` SQLx gate, optional `live-sqlx` `PgPool` open/execute driver, exact-head live PostgreSQL CI, tenant RLS (`tepp_app_runtime` + session GUC), append-only reproducibility-manifest SQL insert/lookup, model-run / model-artifact / corpus-split-manifest chain (migration `0003`), append-only immutability triggers (migration `0004`), temporal interval ordering CHECK constraints (migration `0005`), typed membership-assignment storage (migration `0006`), event-relation/mention/instance SQL, source-artifact SQL, audit-event action-code validation, and concurrent document-write stress implemented-main; backup/restore integrity revalidation on the active PR **Decision status:** Accepted **Implementation maturity:** partial — migration contracts, cutoff eligibility, in-memory bitemporal adapters, live SQL session/migration port, document SQL contracts, `DATABASE_URL` SQLx gate, optional `live-sqlx` `PgPool` driver with session-affine tenant GUC execution, exact-head live PostgreSQL CI, tenant RLS (`tepp_app_runtime` + session GUC), append-only reproducibility-manifest SQL insert/lookup, model-run / model-artifact / corpus-split-manifest chain (migration `0003`), append-only immutability triggers (migration `0004`), temporal interval ordering CHECK constraints (migration `0005`), typed membership-assignment storage (migration `0006`), typed `text_segment` SQL, retention/deletion/legal-hold (migration `0007`), event-relation/mention/instance SQL, source-artifact SQL, audit-event action-code validation, concurrent document-write stress, and backup/restore integrity revalidation implemented-main; entity/project target SQL is on PR #131 and is not implemented-main until exact-head checks, review, and protected-main integration complete; remaining physical ERD and DR-runbook depth accepted-target **Date:** 2026-08-12 diff --git a/docs/adr/0014-scientific-claim-promotion-and-release-evidence.md b/docs/adr/0014-scientific-claim-promotion-and-release-evidence.md index f23aa38e9..841c4ad32 100644 --- a/docs/adr/0014-scientific-claim-promotion-and-release-evidence.md +++ b/docs/adr/0014-scientific-claim-promotion-and-release-evidence.md @@ -1,5 +1,9 @@ # ADR 0014 — Scientific claim promotion and release evidence authority +**Decision status:** Accepted +**Implementation maturity:** partial — claim/promotion authority documented; repository SBOM/provenance evidence generator and CI validation implemented; checkpoint-versus-estimator refusal is `checkpoint_authority` on the active PR; full package/image release bundle and scientific claim promotion packages remain accepted-target +**Implementation maturity:** partial — claim/promotion authority documented; repository SBOM/provenance evidence generator and CI validation implemented; `validation_core` exact-head promotion gates implemented on this PR; full package/image release bundle remains accepted-target +**Date:** 2026-08-12 **Decision status:** Accepted **Implementation maturity:** partial — claim/promotion authority documented; repository SBOM/provenance evidence generator and CI validation implemented; checkpoint-versus-estimator refusal is `checkpoint_authority` on the active PR; full package/image release bundle and scientific claim promotion packages remain accepted-target **Date:** 2026-08-12 diff --git a/docs/adr/0016-tdt-chronos-event-intelligence-boundary.md b/docs/adr/0016-tdt-chronos-event-intelligence-boundary.md index 18e206981..d5438ee68 100644 --- a/docs/adr/0016-tdt-chronos-event-intelligence-boundary.md +++ b/docs/adr/0016-tdt-chronos-event-intelligence-boundary.md @@ -4,6 +4,11 @@ **Implementation maturity:** active-PR — bounded predicted-vs-observed Allen promotion gate, including coverage before unmatched predicted mass may be authorized for promotion; TDT detection/tracking, CHRONOS schema extraction, prediction calibration, and path-consistency laws remain accepted-target **Implementation maturity:** active-PR — evidence-layer admission and first-story detection rates are implemented in `event_core` on the active PR; full TDT tracking/calibration and CHRONOS schema extraction remain accepted-target. +**Implementation maturity:** active-PR — TDT tracking pair precision/recall, identity-switch rate, and track-versus-instance/transition refusal live in existing `event_core`; remaining TDT segmentation/first-story/link and CHRONOS schema/prediction layers remain accepted-target +**Implementation maturity:** active-PR — CHRONOS schema-slot precision/recall and prediction-versus-instance refusal live in existing `event_core`; remaining TDT detection/tracking and symbolic temporal-consistency layers remain accepted-target +**Implementation maturity:** active-PR — TDT story-segmentation `WindowDiff`/`Pk`/boundary precision-recall and segmentation-versus-instance/transition refusal live in existing `event_core`; remaining TDT link/tracking/first-story and CHRONOS schema/prediction layers remain accepted-target +**Implementation maturity:** active-PR — `event_core` scores CHRONOS occurrence forecasts with a Brier rule and refuses to promote them as instances; remaining TDT detection, schema extraction, and temporal-consistency reasoning remain accepted-target +**Date:** 2026-08-12 **Date:** 2026-08-12 **Supersedes:** None; complements ADR 0002 temporal semantics and ADR 0003 event ontology/membership. diff --git a/docs/adr/0020-lineageweave-project-history-boundary.md b/docs/adr/0021-lineageweave-project-history-boundary.md similarity index 98% rename from docs/adr/0020-lineageweave-project-history-boundary.md rename to docs/adr/0021-lineageweave-project-history-boundary.md index d81c43b60..4c417e8de 100644 --- a/docs/adr/0020-lineageweave-project-history-boundary.md +++ b/docs/adr/0021-lineageweave-project-history-boundary.md @@ -1,4 +1,4 @@ -# ADR 0020 — LineageWeave project-history service boundary +# ADR 0021 — LineageWeave project-history service boundary **Decision status:** Accepted **Implementation maturity:** active-PR diff --git a/docs/adr/README.md b/docs/adr/README.md index 1572c20cf..b1170d652 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -6,17 +6,36 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | ADR | Decision | Decision status | Implementation maturity | Clarification / supersession | |---|---|---|---|---| +| [0001](0001-rust-first-modular-msa.md) | Rust-first numerical core and CPU `f64` reference | Accepted | partial | Rust owns production arithmetic and the CPU reference; GPU and estimator completion remain target work. | +| [0002](0002-six-clock-temporal-semantics.md) | Six-clock temporal semantics and fail-closed historical eligibility | Accepted | partial | Protected main owns typed clocks and Allen algebra; the active consolidation PR carries clock identity, availability/cutoff, revision, provenance, and ordering gates. | +| [0003](0003-relational-event-multiple-membership.md) | Relational event ontology and time-varying multiple membership | Accepted | partial | Protected main owns the membership network and forward-transition foundation; the active consolidation PR carries typed target and identity slices. Full multilevel/MMMC estimators and persistence remain target work. | +| [0004](0004-shared-multilingual-latent-space.md) | Shared multilingual latent semantic space | Accepted | accepted-target | Shared-space estimation and measurement invariance remain the scientific target; ADR 0020 owns span-grounded units. | +| [0005](0005-posterior-esem-dsem.md) | Posterior-aware ESEM/DSEM and compositional coordinates | Accepted | active-PR | CPU `f64` fit and longitudinal within/between slices are active; invariance and multilevel estimators remain target work. | +| [0006](0006-vram-gpu-nvidia-orchestration.md) | VRAM-adaptive GPU compute and credential boundary | Accepted | accepted-target | GPU streaming/parity and backend completion remain target work; orchestration policy belongs to ADR 0010. | +| [0007](0007-rust-workspace-quality-gates.md) | Rust workspace, toolchain, and quality gates | Accepted | implemented-main | Repository quality contracts are implemented; scientific claim promotion belongs to ADR 0014. | +| [0008](0008-immutable-evidence-identities-digests-and-spans.md) | Immutable evidence identities, digests, spans, and wire reconstruction | Accepted | partial | Identity and span contracts are implemented-main; untrusted payload bounds are active in the consolidation PR. | +| [0009](0009-purpose-bound-pii-governance.md) | Purpose-bound PII governance without blanket masking | Accepted | partial | Opaque analytical IDs, purpose grants, provider minimization, retention, and encrypted mapping are covered; deployment evidence and persistent access storage remain target work. | +| [0010](0010-adaptive-llm-orchestration.md) | Adaptive LLM orchestration and test-time compute | Accepted | partial | Direct/verify/committee routing and ablation contracts exist; live provider execution and production calibration remain target work. | +| [0011](0011-standalone-modular-msa-boundary.md) | Standalone operation and modular CWL MSA boundary | Accepted | partial | Versioned service boundaries and credential separation are authoritative; production TLS and live ports remain target work. | +| [0012](0012-temporal-relational-shared-latent-topic-measurement.md) | Temporal relational shared-latent topic measurement | Accepted | partial | Method-source and stopword identity slices are active; estimator, backend, global topic identity, and candidate-K completion remain target work. | +| [0013](0013-bitemporal-persistence-reproducibility-and-split-authority.md) | Bitemporal persistence, reproducibility, and split authority | Accepted | partial | Migration, tenant, append-only, interval, and live SQL contracts are present; physical ERD and recovery depth remain target work. | +| [0014](0014-scientific-claim-promotion-and-release-evidence.md) | Scientific claim promotion and release evidence | Accepted | partial | Exact-head promotion authority and repository evidence exist; the complete release bundle remains target work. | +| [0015](0015-autonomous-development-review-and-merge-authority.md) | Autonomous development, review, and merge authority separation | Accepted | active-PR | Proposal, deterministic verification, publication, independent review, and merge/release authority remain separate. | +| [0016](0016-tdt-chronos-event-intelligence-boundary.md) | TDT, CHRONOS, and event-intelligence boundary | Accepted | active-PR | Evidence admission, tracking, schema, segmentation, and Brier gates are active; complete intelligence remains target work. | +| [0017](0017-hourly-contextual-orchestrator-gateway.md) | Hourly contextual-orchestrator gateway and provider discovery | Accepted | active-PR | Proposal-model execution is pinned behind a loopback gateway and remains separate from verification and merge authority. | +| [0018](0018-consumer-scoped-analysis-run-ingress.md) | Consumer-scoped modular analysis-run ingress | Accepted | active-PR | Closed consumer registry, credential-free exchange, and consumer-qualified idempotency are active. | +| [0019](0019-project-history-wire-size-symmetry.md) | Symmetric LineageWeave project-history wire-size enforcement | Accepted | active-PR | Request serialization and generated project-history projections share bounded size rules. | +| [0020](0020-span-grounded-semantic-units.md) | Span-grounded semantic units; language tags are not identity | Accepted | active-PR | First ADR 0004 production slice; concept alignment, invariance, and topic estimation are not claimed. | +| [0021](0021-lineageweave-project-history-boundary.md) | LineageWeave project-history service boundary | Accepted | active-PR | Credential-free bounded project-history API preserves LineageWeave authorization ownership. | | [0001](0001-rust-first-modular-msa.md) | Rust-first numerical core and CPU `f64` reference | Accepted | partial | ADR 0011 owns cross-service/MSA authority; 0001 retains numerical/backend authority. | | [0002](0002-six-clock-temporal-semantics.md) | Six-clock temporal semantics and fail-closed historical leakage prevention | Accepted | partial | Typed clocks/intervals (merged PR #8), Allen/path-consistency (merged PR #9), the clock-identity/revision-order/document-completeness gates (`system_clock`, `event_clock`, `assertion_clock`, `cutoff_clock`, `available_clock`, `document_clocks`, `revision_order`), and the provenance/ordering gates (`citation_edge`, `support_edge`, `retrospective_edge`) are implemented-main; superseded PRs #5/#6 are historical lineage only; remaining graph/split enforcement stays accepted-target. | | [0003](0003-relational-event-multiple-membership.md) | Relational event ontology and time-varying cross-classified multiple membership | Accepted | partial | Membership network/roles, Kish ESS, nested ICC, subevent parent-window containment (`subevent_containment`), the forward-only relation graph, evidential-vs-transition identity (`support_edge`), inferred-versus-observed identity (`inferred_status`), retrospective-reporting identity (`retrospective_edge`), summary-versus-source identity (`summarizes_edge`), copy-versus-source identity (`copy_identity`), location-versus-entity/language identity (`location_membership`), and IPO event-time order (`outcome_order`) are implemented-main; typed target-kind identity in `membership_target` is on PR #131; full multilevel estimators and persistence remain accepted-target. ADR 0016 owns event-intelligence tasks. | | [0004](0004-shared-multilingual-latent-space.md) | One shared multilingual latent space with explicit invariance status | Accepted | accepted-target | ADR 0012 owns the full topic-estimator/backend/global-topic contract. | -| [0005](0005-posterior-esem-dsem.md) | Posterior-aware ESEM/DSEM and valid compositional coordinates | Accepted | active-PR | CPU `f64` ESEM/DSEM fit in `psychometric_fit` on the active PR; `psychometric_core` input gates remain #49; invariance/multilevel remain accepted-target. | -| [0005](0005-posterior-esem-dsem.md) | Posterior-aware ESEM/DSEM and valid compositional coordinates | Accepted | active-PR | Within/between decomposition in `longitudinal_core` on the active PR; remaining ESEM/DSEM fit remains accepted-target. ADR 0012 owns the upstream topic/network contract. | +| [0005](0005-posterior-esem-dsem.md) | Posterior-aware ESEM/DSEM and valid compositional coordinates | Accepted | active-PR | CPU `f64` ESEM/DSEM fit in `psychometric_fit` on the active PR; `psychometric_core` input gates remain #49; within/between decomposition in `longitudinal_core` is on the active PR; invariance/multilevel and remaining ESEM/DSEM fit remain accepted-target. ADR 0012 owns the upstream topic/network contract. | | [0006](0006-vram-gpu-nvidia-orchestration.md) | VRAM-adaptive GPU compute and model-credential boundary | Accepted | accepted-target | LLM orchestration policy superseded by ADR 0010; autonomous development authority governed by ADR 0015. | | [0007](0007-rust-workspace-quality-gates.md) | Explicit Rust workspace, pinned toolchains, and exact quality gates | Accepted | implemented-main | ADR 0014 governs scientific/product claim promotion beyond repository-quality tooling. | | [0008](0008-immutable-evidence-identities-digests-and-spans.md) | Immutable evidence identities, `SHA-256` digests, exact spans, and strict wire reconstruction | Accepted | implemented-main | Identities/spans are implemented-main; inbound size/depth/identity/provenance refusal is `payload_bound` on the active PR. ADR 0013 governs persistence/split authority. | | [0009](0009-purpose-bound-pii-governance.md) | Purpose-bound PII governance without blanket masking | Accepted | partial | Persistence retention/deletion/legal-hold (`0007`) and provider-payload minimization are implemented-main; authorization/export and deployment evidence remain accepted-target. | -| [0008](0008-immutable-evidence-identities-digests-and-spans.md) | Immutable evidence identities, `SHA-256` digests, exact spans, and strict wire reconstruction | Accepted | implemented-main | ADR 0013 governs future persistence/reproducibility/split authority. | | [0009](0009-purpose-bound-pii-governance.md) | Purpose-bound PII governance without blanket masking | Accepted | partial | Persistence retention/deletion/legal-hold (`0007`) and provider-payload minimization are implemented-main; untrusted-intake grant presence is `intake_authorization` on the active PR; deployment evidence remains accepted-target. | | [0009](0009-purpose-bound-pii-governance.md) | Purpose-bound PII governance without blanket masking | Accepted | active-PR | `encrypted_mapping` AES-256-GCM envelope on the active PR; persistence retention/deletion/legal-hold (`0007`) and provider-payload minimization are implemented-main; persistence/KMS and remaining adapters stay accepted-target. Controls are not a certification claim. | | [0009](0009-purpose-bound-pii-governance.md) | Purpose-bound PII governance without blanket masking | Accepted | partial | Retention/deletion/legal-hold and provider-payload minimization are implemented-main; provider-disclosure receipts are active-PR; deployment evidence remains accepted-target. | @@ -53,7 +72,6 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [0017](0017-hourly-contextual-orchestrator-gateway.md) | Hourly contextual-orchestrator gateway and all-provider model discovery | Accepted | active-PR | Keeps proposal-model execution behind a pinned loopback gateway while preserving independent verifier, publisher, reviewer, and merge authority. | | [0018](0018-consumer-scoped-analysis-run-ingress.md) | Consumer-scoped modular analysis-run ingress | Accepted | active-PR | Narrows ADR 0011 for the closed consumer registry, credential-free exchange, and consumer-qualified idempotency namespace; production TLS remains separate. | | [0019](0019-project-history-wire-size-symmetry.md) | Symmetric project-history wire-size enforcement | Accepted | active-PR | Narrows ADR 0008 for request serialization and generated LineageWeave project-history projections. | -| [0020](0020-lineageweave-project-history-boundary.md) | LineageWeave project-history service boundary | Accepted | active-PR | Narrows ADR 0011 for the credential-free bounded project-history API and preserves LineageWeave authorization ownership. | ## Decision ownership summary @@ -78,7 +96,7 @@ Use the narrowest owning ADR when decisions overlap: - **hourly proposal gateway and provider discovery:** ADR 0017. - **modular consumer admission / replay identity:** ADR 0018. - **project-history wire-size symmetry:** ADR 0019. -- **LineageWeave project-history service boundary:** ADR 0020. +- **LineageWeave project-history service boundary:** ADR 0021. ## Change and supersession rule diff --git a/docs/research/causal-identification-gate.md b/docs/research/causal-identification-gate.md new file mode 100644 index 000000000..2f99ee822 --- /dev/null +++ b/docs/research/causal-identification-gate.md @@ -0,0 +1,25 @@ +# Causal identification versus association + +## Scope + +This note doctors the `relation_graph` gate that keeps TEPP from converting association, temporal precedence, or document links into causal language: + +1. only `causes` and `intervenes_on` may be described as identified causal claims; +2. `leads_to`, `enables`, production, input/process, and all provenance kinds fail closed. + +No database migration is allocated. A later identified design can widen the allowed set with an ADR. + +## Authoritative sources + +Pearl, J. (2009). *Causality: Models, reasoning, and inference* (2nd ed.). Cambridge University Press. + +Holland, P. W. (1986). Statistics and causal inference. *Journal of the American Statistical Association, 81*(396), 945–960. https://doi.org/10.1080/01621459.1986.10478354 + +## Application + +Holland (1986) and Pearl (2009) distinguish association and temporal order from an identified causal effect. TEPP therefore refuses to treat `references`, `leads_to`, or `enables` as `causes` without a later identification argument (Holland, 1986; Pearl, 2009). + +## Verification + +- `refuse_association_as_cause(Causes)` and `IntervenesOn` succeed; +- every other closed vocabulary kind returns `CausalClaimNotIdentified`. diff --git a/docs/research/chronos-prediction-calibration.md b/docs/research/chronos-prediction-calibration.md new file mode 100644 index 000000000..ca0a76622 --- /dev/null +++ b/docs/research/chronos-prediction-calibration.md @@ -0,0 +1,30 @@ +# CHRONOS occurrence-prediction calibration + +## Scope + +This note doctors the `event_core` contract for CHRONOS-style occurrence forecasts: + +1. a forecast is hypothesized future or schema-completion evidence, not a promoted event instance; +2. `chronos_prediction_brier_score` is the mean squared error of occurrence probabilities against later-observed binary truth; +3. empty or length-mismatched streams fail closed. + +No database migration is allocated. Mention-confidence scoring, TDT detection, schema-slot extraction, and temporal-consistency reasoning remain separate slices. + +## Authoritative sources + +Anagnostopoulos, E., Batsakis, S., & Petrakis, E. G. M. (2013). CHRONOS: A reasoning engine for qualitative temporal information in OWL. *Procedia Computer Science, 22*, 70–77. https://doi.org/10.1016/j.procs.2013.09.082 + +Brier, G. W. (1950). Verification of forecasts expressed in terms of probability. *Monthly Weather Review, 78*(1), 1–3. https://doi.org/10.1175/1520-0493(1950)078<0001:VOFEIT>2.0.CO;2 + +Gneiting, T., & Raftery, A. E. (2007). Strictly proper scoring rules, prediction, and estimation. *Journal of the American Statistical Association, 102*(477), 359–378. https://doi.org/10.1198/016214506000001437 + +## Application + +CHRONOS-style reasoning may propose next-event or schema-completion candidates (Anagnostopoulos et al., 2013). ADR 0016 keeps those candidates hypothetical until later evidence supports them. Brier (1950) defines the mean squared error of a probability forecast, and Gneiting and Raftery (2007) treat that score as strictly proper, so a forecast that is certain when the event later occurs and impossible when it does not is uniquely optimal. TEPP therefore scores CHRONOS occurrence probabilities against later-observed truth and refuses to cast a prediction as an event instance (Brier, 1950; Gneiting & Raftery, 2007). + +## Verification + +- forecasts `(1,0,1)` against outcomes `(occurred, did_not_occur, occurred)` recover Brier `0`; +- calibrated probabilities beat an always-occur predictor on mixed later truth; +- empty and mismatched streams return `InvalidWirePayload`; +- `refuse_prediction_as_instance` always returns `PredictionIsNotEventInstance`. diff --git a/docs/research/chronos-schema-slot-calibration.md b/docs/research/chronos-schema-slot-calibration.md new file mode 100644 index 000000000..a6fcb45c9 --- /dev/null +++ b/docs/research/chronos-schema-slot-calibration.md @@ -0,0 +1,31 @@ +# CHRONOS schema-slot calibration + +## Scope + +This note doctors the `event_core` gate that keeps CHRONOS schema-slot prediction distinct from event-instance promotion: + +1. a filled versus empty slot label is prediction evidence, not a promoted instance or state transition; +2. slot precision and recall are computed from known-truth `(role, argument)` fills; +3. calibrated occupancy probabilities recover the binary fill target with lower RMSE than an always-fill predictor. + +No database migration is allocated. A later CHRONOS reasoner may consume these scores as hypothetical schema evidence only. + +## Authoritative sources + +Anagnostopoulos, E., Batsakis, S., & Petrakis, E. G. M. (2013). CHRONOS: A reasoning engine for qualitative temporal information in OWL. *Procedia Computer Science, 22*, 70–77. https://doi.org/10.1016/j.procs.2013.09.082 + +Chambers, N., & Jurafsky, D. (2009). Unsupervised learning of narrative schemas and their participants. In *Proceedings of the Joint Conference of the 47th Annual Meeting of the ACL and the 4th International Joint Conference on Natural Language Processing of the AFNLP* (pp. 602–610). Association for Computational Linguistics. + +Doddington, G., Mitchell, A., Przybocki, M., Ramshaw, L., Strassel, S., & Weischedel, R. (2004). The Automatic Content Extraction (ACE) program—Tasks, data, and evaluation. In *Proceedings of the Fourth International Conference on Language Resources and Evaluation (LREC’04)* (pp. 837–840). European Language Resources Association. + +## Application + +Anagnostopoulos et al. (2013) describe CHRONOS as a reasoner for qualitative temporal information and inferred temporal relations. Chambers and Jurafsky (2009) provide a narrative-schema participant-slot precedent, while Doddington et al. (2004) describe system-to-reference mapping and application-level evaluation; none of these sources defines TEPP's metric contract. TEPP independently refuses prediction promotion through `refuse_schema_prediction_as_instance` and `refuse_schema_prediction_as_transition`, computes slot precision and recall through `schema_slot_precision` and `schema_slot_recall`, and evaluates known-truth RMSE in [`schema_slot_contract.rs`](../../crates/event_core/tests/schema_slot_contract.rs) using production `SchemaSlotLabel` and `EventConfidence` APIs (Anagnostopoulos et al., 2013; Chambers & Jurafsky, 2009; Doddington et al., 2004). + +## Verification + +- `refuse_schema_prediction_as_instance` always returns `SchemaPredictionIsNotEventInstance`; +- `refuse_schema_prediction_as_transition` always returns `SchemaPredictionIsNotStateTransition`; +- `decide_schema_slot` uses an inclusive probability threshold; +- `schema_slot_precision` and `schema_slot_recall` fail closed on empty or duplicate fill sets; +- computed RMSE of known occupancy targets is lower under calibrated probabilities than under an always-fill predictor. diff --git a/docs/research/episode-membership-identity.md b/docs/research/episode-membership-identity.md new file mode 100644 index 000000000..84aedc44e --- /dev/null +++ b/docs/research/episode-membership-identity.md @@ -0,0 +1,29 @@ +# Episode membership cannot escape the episode interval (doctoring) + +## Scope + +`episode_membership` keeps a document's episode assignment inside the +episode's event-time interval. Recovery is the computed share of +containment flags that match known truth. + +This slice does not persist memberships, allocate migration `0008`, or +replace `membership_core` or `subevent_containment`. + +## Authority + +### Normative TEPP contract + +- `docs/adr/0003-relational-event-multiple-membership.md` — episodes + form time-varying multiple-membership assignments with governed + validity intervals. +- `docs/adr/0002-six-clock-temporal-semantics.md` — membership validity + is event/valid time. + +### Supporting literature + +Allen (1983) defines interval `during` and equality. A membership that +starts before or ends after its episode is not `during` that episode. + +Allen, J. F. (1983). Maintaining knowledge about temporal intervals. +*Communications of the ACM, 26*(11), 832–843. +https://doi.org/10.1145/182.358434 diff --git a/docs/research/event-tracking-calibration.md b/docs/research/event-tracking-calibration.md new file mode 100644 index 000000000..5389a5a88 --- /dev/null +++ b/docs/research/event-tracking-calibration.md @@ -0,0 +1,31 @@ +# Event-tracking calibration + +## Scope + +This note doctors the `event_core` gate that keeps TDT tracking distinct from event-instance promotion and state-transition authority: + +1. a hypothesized track assignment is measurement evidence, not a promoted instance or transition; +2. pair precision, pair recall, and identity-switch rate are computed from known-truth assignments; +3. calibrated same-track probabilities recover the binary same-track target with lower RMSE than an always-one-track detector. + +No database migration is allocated. Later TDT/CHRONOS layers may consume these scores as measurement evidence only. + +## Authoritative sources + +Allan, J., Carbonell, J., Doddington, G., Yamron, J., & Yang, Y. (1998). Topic detection and tracking pilot study: Final report. In *Proceedings of the DARPA Broadcast News Transcription and Understanding Workshop* (pp. 194–218). + +Allan, J. (Ed.). (2002). *Topic detection and tracking: Event-based information organization*. Kluwer Academic Publishers. + +Fiscus, J. G., & Doddington, G. R. (2002). Topic detection and tracking evaluation overview. In J. Allan (Ed.), *Topic detection and tracking: Event-based information organization* (pp. 17–31). Kluwer Academic Publishers. + +## Application + +Allan et al. (1998) and Allan (2002) define topic tracking as a longitudinal *same-topic / same-story* assignment task whose official evaluation reports miss, false-alarm, and tracking-cost trade-offs rather than instance identity. Fiscus and Doddington (2002) keep those tracking scores in the measurement layer. TEPP therefore refuses to cast a track assignment as an event instance or a forward state transition and requires computed pair precision, pair recall, identity-switch rate, and RMSE against known truth (Allan et al., 1998; Allan, 2002; Fiscus & Doddington, 2002). + +## Verification + +- `refuse_track_as_instance` always returns `EventTrackIsNotEventInstance`; +- `refuse_track_as_transition` always returns `EventTrackIsNotStateTransition`; +- `tracking_pair_precision` and `tracking_pair_recall` fail closed on empty, mismatched, duplicate-mention, or pairless assignment streams; +- `tracking_identity_switch_rate` fails closed when no consecutive truth pair stays on the same track; +- computed RMSE of known same-track pair targets is lower under calibrated probabilities than under an always-one-track detector. diff --git a/docs/research/interval-cutoff-eligibility.md b/docs/research/interval-cutoff-eligibility.md new file mode 100644 index 000000000..7a59159a6 --- /dev/null +++ b/docs/research/interval-cutoff-eligibility.md @@ -0,0 +1,33 @@ +# Interval-aware historical eligibility (doctoring) + +## Scope + +This note doctors the `temporal_core` historical-eligibility contract: + +1. evidence may enter a historical analysis only when its governed availability interval is fully at or before the knowledge cutoff; +2. unknown availability and open-ended upper bounds fail closed because they can extend past the cutoff; +3. event time and document time cannot be substituted for availability. + +Point-instant `available_time <= knowledge_cutoff` remains the exact special case. This crate owns the interval decision; persistence adapters and corpus snapshots continue to apply the same inequality to stored instants. The change allocates no database migration. + +## Authoritative sources + +Tashman, L. J. (2000). Out-of-sample tests of forecasting accuracy: An analysis and review. *International Journal of Forecasting, 16*(4), 437–450. https://doi.org/10.1016/S0169-2070(00)00065-0 + +Jensen, C. S., & Snodgrass, R. T. (1999). Temporal data management. *IEEE Transactions on Knowledge and Data Engineering, 11*(1), 36–44. https://doi.org/10.1109/69.755613 + +Allen, J. F. (1983). Maintaining knowledge about temporal intervals. *Communications of the ACM, 26*(11), 832–843. https://doi.org/10.1145/182.358434 + +## Application + +Tashman (2000) requires that evaluation origins use only information available at the origin. Jensen and Snodgrass (1999) separate valid time from transaction/availability time so a later report about an earlier event cannot leak into an earlier analysis. When availability is an interval rather than a point, Allen (1983) interval bounds are the representation: if any possible availability instant is after the cutoff, the evidence is not fully eligible. + +TEPP therefore computes the latest representable availability instant and admits the interval only when that instant is `<= knowledge_cutoff`. An unknown interval or an unbounded upper bound has no such instant and fails closed. + +## Verification + +- exact availability on or before the cutoff is eligible; one second later is not; +- a closed interval whose included upper bound is after the cutoff is ineligible; +- an exclusive upper bound one nanosecond after the cutoff remains eligible because the latest representable instant is the cutoff; +- unknown and open-ended-upper availability return `UncertainAvailability`; +- the gate agrees with an independently computed latest-instant comparison on a fixture suite. diff --git a/docs/research/scientific-claim-promotion-gates.md b/docs/research/scientific-claim-promotion-gates.md new file mode 100644 index 000000000..6e9f89280 --- /dev/null +++ b/docs/research/scientific-claim-promotion-gates.md @@ -0,0 +1,31 @@ +# Scientific claim-promotion gates + +## Scope + +This note doctors the first ADR 0014 executable promotion slice in `validation_core`: + +1. four claim authorities remain distinct (`decision_accepted`, `implemented_main`, `scientifically_supported`, `released`); +2. implementation, scientific, and release authorities bind to one exact protected-head SHA; +3. queued checks, predecessor-head results, skipped required tests, and LLM judgments cannot promote any authority; +4. scientific promotion uses computed RMSE and its standard error, not a hardcoded recovery threshold. + +Full package/image release bundles remain accepted-target. No database migration is allocated. + +## Authoritative sources + +National Academies of Sciences, Engineering, and Medicine. (2019). *Reproducibility and replicability in science*. The National Academies Press. https://doi.org/10.17226/25303 + +Wasserstein, R. L., & Lazar, N. A. (2016). The ASA statement on *p*-values: Context, process, and purpose. *The American Statistician, 70*(2), 129–133. https://doi.org/10.1080/00031305.2016.1154108 + +## Application + +The National Academies (2019) separate computational reproducibility from a scientific claim that a result is correct. Wasserstein and Lazar (2016) refuse to treat a passing statistical threshold as automatic scientific authority. TEPP therefore refuses to promote `implemented_main`, `scientifically_supported`, or `released` from queued, stale, skipped, or LLM evidence, and accepts scientific recovery only when computed RMSE lies within a configured number of its own standard errors (National Academies of Sciences, Engineering, and Medicine, 2019; Wasserstein & Lazar, 2016). + +## Verification + +- `DecisionAccepted` binds without implementation evidence; +- `ImplementedMain` requires the candidate SHA to equal the protected SHA and passing exact-head tests; +- queued, predecessor, skipped-required, and LLM evidence return dedicated fail-closed errors; +- `ScientificallySupported` and `Released` require their additional gates; +- a near-recovery vector promotes only when the computed RMSE/SE multiplier admits it; +- a large bias vector returns `ClaimRecoveryRejected`. diff --git a/docs/research/standards-and-literature.md b/docs/research/standards-and-literature.md index 4e2b274db..70ce245ff 100644 --- a/docs/research/standards-and-literature.md +++ b/docs/research/standards-and-literature.md @@ -16,12 +16,29 @@ Fox, J.-P., & Glas, C. A. W. (2001). Bayesian estimation of a multilevel IRT mod Marsh, H. W., Morin, A. J. S., Parker, P. D., & Kaur, G. (2014). Exploratory structural equation modeling: An integration of the best features of exploratory and confirmatory factor analysis. *Annual Review of Clinical Psychology, 10*, 85–110. https://doi.org/10.1146/annurev-clinpsy-032813-153700 +TEPP applies these sources to construct definition, score interpretation, reliability, validity evidence, uncertainty, consequences, longitudinal invariance, ESEM cross-loadings, and DSEM. Topic outputs are treated as fallible indicators or components only after their construct role is evaluated. `psychometric_fit` recovers those cross-loadings and event-time lagged paths on a CPU `f64` OLS path; see `docs/research/esem-dsem-fit.md`. Browne, W. J., Goldstein, H., & Rasbash, J. (2001). Multiple membership multiple classification (MMMC) models. *Statistical Modelling, 1*(2), 103–124. https://doi.org/10.1177/1471082X0100100202 Jones, K. (1991). Specifying and estimating multi-level models for geographical research. *Transactions of the Institute of British Geographers, 16*(2), 148–160. https://doi.org/10.2307/622612 TEPP applies these sources to construct definition, score interpretation, reliability, validity evidence, uncertainty, consequences, longitudinal invariance, ESEM cross-loadings, and DSEM. Topic outputs are treated as fallible indicators or components only after their construct role is evaluated. Location and market assignments remain multiple-membership classifications; they are not permanent entity identity and not language channels (Browne et al., 2001; Jones, 1991). +## Numerical precision, memory-aware computation, and causal identification + +IEEE. (2019). *IEEE standard for floating-point arithmetic* (IEEE Std 754-2019). https://standards.ieee.org/ieee/754/6210/ + +Micikevicius, P., Narang, S., Alben, J., Diamos, G., Elsen, E., Garcia, D., Ginsburg, B., Houston, M., Kuchaiev, O., Venkatesh, G., & Wu, H. (2018). Mixed precision training. In *International Conference on Learning Representations*. https://openreview.net/forum?id=r1gs9JgRZ + +Ogita, T., Rump, S. M., & Oishi, S. (2005). Accurate sum and dot product. *SIAM Journal on Scientific Computing, 26*(6), 1955–1988. https://doi.org/10.1137/030601818 + +Rhu, M., Gimelshein, N., Clemons, J., Zulfiqar, A., & Keckler, S. W. (2016). vDNN: Virtualized deep neural networks for scalable, memory-efficient neural network design. In *2016 49th Annual IEEE/ACM International Symposium on Microarchitecture (MICRO)* (pp. 1–13). IEEE. https://doi.org/10.1109/MICRO.2016.7783721 + +Holland, P. W. (1986). Statistics and causal inference. *Journal of the American Statistical Association, 81*(396), 945–960. https://doi.org/10.1080/01621459.1986.10478354 + +Pearl, J. (2009). *Causality: Models, reasoning, and inference* (2nd ed.). Cambridge University Press. + +These sources support the repository's binary64 reference arithmetic, compensated summation, bounded accelerator-memory planning, and separation of association or temporal precedence from identified causal effects. The implementation notes in `docs/research/vram-budget-types.md` and `docs/research/causal-identification-gate.md` remain the claim-specific records. + ## Structural, correlated, dynamic, relational, and multilingual topic models Blei, D. M., & Lafferty, J. D. (2006). Dynamic topic models. In *Proceedings of the 23rd International Conference on Machine Learning* (pp. 113–120). Association for Computing Machinery. https://doi.org/10.1145/1143844.1143859 @@ -36,12 +53,20 @@ Roberts, M. E., Stewart, B. M., & Tingley, D. (2019). stm: An R package for stru Roberts, M. E., Stewart, B. M., Tingley, D., Lucas, C., Leder-Luis, J., Gadarian, S. K., Albertson, B., & Rand, D. G. (2014). Structural topic models for open-ended survey responses. *American Journal of Political Science, 58*(4), 1064–1082. https://doi.org/10.1111/ajps.12103 +Bateman, J. A. (2008). *Multimodality and genre: A foundation for the systematic analysis of multimodal documents*. Palgrave Macmillan. Chemudugunta, C., Smyth, P., & Steyvers, M. (2007). Modeling general and specific aspects of documents with a probabilistic topic model. In B. Schölkopf, J. Platt, & T. Hoffman (Eds.), *Advances in Neural Information Processing Systems 19* (pp. 241–248). MIT Press. Bianchi, F., Terragni, S., Hovy, D., Nozza, D., & Fersini, E. (2021). Cross-lingual contextualized topic models with zero-shot learning. In *Proceedings of the 16th Conference of the European Chapter of the Association for Computational Linguistics* (pp. 1676–1683). Association for Computational Linguistics. https://doi.org/10.18653/v1/2021.eacl-main.143 Nguyen, T. P., Minh, N. V., Nguyen, T., Van, L. N., Nguyen, D. A., Sang, D. V., & Le, T. (2025). XTRA: Cross-lingual topic modeling with topic and representation alignments. In *Findings of the Association for Computational Linguistics: EMNLP 2025*. Association for Computational Linguistics. +TEPP retains a logistic-normal CPU reference while allowing adapter backends that satisfy shared-latent, posterior, temporal, relational, and measurement-invariance contracts. `topic_lineage` keeps one global topic identity when activity becomes dormant or reactivated. +Schofield, A., Magnusson, M., & Mimno, D. (2017). Pulling out the stops: Rethinking stopword removal for topic models. In *Proceedings of the 15th Conference of the European Chapter of the Association for Computational Linguistics: Volume 2, Short Papers* (pp. 432–436). Association for Computational Linguistics. https://doi.org/10.18653/v1/E17-2069 + +TEPP retains a logistic-normal CPU reference while allowing adapter backends that satisfy shared-latent, posterior, temporal, relational, and measurement-invariance contracts. Default or global stopword deletion is not a valid method for removing repeated report language; `stopword_deletion` refuses that treatment so boilerplate stays explicit method/background structure (Schofield, Magnusson, & Mimno, 2017). +TEPP retains a logistic-normal CPU reference while allowing adapter backends that satisfy shared-latent, posterior, temporal, relational, and measurement-invariance contracts. House-voice style residue is modeled as explicit structure, not unique latent content and not a stopword deletion (Roberts et al., 2019). +TEPP retains a logistic-normal CPU reference while allowing adapter backends that satisfy shared-latent, posterior, temporal, relational, and measurement-invariance contracts. Non-lexical modality is modeled as explicit structure, not unique latent content and not a stopword deletion (Bateman, 2008). +TEPP retains a logistic-normal CPU reference while allowing adapter backends that satisfy shared-latent, posterior, temporal, relational, and measurement-invariance contracts. Corpus-background wording is modeled as explicit structure, not unique latent content and not a stopword deletion (Chemudugunta et al., 2007). Brown, T. B., Mann, B., Ryder, N., Subbiah, M., Kaplan, J. D., Dhariwal, P., Neelakantan, A., Shyam, P., Sastry, G., Askell, A., Agarwal, S., Herbert-Voss, A., Krueger, G., Henighan, T., Child, R., Ramesh, A., Ziegler, D., Wu, J., Winter, C., … Amodei, D. (2020). Language models are few-shot learners. *Advances in Neural Information Processing Systems, 33*, 1877–1901. https://papers.neurips.cc/paper/2020/hash/1457c0d6bfcb4967418bfb8ac142f64a-Abstract.html Reynolds, L., & McDonell, K. (2021). Prompt programming for large language models: Beyond the few-shot paradigm. In *Extended abstracts of the 2021 CHI conference on human factors in computing systems*. Association for Computing Machinery. https://doi.org/10.1145/3411763.3451760 @@ -87,10 +112,16 @@ International Organization for Standardization. (2012). *Language resource manag Hobbs, J. R., & Pan, F. (2017). *Time ontology in OWL* (W3C Recommendation). World Wide Web Consortium. https://www.w3.org/TR/owl-time/ +Allan, J., Carbonell, J., Doddington, G., Yamron, J., & Yang, Y. (1998). Topic detection and tracking pilot study: Final report. In *Proceedings of the DARPA Broadcast News Transcription and Understanding Workshop* (pp. 194–218). Allen, J. F. (1983). Maintaining knowledge about temporal intervals. *Communications of the ACM, 26*(11), 832–843. https://doi.org/10.1145/182.358434 Allan, J. (Ed.). (2002). *Topic detection and tracking: Event-based information organization*. Kluwer Academic Publishers. +Fiscus, J. G., & Doddington, G. R. (2002). Topic detection and tracking evaluation overview. In J. Allan (Ed.), *Topic detection and tracking: Event-based information organization* (pp. 17–31). Kluwer Academic Publishers. +Beeferman, D., Berger, A., & Lafferty, J. (1999). Statistical models for text segmentation. *Machine Learning, 34*(1–3), 177–210. https://doi.org/10.1023/A:1007506220214 + +Pevzner, L., & Hearst, M. A. (2002). A critique and improvement of an evaluation metric for text segmentation. *Computational Linguistics, 28*(1), 19–36. https://doi.org/10.1162/089120102317341756 + Anagnostopoulos, E., Batsakis, S., & Petrakis, E. G. M. (2013). CHRONOS: A reasoning engine for qualitative temporal information in OWL. *Procedia Computer Science, 22*, 70–77. https://doi.org/10.1016/j.procs.2013.09.082 International Organization for Standardization. (2019). *Date and time—Representations for information interchange—Part 1: Basic rules* (ISO Standard No. 8601-1:2019). https://www.iso.org/standard/70907.html @@ -107,10 +138,43 @@ Tashman, L. J. (2000). Out-of-sample tests of forecasting accuracy: An analysis TEPP uses interval and partial-order reasoning, bitemporal availability, leakage-safe cutoffs, TDT segmentation/link/detection/first-story/tracking tasks, and separate neural/symbolic event-schema and temporal-consistency layers. Valid time, transaction time, and a document's availability are not substitutes for `cutoff_clock` knowledge cutoff. +TEPP uses interval and partial-order reasoning, bitemporal availability, leakage-safe cutoffs, TDT segmentation/link/detection/first-story/tracking tasks, and separate neural/symbolic event-schema and temporal-consistency layers. +Chambers, N., & Jurafsky, D. (2009). Unsupervised learning of narrative schemas and their participants. In *Proceedings of the Joint Conference of the 47th Annual Meeting of the ACL and the 4th International Joint Conference on Natural Language Processing of the AFNLP* (pp. 602–610). Association for Computational Linguistics. + +Doddington, G., Mitchell, A., Przybocki, M., Ramshaw, L., Strassel, S., & Weischedel, R. (2004). The Automatic Content Extraction (ACE) program—Tasks, data, and evaluation. In *Proceedings of the Fourth International Conference on Language Resources and Evaluation (LREC’04)* (pp. 837–840). European Language Resources Association. + +TEPP uses interval and partial-order reasoning, bitemporal availability, leakage-safe cutoffs, TDT segmentation/link/detection/first-story/tracking tasks (Allan, 2002), qualitative temporal reasoning (Anagnostopoulos et al., 2013), and separate neural/symbolic event-schema and narrative participant-slot layers (Chambers & Jurafsky, 2009). Under [ADR 0016](../adr/0016-tdt-chronos-event-intelligence-boundary.md), predicted schema-slot fills stay hypothetical until independently promoted; this is a TEPP policy boundary, not a literature result. +Brier, G. W. (1950). Verification of forecasts expressed in terms of probability. *Monthly Weather Review, 78*(1), 1–3. https://doi.org/10.1175/1520-0493(1950)078<0001:VOFEIT>2.0.CO;2 + +Gneiting, T., & Raftery, A. E. (2007). Strictly proper scoring rules, prediction, and estimation. *Journal of the American Statistical Association, 102*(477), 359–378. https://doi.org/10.1198/016214506000001437 + +TEPP uses interval and partial-order reasoning, bitemporal availability, leakage-safe cutoffs, TDT segmentation/link/detection/first-story/tracking tasks, and separate neural/symbolic event-schema and temporal-consistency layers. CHRONOS occurrence forecasts remain hypothetical and are scored with the Brier mean squared error against later-observed truth (Brier, 1950; Gneiting & Raftery, 2007). +Jensen, C. S., & Snodgrass, R. T. (1999). Temporal data management. *IEEE Transactions on Knowledge and Data Engineering, 11*(1), 36–44. https://doi.org/10.1109/69.755613 + +Snijders, T. A. B., & Bosker, R. J. (2012). *Multilevel analysis: An introduction to basic and advanced multilevel modeling* (2nd ed.). SAGE. + +Beretvas, S. N. (2011). Cross-classified and multiple-membership models. In J. J. Hox & J. K. Roberts (Eds.), *Handbook of advanced multilevel analysis* (pp. 313–334). Routledge. + +TEPP uses interval and partial-order reasoning, bitemporal availability, leakage-safe cutoffs, TDT segmentation/link/detection/first-story/tracking tasks, and separate neural/symbolic event-schema and temporal-consistency layers. Typed `entity_record` and `project_record` SQL keep multiple-membership targets referentially intact and refuse hostile labels before insert (Snijders & Bosker, 2012; Beretvas, 2011; Jensen & Snodgrass, 1999). +Jensen, C. S., & Snodgrass, R. T. (1999). Temporal data management. *IEEE Transactions on Knowledge and Data Engineering, 11*(1), 36–44. https://doi.org/10.1109/69.755613 + +Snodgrass, R. T. (2000). *Developing time-oriented database applications in SQL*. Morgan Kaufmann. + +Tashman, L. J. (2000). Out-of-sample tests of forecasting accuracy: An analysis and review. *International Journal of Forecasting, 16*(4), 437–450. https://doi.org/10.1016/S0169-2070(00)00065-0 + +TEPP uses interval and partial-order reasoning, bitemporal availability, leakage-safe cutoffs, TDT segmentation/link/detection/first-story/tracking tasks, and separate neural/symbolic event-schema and temporal-consistency layers. Valid time, transaction time, and a document's availability are not substitutes for `cutoff_clock` knowledge cutoff. + +Allen, J. F. (1983). Maintaining knowledge about temporal intervals. *Communications of the ACM, 26*(11), 832–843. https://doi.org/10.1145/182.358434. Interval relations inform `support_edge`; they do not make support, contradiction, summary, or `outcome_of` a state transition. +TEPP uses interval and partial-order reasoning, bitemporal availability, leakage-safe cutoffs, TDT segmentation/link/detection/first-story/tracking tasks, and separate neural/symbolic event-schema and temporal-consistency layers. Input→process→outcome transitions require a strict event-time partial order; `outcome_of` may point at an earlier producer and is not a reverse state transition (Allen, 1983). + ## Input-process-outcome order Allen, J. F. (1983). Maintaining knowledge about temporal intervals. *Communications of the ACM, 26*(11), 832–843. https://doi.org/10.1145/182.358434 +Allen (1983) classifies interval relations; it does **not** authorize treating a later outcome as an earlier input, nor treating `outcome_of` provenance as `input_to` or `process_to`. +Allen, J. F. (1983). Maintaining knowledge about temporal intervals. *Communications of the ACM, 26*(11), 832–843. https://doi.org/10.1145/182.358434 + +TEPP uses interval and partial-order reasoning, bitemporal availability, leakage-safe cutoffs, TDT segmentation/link/detection/first-story/tracking tasks, and separate neural/symbolic event-schema and temporal-consistency layers. Episode membership must stay `during` the episode interval; it cannot start before or end after that episode (Allen, 1983). Allen interval relations do not authorize treating a later outcome as an earlier input, or treating `outcome_of` provenance as `input_to` or `process_to`. The `outcome_order` contract therefore requires strict event-time rank for forward transitions while retaining provenance edges as non-transitions. Allen, J. F. (1983). Maintaining knowledge about temporal intervals. *Communications of the ACM, 26*(11), 832–843. https://doi.org/10.1145/182.358434. Interval relations inform `support_edge`; they do not make support, contradiction, summary, or `outcome_of` a state transition. @@ -168,6 +232,20 @@ TEPP separates stable record identity, content equality, exact text location, wi International Organization for Standardization and International Electrotechnical Commission. (2011). *Information technology—Security techniques—Privacy framework* (ISO/IEC Standard No. 29100:2011). Data minimization informs `provider_receipt`; it is not a certification claim. +Snodgrass, R. T. (2000). *Developing time-oriented database applications in SQL*. Morgan Kaufmann. Valid vs transaction time informs `available_clock`; availability is a third TEPP clock. + +Snodgrass, R. T. (2000). *Developing time-oriented database applications in SQL*. Morgan Kaufmann. Valid vs transaction time informs `assertion_clock`; assertion time is a distinct TEPP clock. + +Snodgrass, R. T. (2000). *Developing time-oriented database applications in SQL*. Morgan Kaufmann. Valid vs transaction time informs `event_clock`; event time is TEPP's valid-time clock. + +Snodgrass, R. T. (2000). *Developing time-oriented database applications in SQL*. Morgan Kaufmann. Transaction time informs `system_clock`; it is not event, assertion, document, available, or cutoff time. +TEPP separates stable record identity, content equality, exact text location, wire representation, authorization, and provenance. JSON wire records are explicit versioned DTOs with unknown-field rejection and reconstruct through domain validation. `SHA-256` detects content substitution but is not treated as proof of origin, authority, or chain of custody. Inferred relations are a PROV derivation, not an observation, and cannot be promoted to observed evidence or to a state transition without a separate validation gate (Moreau & Missier, 2013). +TEPP separates stable record identity, content equality, exact text location, wire representation, authorization, and provenance. JSON wire records are explicit versioned DTOs with unknown-field rejection and reconstruct through domain validation. `SHA-256` detects content substitution but is not treated as proof of origin, authority, or chain of custody. Documents, serialized records, checkpoints, and LLM outputs remain untrusted until identity, provenance, size, and nesting depth validate (Bray, 2017; Moreau & Missier, 2013). +TEPP separates stable record identity, content equality, exact text location, wire representation, authorization, and provenance. JSON wire records are explicit versioned DTOs with unknown-field rejection and reconstruct through domain validation. `SHA-256` detects content substitution but is not treated as proof of origin, authority, or chain of custody. A summary is a PROV derivation of the source document, not a state transition and not a reuse of the source identity (Moreau & Missier, 2013). + +International Organization for Standardization and International Electrotechnical Commission. (2011). *Information technology—Security techniques—Privacy framework* (ISO/IEC Standard No. 29100:2011). Data minimization informs `provider_receipt`; it is not a certification claim. +TEPP separates stable record identity, content equality, exact text location, wire representation, authorization, and provenance. JSON wire records are explicit versioned DTOs with unknown-field rejection and reconstruct through domain validation. `SHA-256` detects content substitution but is not treated as proof of origin, authority, or chain of custody. A template or pasted copy is a PROV derivation of the source document, not a reuse of the source identity and not a state transition (Moreau & Missier, 2013). + ## Privacy lifecycle, retention, and legal hold European Union. (2016). *Regulation (EU) 2016/679 of the European Parliament and of the Council of 27 April 2016 on the protection of natural persons with regard to the processing of personal data and on the free movement of such data (General Data Protection Regulation)*. Official Journal of the European Union, L 119, 1–88. https://eur-lex.europa.eu/eli/reg/2016/679/oj diff --git a/docs/research/tdt-story-segmentation.md b/docs/research/tdt-story-segmentation.md new file mode 100644 index 000000000..6e716a5c5 --- /dev/null +++ b/docs/research/tdt-story-segmentation.md @@ -0,0 +1,32 @@ +# TDT story-segmentation calibration + +## Scope + +This note doctors the `event_core` gate that keeps TDT story/event segmentation distinct from event-instance promotion and state-transition authority: + +1. an interior story cut is detection evidence, not a promoted instance or transition; +2. `WindowDiff` and `Pk` are computed from known-truth unit partitions; +3. boundary precision/recall and calibrated cut probabilities recover known-truth cuts with lower RMSE than an always-cut detector. + +No database migration is allocated. A later TDT linker or tracker may consume these scores as measurement evidence only. + +## Authoritative sources + +Allan, J. (Ed.). (2002). *Topic detection and tracking: Event-based information organization*. Kluwer Academic Publishers. + +Beeferman, D., Berger, A., & Lafferty, J. (1999). Statistical models for text segmentation. *Machine Learning, 34*(1–3), 177–210. https://doi.org/10.1023/A:1007506220214 + +Pevzner, L., & Hearst, M. A. (2002). A critique and improvement of an evaluation metric for text segmentation. *Computational Linguistics, 28*(1), 19–36. https://doi.org/10.1162/089120102317341756 + +## Application + +Allan (2002) treats story segmentation as a TDT measurement task over ordered units. Beeferman et al. (1999) score probe pairs at a fixed distance (`Pk`), and Pevzner and Hearst (2002) count mismatched window boundary totals (`WindowDiff`) so near-miss and over-segmentation errors remain visible. TEPP therefore refuses to cast a detected story partition as an event instance or a forward state transition and requires computed `WindowDiff`, `Pk`, precision, recall, and RMSE against known truth (Allan, 2002; Beeferman et al., 1999; Pevzner & Hearst, 2002). + +## Verification + +- `refuse_story_segmentation_as_instance` always returns `StorySegmentationIsNotEventInstance`; +- `refuse_story_segmentation_as_transition` always returns `StorySegmentationIsNotStateTransition`; +- `StorySegmentation::new` refuses fewer than two units and mismatched boundary lengths; +- `story_window_diff` and `story_pk` fail closed on empty, misaligned, or oversized windows; +- `story_boundary_precision` and `story_boundary_recall` fail closed on empty recovered or truth boundary sets; +- computed RMSE of known boundary targets is lower under calibrated probabilities than under an always-cut detector. diff --git a/docs/research/typed-membership-assignment-persistence.md b/docs/research/typed-membership-assignment-persistence.md index 7b4d8cd1d..bd652cd0d 100644 --- a/docs/research/typed-membership-assignment-persistence.md +++ b/docs/research/typed-membership-assignment-persistence.md @@ -49,6 +49,13 @@ exactly-one constraints preserve those distinct contexts. Migration membership for the same document, asserts those three rows persist, and rejects both dual-target and dual observed-unit rows when `TEPP_LIVE_POSTGRES=1`. +- segment-level membership now inserts the observed `text_segment` + through the typed adapter rather than ad-hoc SQL. See + `docs/research/text-segment-sql.md`. + `TEPP_LIVE_POSTGRES=1`; +- entity and project target rows are now inserted through fail-closed + SQL helpers rather than raw label interpolation (see + `docs/research/entity-project-sql.md`). - entity and project target rows are now inserted through fail-closed SQL helpers that validate labels before rendering INSERT SQL (see `docs/research/entity-project-sql.md`). diff --git a/docs/research/vram-budget-types.md b/docs/research/vram-budget-types.md new file mode 100644 index 000000000..af4e0cf6e --- /dev/null +++ b/docs/research/vram-budget-types.md @@ -0,0 +1,47 @@ +# VRAM budget types, executable OOM retries, and CPU `f64` reference + +## Scope + +This slice delivers the first executable ADR 0006 contract in `compute_backend`: + +1. classify devices into the accepted 4/6/8/12/24-GiB profiles; +2. reserve one eighth of profile capacity as unused safety memory; +3. predict peak bytes as `batch × bytes_per_observation + working_set`; +4. autotune the micro-batch by successive halving until the predicted peak fits usable VRAM; +5. after each observed OOM, emit a smaller executable GPU plan with an incremented retry count, then fall back to the CPU `f64` reference after the bounded retry budget or a failed unit batch; +6. refuse full-corpus document-by-topic device tensors and refuse dropping observations, shrinking topic/model complexity, or moving a knowledge cutoff to fit memory; +7. keep mixed precision out of final diagnostic quantities and compare CPU/candidate outputs with a normalized parity tolerance; +8. keep raw source text out of allocation telemetry; +9. use compensated deterministic summation for the sequential CPU `f64` numerical reference. + +Live CUDA/WGPU kernels, deterministic fixed-pool CPU multithreading, mixed-precision device lanes, and hardware CPU/GPU parity remain accepted-target. This slice does not claim an accelerator or a multithreaded production estimator. + +## Authoritative sources + +IEEE. (2019). *IEEE standard for floating-point arithmetic* (IEEE Std 754-2019). https://standards.ieee.org/ieee/754/6210/ + +Micikevicius, P., Narang, S., Alben, J., Diamos, G., Elsen, E., Garcia, D., Ginsburg, B., Houston, M., Kuchaiev, O., Venkatesh, G., & Wu, H. (2018). Mixed precision training. In *International Conference on Learning Representations*. https://openreview.net/forum?id=r1gs9JgRZ + +NVIDIA Corporation. (2024). *CUDA C++ programming guide*. https://docs.nvidia.com/cuda/cuda-c-programming-guide/ + +Ogita, T., Rump, S. M., & Oishi, S. (2005). Accurate sum and dot product. *SIAM Journal on Scientific Computing, 26*(6), 1955–1988. https://doi.org/10.1137/030601818 + +Rhu, M., Gimelshein, N., Clemons, J., Zulfiqar, A., & Keckler, S. W. (2016). vDNN: Virtualized deep neural networks for scalable, memory-efficient neural network design. In *2016 49th Annual IEEE/ACM International Symposium on Microarchitecture (MICRO)* (pp. 1–13). IEEE. https://doi.org/10.1109/MICRO.2016.7783721 + +## Formula notes + +- **Profile capacity** is \(p \times 2^{30}\) bytes for \(p \in \{4,6,8,12,24\}\). +- **Safety reserve** is \(p \times 2^{30} / 8\). Usable VRAM is \(\max(0, a - s)\) for available bytes \(a\) and reserve \(s\). +- **Peak** is \(b \cdot c + w\) for batch \(b\), per-observation charge \(c\), and working set \(w\). Overflow fails closed. +- **OOM retry** is stateful: retry count \(r\) increments after each observed OOM, batch is halved when \(r\leq r_{max}\), and the peak is recomputed from the original workload. No loop is counted as a retry unless an executable plan is returned to the caller. +- **CPU `f64` reference** uses deterministic compensated summation in IEEE 754 binary64 so cancellation-heavy low-order terms are not needlessly discarded (IEEE, 2019; Ogita et al., 2005). +- Streamed document/topic cardinalities are not multiplied into a hypothetical full-corpus allocation; the forbidden full-corpus policy is rejected by the controller. +- Mixed precision may be recorded as a transient mode only; final diagnostics remain binary64 (Micikevicius et al., 2018). + +## Verification + +- cancellation-heavy CPU `f64` weighted sums recover the low-order term and known totals with computed RMSE; +- 24-GiB profiles admit a larger autotuned micro-batch than 4-GiB profiles for the same workload; +- each accepted OOM retry returns a smaller GPU plan and an exact retry count before CPU fallback; +- streamed extreme cardinalities remain valid because no full tensor is sized; +- negative parity tolerances, full-corpus placement, observation drop, complexity reduction, cutoff mutation, mixed-final precision, and source-text telemetry fail closed. diff --git a/docs/validation/temporal-event-foundation.md b/docs/validation/temporal-event-foundation.md index dd3b18763..f3432e399 100644 --- a/docs/validation/temporal-event-foundation.md +++ b/docs/validation/temporal-event-foundation.md @@ -14,11 +14,20 @@ This report tracks exact-head scientific and engineering evidence required befor |---|---|---|---|---|---| | Immutable evidence + spans | `evidence_core` | implemented-main | — | unit + wire + coverage | Task 2 | | Six-clock temporal | `temporal_core` | implemented-main | — | unit + wire | Task 3 / PR #8 | +| Interval-aware cutoff eligibility | `temporal_core` | active-PR | this PR | unknown/open-ended fail-closed + computed latest-instant agreement | ADR 0002 | | Knowledge-cutoff identity | `cutoff_clock` | active-PR | this PR | recovered cutoff flags vs availability-time stand-in | ADR 0002 | | Six-clock temporal | `temporal_core` | implemented-main | `document_clocks` omitted assertion/document time | unit + wire | Task 3 / PR #8; document-row clocks on this PR | +| Knowledge-cutoff identity | `cutoff_clock` | active-PR | this PR | recovered cutoff flags vs availability-time stand-in | ADR 0002 | | Allen path-consistency | `temporal_core` | implemented-main | — | unit + budget tests | Task 4 / PR #9 | | Event mention/instance | `event_core` | partial | — | unit + fail-closed promotion | Task 5 / PR #13 | | Multiple membership | `membership_core` | partial | nested ICC + non-nested refusal | unit + ESS + nested ICC recovery | Task 7 / PR #12 + #25 + this increment | +| TDT tracking stability | `event_core` | active-PR | this PR | pair P/R + switch rate + RMSE vs always-one-track | ADR 0016; `docs/research/event-tracking-calibration.md` | +| CHRONOS schema-slot accuracy | `event_core` | active-PR | this PR | computed slot P/R + RMSE vs always-fill | ADR 0016; `docs/research/chronos-schema-slot-calibration.md` | +| TDT story segmentation | `event_core` | active-PR | this PR | computed `WindowDiff`/`Pk` + RMSE vs always-cut | ADR 0016; `docs/research/tdt-story-segmentation.md` | +| Event mention/instance | `event_core` | partial | CHRONOS prediction calibration | unit + fail-closed promotion | Task 5 / PR #13 | +| CHRONOS occurrence-prediction calibration | `event_core` | accepted-target | active PR | Brier vs later-observed truth; refuse prediction-as-instance | ADR 0016; `docs/research/chronos-prediction-calibration.md` | +| Multiple membership | `membership_core` | partial | — | unit + ESS weights | Task 7 / PR #12 + #25 | +| Episode membership containment | `episode_membership` | accepted-target | active PR | refuse membership outside episode + recovery vs accept-all | ADR 0003 | | Forward transition DAG | `relation_graph` | implemented-main | — | unit + cycle rejection | Task 6 / PR #14 | | Copy-versus-source identity | `copy_identity` | accepted-target | active PR | refuse copy-as-source/transition + recovery vs source collapse | ADR 0003 | | Summary-versus-source identity | `summarizes_edge` | accepted-target | active PR | refuse summary-as-transition/source + recovery vs source collapse | ADR 0003 | @@ -26,6 +35,17 @@ This report tracks exact-head scientific and engineering evidence required befor | Retrospective reporting identity | `retrospective_edge` | accepted-target | active PR | refuse retrospective-as-transition/translation + recovery vs forward collapse | ADR 0002/0003 | | Inferred-versus-observed promotion | `inferred_status` | accepted-target | active PR | refuse inferred-as-observed/transition + recovery vs observed collapse | ADR 0003 | | Evidential-vs-transition gate | `support_edge` | active-PR | this PR | recovered kind rate vs support collapse | ADR 0002/0003 | +| Bitemporal persistence + live SQL port | `persistence_postgres` | partial | backup/restore integrity | migration contracts + recording transport + optional PgPool + live CI + tenant RLS + `0005`/`0006` + event relation/mention/instance + source-artifact + audit-event + concurrent-write (#37–#43 implemented-main) + restore integrity probes (active PR) | Task 8 / PR #16 + #23 + #26 + #27 + #29 + #30–#43 + restore integrity | +| Bitemporal persistence + live SQL port | `persistence_postgres` | partial | backup/restore integrity | migration contracts + recording transport + optional PgPool + live CI + tenant RLS + `0005`/`0006` + event relation/mention/instance + source-artifact + audit-event + concurrent-write (#37–#43 implemented-main) + restore integrity probes (#44 implemented-main) | Task 8 / PR #16 + #23 + #26 + #27 + #29 + #30–#44 + restore integrity | +| Bitemporal persistence + live SQL port | `persistence_postgres` | partial | typed `text_segment` SQL | migration contracts + recording transport + optional PgPool + live CI + tenant RLS + `0005`/`0006` + event relation/mention/instance + source-artifact + audit-event + concurrent-write + restore integrity (#37–#44 implemented-main) + typed `text_segment` insert/cutoff lookup (active PR) | Task 8 / PR #16 + #23 + #26 + #27 + #29 + #30–#44 + text-segment SQL | +| Bitemporal persistence + live SQL port | `persistence_postgres` | partial | — | migration contracts + recording transport + optional PgPool + live CI + tenant RLS + `0005`/`0006` interval and membership contracts + event relation/mention/instance + source-artifact + audit-event + concurrent-write + restore-integrity contracts implemented-main | Task 8 / PR #16 + #23 + #26 + #27 + #29 + #30–#44 | +| Bitemporal persistence + live SQL port | `persistence_postgres` | partial | entity/project target SQL | migration contracts + recording transport + optional PgPool + live CI + tenant RLS + `0005`/`0006` + event relation/mention/instance + source-artifact + audit-event + concurrent-write + restore integrity (#37–#44 implemented-main) + entity/project SQL (this PR) | Task 8 / PR #16 + #23 + #26 + #27 + #29 + #30–#44 + entity/project SQL | +| Evidential-vs-transition gate | `support_edge` | active-PR | this PR | recovered kind rate vs support collapse | ADR 0002/0003 | +| Inferred-versus-observed promotion | `inferred_status` | accepted-target | active PR | refuse inferred-as-observed/transition + recovery vs observed collapse | ADR 0003 | +| Input-process-outcome event-time order | `outcome_order` | accepted-target | active PR | refuse reverse/uncertain IPO order + outcome_of-is-not-transition + recovery vs input collapse | ADR 0002/0003 | +| Summary-versus-source identity | `summarizes_edge` | accepted-target | active PR | refuse summary-as-transition/source + recovery vs source collapse | ADR 0003 | +| Copy-versus-source identity | `copy_identity` | accepted-target | active PR | refuse copy-as-source/transition + recovery vs source collapse | ADR 0003 | +| Bitemporal persistence + live SQL port | `persistence_postgres` | partial | backup/restore integrity | migration contracts + recording transport + optional PgPool + live CI + tenant RLS + `0005`/`0006` + event relation/mention/instance + source-artifact + audit-event + concurrent-write (#37–#43 implemented-main) + restore integrity probes (active PR) | Task 8 / PR #16 + #23 + #26 + #27 + #29 + #30–#43 + restore integrity | | Typed membership targets beyond entity/project | `membership_target` | active-PR | PR #131 | refuse collapse + recovery vs entity stand-in for language, episode, template, department, and opportunity pool | ADR 0003 | | Bitemporal persistence + live SQL port | `persistence_postgres` | partial | entity/project target SQL on PR #131 | migration contracts + recording transport + session-affine optional PgPool + live CI + tenant RLS + `0005`/`0006` interval/membership contracts + event relation/mention/instance + source-artifact + audit-event + concurrent-write + restore integrity + typed `text_segment` SQL (#37–#44 implemented-main) | Task 8 / PR #16 + #23 + #26 + #27 + #29 + #30–#44 + entity/project SQL | | Leakage-safe splits | `corpus_split` | implemented-main | — | cutoff + co-partition tests | Task 9 / PR #17 | @@ -34,6 +54,8 @@ This report tracks exact-head scientific and engineering evidence required befor | Recovery metrics | `validation_core` | implemented-main | — | RMSE/bias/coverage/MC gates | Task 11 / PR #19 | | Mention-confidence Brier score | `event_core` | active-PR | calibration vs binary truth | perfect 0 / half 0.25 RMSE | ADR 0003; `docs/research/mention-confidence-brier.md` | | Checkpoint is not the estimator | `checkpoint_authority` | accepted-target | active PR | refuse checkpoint-as-estimator + unvalidated artifact + recovery vs estimator collapse | ADR 0001/0014 | +| Scientific claim promotion gates | `validation_core` | active-PR | this PR | exact-head SHA + computed RMSE SE gate | ADR 0014; full release bundle remaining | +| Causal-identification gate | `relation_graph` | active-PR | association ≠ cause | LeadsTo/References denied | ADR 0003; `docs/research/causal-identification-gate.md` | | Versioned API/export contracts | `tepp_api` | implemented-main | naruon HTTP interchange | unknown-field/version/limit + naruon HTTPS interchange tests | Task 12 / PR #21; live HTTP service remaining | | Prompt-versus-unique-content identity | `prompt_source` | accepted-target | active PR | refuse prompt-as-unique/stopword + recovery vs unique-content collapse | ADR 0004/0012 | | Corpus-background-versus-unique-content identity | `corpus_background` | accepted-target | active PR | refuse background-as-unique/stopword + recovery vs unique-content collapse | ADR 0004/0012 | @@ -59,6 +81,15 @@ This report tracks exact-head scientific and engineering evidence required befor | Global topic activity identity | `topic_lineage` | active-PR | this PR | dormancy/reactivation identity recovery | ADR 0012; birth/split/merge remaining | | Compositional cluster-pair gates | `network_analysis` | active-PR | this PR | raw-simplex refusal + known-truth pair precision/recall + cluster-label permutation invariance | ADR 0005/0012; `crates/network_analysis/tests/compositional_cluster_contract.rs`; graphical model remaining | | Candidate-K statistical/Pareto gates | `model_selection` | active-PR | this PR | known-K RMSE + LLM-vote refusal | ADR 0012; estimator/backend remaining | +| Assertion-clock identity | `assertion_clock` | active-PR | this PR | recovered assertion flags vs event-time stand-in | ADR 0002 | +| Event-clock identity | `event_clock` | active-PR | this PR | recovered event flags vs assertion-time stand-in | ADR 0002 | +| System-clock identity | `system_clock` | active-PR | this PR | recovered system flags vs event-time stand-in | ADR 0002 | +| Untrusted payload identity/provenance/size/depth | `payload_bound` | accepted-target | active PR | refuse missing identity/provenance and oversize/over-deep payloads + recovery vs accept-all | AGENTS.md untrusted-boundary | +| Untrusted intake grant presence | `intake_authorization` | accepted-target | active PR | refuse missing grant + refuse bounds-as-authorization + recovery vs accept-all | ADR 0009; AGENTS.md | +| Style-versus-unique-content identity | `style_source` | accepted-target | active PR | refuse style-as-unique/stopword + recovery vs unique-content collapse | ADR 0004/0012 | +| Modality-versus-unique-content identity | `modality_source` | accepted-target | active PR | refuse modality-as-unique/stopword + recovery vs unique-content collapse | ADR 0004/0012 | +| Corpus-background-versus-unique-content identity | `corpus_background` | accepted-target | active PR | refuse background-as-unique/stopword + recovery vs unique-content collapse | ADR 0004/0012 | +| Prompt-versus-unique-content identity | `prompt_source` | accepted-target | active PR | refuse prompt-as-unique/stopword + recovery vs unique-content collapse | ADR 0004/0012 | | Purpose-bound provider payloads | `tepp_api` | implemented-main | provider-payload minimization | expired/not-yet-valid/inverted/cross-tenant/impossible-calendar grant, mapping refusal, audited elevated re-id replay | ADR 0009; `docs/research/provider-payload-minimization.md` | | Adaptive orchestration router | `tepp_api` | implemented-main | live execution/production ablation | mode selection, document-control denial, ablation, credential-free bind | ADR 0010; `docs/research/adaptive-orchestration-router.md` | | Operational log/source separation | `operational_log` + `persistence_postgres` | active-PR | this PR | replayed action recovery vs collapsed action; inspected `audit_event` insert refuses source text, source identity, and blanket-mask grants | ADR 0009 | diff --git a/scripts/check_workspace_contract.py b/scripts/check_workspace_contract.py index 863c80362..5a3e3fef0 100644 --- a/scripts/check_workspace_contract.py +++ b/scripts/check_workspace_contract.py @@ -61,6 +61,8 @@ "interpretation_gateway", "model_selection", "checkpoint_authority", + "compute_backend", + "episode_membership", "membership_target", ) diff --git a/scripts/validate_documentation.py b/scripts/validate_documentation.py index 3050b1f4a..968f0aaff 100644 --- a/scripts/validate_documentation.py +++ b/scripts/validate_documentation.py @@ -44,7 +44,7 @@ "docs/adr/0017-hourly-contextual-orchestrator-gateway.md", "docs/adr/0018-consumer-scoped-analysis-run-ingress.md", "docs/adr/0019-project-history-wire-size-symmetry.md", - "docs/adr/0020-lineageweave-project-history-boundary.md", + "docs/adr/0021-lineageweave-project-history-boundary.md", "docs/product/prd-v0.4-approved.md", PRODUCT_TECHNICAL_GAP_BASELINE, "docs/roadmaps/2026-08-05-tepp-delivery-roadmap.md", diff --git a/tests/quality/test_check_coverage.py b/tests/quality/test_check_coverage.py index 36cb2359f..caabc0013 100644 --- a/tests/quality/test_check_coverage.py +++ b/tests/quality/test_check_coverage.py @@ -626,5 +626,46 @@ def test_multiline_scanner_ignores_comments_char_literals_and_raw_strings(self) self.assertFalse(coverage_contract.is_executable_source_line(path, 7)) + + + def test_multiline_string_empty_lines_returns_false(self) -> None: + """An empty source produces no multiline-string continuations.""" + + self.assertFalse( + coverage_contract._line_in_multiline_string([], 1) + ) + + def test_structural_comma_continuation_edge_cases(self) -> None: + """Exercise structural comma continuation detection edge branches.""" + + with tempfile.TemporaryDirectory() as temporary: + source = Path(temporary) / "commas.rs" + # Lines 211->215 and 212->211: loop skips blank lines and non-matching + source.write_text( + "fn example() {\n" + " let value = foo(\n" + "\n" + " 1,\n" + " );\n" + "}\n", + encoding="utf-8", + ) + self.assertTrue( + coverage_contract.is_executable_source_line(str(source), 2) + ) + + # Line 318->311: while loop with backslash at end of line inside string + source.write_text( + 'fn path() {\n' + ' let s = "a\\\n' + 'b";\n' + "}\n", + encoding="utf-8", + ) + self.assertTrue( + coverage_contract.is_executable_source_line(str(source), 2) + ) + + if __name__ == "__main__": # pragma: no cover unittest.main() diff --git a/tests/quality/test_check_docstrings.py b/tests/quality/test_check_docstrings.py index 1feb3d026..b990da9ae 100644 --- a/tests/quality/test_check_docstrings.py +++ b/tests/quality/test_check_docstrings.py @@ -40,6 +40,8 @@ def test_live_repository_is_documented(self) -> None: for path in contract.expected_member_paths() } self.assertEqual(set(crate_roots), expected_crate_roots) + self.assertEqual(len(crate_roots), len(contract.EXPECTED_CRATES)) + self.assertEqual(len(crate_roots), len(contract.EXPECTED_CRATES)) self.assertTrue(set(crate_roots).issubset(sources)) self.assertGreaterEqual(len(sources), len(crate_roots)) self.assertEqual(docstrings.validate_repository(REPOSITORY_ROOT), [])