From 5e01e409234b5fab242ff9937a76efbb7020c255 Mon Sep 17 00:00:00 2001 From: Julio Rodriguez <144072916+Litju@users.noreply.github.com> Date: Fri, 18 Sep 2026 18:43:00 +0000 Subject: [PATCH 01/18] docs(res70): freeze scientific authority design --- ...-comparability-analysis-claim-authority.md | 1126 +++++++++++++++++ 1 file changed, 1126 insertions(+) create mode 100644 docs/decisions/RES70-DR-001-cross-source-comparability-analysis-claim-authority.md diff --git a/docs/decisions/RES70-DR-001-cross-source-comparability-analysis-claim-authority.md b/docs/decisions/RES70-DR-001-cross-source-comparability-analysis-claim-authority.md new file mode 100644 index 0000000..28d6909 --- /dev/null +++ b/docs/decisions/RES70-DR-001-cross-source-comparability-analysis-claim-authority.md @@ -0,0 +1,1126 @@ +# RES-70-DR-001 — Cross-source comparability, bridge authority, analysis capability, and claim authority + +## Status + +`FROZEN / DESIGN ONLY` + +`MISSION=RES-70-SCIENTIFIC-DESIGN-001` + +`BASE_MAIN=83041cd3e69e0dc0947c181b19effd5402bd5a38` + +`CONSTITUTION_VERSION=2.0.0` + +`SERIALIZATION_VERSION=3` — unchanged + +`PRODUCTION_IMPLEMENTATION_AUTHORIZED=NO` + +This record freezes the scientific and software design for RES-70. It does +not implement production code, numerical operations, bridges, tests, model +runtime behavior, or LM tool calling. A future implementation mission must +realize this record without weakening the sealed RES-34→69 contracts. + +## Decision summary + +RES-70 uses four deterministic authority layers: + +```text +typed request + ↓ +exact identity/context/provenance revalidation + ↓ +pairwise cross-source comparability and optional registered bridge execution + ↓ +registered analysis-capability prerequisite validation + ↓ +registered deterministic analysis result + ↓ +two-axis claim-authority validation and evidence applicability + ↓ +structured authorization or claim-specific refusal +``` + +The design freezes the following: + +| Contract | Status | Frozen decision | +| --- | --- | --- | +| Cross-source comparability | FROZEN | Reuse the existing six states; add an additive cross-source wrapper, material-dimension findings, pairwise-only evaluation, and no transitive closure. | +| Bridge/transformation authority | FROZEN | A registered bridge declares an operation and applicability; numerical transformations must execute and emit a new provenance-bound result before they can support comparison. | +| Analysis capability registry | FROZEN | The caller proposes an analysis class; Python resolves the canonical capability entry and either authorizes it or returns a refusal. | +| Claim authority | FROZEN | Use two orthogonal ordered axes: measurement/change evidence and relationship/causal inference. Prediction remains separate. | +| Evidence applicability | FROZEN | Method validity, source quality, population relevance, contextual relevance, and statistical adequacy remain separate typed dimensions; no confidence score. | +| Level of analysis | FROZEN | Every analysis carries an explicit unit/cluster/estimand identity; pooled observations never silently become independent subjects. | +| Refusal taxonomy | FROZEN | Reuse generic `RefusalResult` and add only RES-70-specific granular reason codes. | +| LM boundary | FROZEN | The LM may propose and request; deterministic Python owns registration, prerequisites, transformations, calculations, and claim promotion/refusal. | + +## Authority provenance + +Every rule in this record is classified so repository facts, external +methodological support, and RES-70 design choices are not conflated. + +### Repository facts + +The current merged repository establishes: + +- `ScientificMeasurementObservation` is + `ObservationContext + MeasurementIdentity + MeasurementResult + Provenance`. +- `MeasurementIdentity` contains semantic, acquisition, processing, and version + identity; the result value is not part of identity. +- `ComparabilityState` already contains + `COMPARABLE`, `COMPARABLE_WITH_CONDITIONS`, `REQUIRES_TRANSFORMATION`, + `BRIDGE_VALIDATION_REQUIRED`, `NOT_COMPARABLE`, and + `INSUFFICIENT_INFORMATION`. +- `ComparabilityRequest`/`ComparabilityResult` are claim-relative, immutable, + V3-serializable contracts. `TransformationRequest` is explicitly a request, + not a verdict. +- Family-specific comparators already return the generic result envelope: + CMJ acquisition/derived/event/phase/mechanics/metric/session/jump-height, + external load, field testing, VBT, Drop Jump, Bench Press Throw, and + Medicine-Ball Throw each retain family-owned material identity rules. +- RES-62 owns exact longitudinal entries, immutable analysis inputs, explicit + temporal scope, source qualification bindings, missingness, and additive + provenance graphs. +- RES-69 owns deterministic statistical support, scale authority, + reliability-design authority, method-comparison authority, operation + dispositions, result envelopes, and claim-specific refusal construction. +- RES-60 owns canonical population/source decisions and explicit V2 evidence + applicability. RES-61 owns football-world context and match/training/testing + placement. Neither is replaced by RES-70. +- Serialization V3, canonical hashes, immutable tuples, registry references, + and append-only reprocessing are existing authority. `serialization.py` is + not a RES-70 implementation target. + +### Literature-supported methodological rules + +The evidence table at the end records the targeted sources. Their bounded +consequences are: + +- correlation describes association, not agreement or interchangeability; +- cross-group/time comparison requires evidence that the measurement has the + same meaning under the intended comparison, not merely a common label; +- repeated observations have an experimental/observational unit and dependence + structure that must be represented in the analysis; +- within-person and between-person effects are distinct estimands; +- random measurement error, systematic trial effects, and retest association + are different reliability quantities; +- statistical significance does not itself establish effect magnitude or + practical importance; +- causal and transportability claims require an explicit target, design, + estimand, assumptions, and evidence-applicability argument. + +### RES-70 design choices + +RES-70 operationalizes those rules conservatively for this repository: + +- comparability is pairwise and claim-relative; +- registry keys are semantic and never observation-instance keys; +- bridge declarations cannot alter values or provenance; +- only an executed registered transformation can supply transformed numerical + support; +- evidence applicability is a vector of typed judgments, not a scalar score; +- claim authority is a product of two ordered axes, not one universal ladder; +- no deferred RES-69 method is made executable by registering a prerequisite + schema; +- an unsupported stronger claim produces a refusal while preserving safe lower + descriptions. + +## Current repository reconnaissance and reuse boundary + +| Concern | Existing authority | RES-70 treatment | +| --- | --- | --- | +| Generic comparability | `src/dynamislm/comparability/models.py`, `authority.py` | Reuse states, request/result invariants, deterministic rule dispatch, and transformation-request semantics. Additive wrappers only; no historical hash/wire mutation. | +| Family comparability | `src/dynamislm/measurement/**/comparability.py`, `src/dynamislm/external_load/comparability.py` | Consume family results as leaf authority. Do not duplicate CMJ, external-load, field-test, strength, DJ, BPT, or MBT rules in RES-70. | +| Longitudinal support | `src/dynamislm/longitudinal/models.py`, `record.py`, `lineage.py`, `statistics/support.py` | Reuse exact entry/input/support hashes, explicit exclusions, no-imputation policy, and complete source graphs. | +| RES-69 statistical authority | `src/dynamislm/longitudinal/statistics/models.py`, `registry.py`, `validation.py` | Reuse `StatisticalSupport`, `StatisticalComparabilityEvidence`, `StatisticalAnalysisRun`, `StatisticalResult`, scale/reliability/method-comparison authority, and operation disposition. Add an upper capability gate; do not implement deferred methods. | +| Measurement identity | `src/dynamislm/measurement/identity.py`, `observation.py`, `result.py`, `taxonomy.py` | Treat labels and values as non-authoritative shortcuts. Use full typed identity and independent value-origin/scientific-role axes. | +| Population/source qualification | `src/dynamislm/population/`, `src/dynamislm/evidence/` | Reuse canonical decisions and V2 applicability. Add claim-relative aggregation of applicability axes without replacing RES-60 decisions. | +| Football context | `src/dynamislm/football/` | Reuse typed match/training/testing context and exposure. Context may be claim-relevant without becoming measurement identity. | +| Refusal | `src/dynamislm/refusal/models.py`, RES-69 validation | Reuse `RefusalResult`, high-level classes, observation preservation, and safe-description behavior. Add only RES-70-local granular codes. | +| Serialization/public exports | `src/dynamislm/serialization.py`, package `__init__.py` files | Register new types with V3 and export them additively. Do not change `SERIALIZATION_VERSION` or existing hashes. | +| Repository policy | `scripts/repository_policy.py`, `scripts/ci.sh`, `tests/test_repository_policy.py` | Future implementation must preserve synthetic-fixture-only policy and QA mutation checks. This mission adds documentation only. | + +The Codebase Memory architecture and call-path review identified +`ComparabilityAuthority.adjudicate`, RES-69 +`validate_comparable_entries`, RES-69 `build_analysis_run`, and the RES-62 +provenance builders as the main downstream seams. RES-70 therefore composes +above them rather than introducing a parallel observation, support, or +provenance model. + +## 1. Cross-source comparability + +### 1.1 Material dimensions + +The RES-70 dimension vocabulary is typed. A caller may request attention to a +dimension, but cannot remove a dimension that the registered rule marks as +material for the claim. + +```text +CONSTRUCT +TEST_FAMILY +MEASURAND +METRIC_DEFINITION +PROTOCOL +EVENT_DEFINITION +PHASE_DEFINITION +UNIT +NORMALIZATION +ESTIMATOR +REGISTERED_PROCESSING_OPERATION +PROCESSING_PARAMETERS +FILTERING_SMOOTHING_RESAMPLING +SAMPLING_AND_TIMEBASE +CALIBRATION_REFERENCE +DEVICE_MEASURING_SYSTEM +PROVIDER +SOFTWARE_ALGORITHM_VERSION +HARDWARE_FIRMWARE_VERSION +SIGN_CONVENTION_AND_REFERENCE_FRAME +THRESHOLD_IDENTITY +TRIAL_SELECTION_POLICY +AGGREGATION_POLICY +SESSION_SEGMENTATION +ACQUISITION_CONTEXT +EXPOSURE_CONTEXT_MATCH_OR_TRAINING +VALUE_ORIGIN +UNCERTAINTY_ERROR_MODEL +POPULATION_APPLICABILITY +EVIDENCE_APPLICABILITY +``` + +`FOOTBALL_WORLD_CONTEXT` is not copied into `MeasurementIdentity`. It is +evaluated as a separate claim-relevant context dimension. For example, a +match-versus-training comparison may be scientifically blocked for a match +claim even when both observations have identical measurement identities. + +The following are hard rules: + +```text +same label != same identity +same unit != same measurand +same number != same observation +unit conversion != method harmonization +valid in isolation != interchangeable +high correlation != agreement +same athlete != same context +same context != same method +``` + +Display labels, aliases, provider column names, and caller descriptions may be +diagnostic metadata only. They cannot satisfy an identity comparison. + +### 1.2 Typed request and decision + +The future additive cross-source contract is: + +```text +ObservationAuthorityReference { + observation_id + observation_hash + identity_hash + result_hash + context_hash + provenance_hash +} + +CrossSourceComparabilityRequest { + request_id + left_observation: ObservationAuthorityReference + right_observation: ObservationAuthorityReference + claim_intent: registered claim/analysis intent reference + requested_transformations: TransformationRequest[] + requested_dimensions: ComparabilityDimension[] # advisory only + claim_context: typed match/training/population context +} + +CrossSourceComparabilityDecision { + decision_id + request_hash + state: ComparabilityState + dimension_findings[] + conditions[] + transformations_required[] + bridge_application_reference: optional + rule_reference + evidence_references[] + registry_version + registry_hash + observation/provenance references +} +``` + +`ObservationAuthorityReference` is an exact input/provenance reference; it is +not a semantic registry key. The validator must resolve the referenced +observations and recompute every supplied hash before deciding. + +`dimension_findings` retain, for each material dimension, one of +`MATCH`, `MISMATCH`, `UNKNOWN`, `BRIDGED`, or `NOT_APPLICABLE`, together with +the stable semantic IDs/hashes and the rule-derived reason code. A human label +is never the finding value. + +The existing `ComparabilityRequest` and `ComparabilityResult` remain valid +leaf contracts. The RES-70 decision composes them or adapts a family result; +it does not change their serialized shape. + +### 1.3 State semantics + +| State | Meaning in RES-70 | Numerical support allowed? | +| --- | --- | --- | +| `COMPARABLE` | All claim-relevant identity/context dimensions are resolved and directly compatible under one registered deterministic rule. | Yes, subject to the separate analysis/claim prerequisites. | +| `COMPARABLE_WITH_CONDITIONS` | A registered bridge or conditional rule establishes compatibility for this named claim, and all conditions are explicit and satisfied. | Yes, with the conditions and bridge evidence bound to the support. | +| `REQUIRES_TRANSFORMATION` | A registered deterministic transformation is named or required, but it has not yet been executed and bound to output provenance. | No transformed support; return the request/result state only. | +| `BRIDGE_VALIDATION_REQUIRED` | A material identity/method/device/context difference exists and no applicable registered bridge has established claim-relative compatibility. | No cross-source comparison claim. Independent observations remain describable. | +| `NOT_COMPARABLE` | The construct, measurand, metric meaning, protocol, or other irreconcilable claim dimension is incompatible. | No comparison of the requested claim. | +| `INSUFFICIENT_INFORMATION` | Required metadata, provenance, evidence applicability, or registry authority is absent or conflicting. | No claim; request missing information or a registered rule. | + +Direct comparability means `COMPARABLE`. A bridge-mediated comparison is not +silently relabelled as direct comparability; it is at least +`COMPARABLE_WITH_CONDITIONS`, with the bridge and its applicability conditions +in the decision. A bridge can be valid for one claim and invalid for another. + +### 1.4 Pairwise-only, non-transitive evaluation + +RES-70 V1 does not infer a relation by graph closure: + +```text +A ~ B and B ~ C does not imply A ~ C +``` + +For an analysis involving more than two observations, Python evaluates every +pair required by the registered analysis capability or consumes an exact +multi-observation rule. A `CrossSourceComparabilityMatrix` may store pairwise +decisions, but it has no transitive `reachable => comparable` operation. + +Registry/bridge semantic keys exclude observation-instance IDs, athlete IDs, +timestamps, session IDs, source-artifact IDs, acquisition-instance IDs, +processing-run IDs, and result-instance IDs. Exact decisions and provenance +must include those instance references. This separation prevents both +observation-instance leakage into reusable authority and loss of exact source +lineage. + +### 1.5 Family adapter boundary + +Family-specific comparators remain the first authority for family-owned +dimensions. A RES-70 adapter may: + +1. construct the existing family request from exact typed observations; +2. invoke the registered family rule; +3. preserve its result, reasons, conditions, evidence, and rule reference; and +4. add claim-level context/population/evidence findings that the family rule + does not own. + +It may not reinterpret a family `BRIDGE_VALIDATION_REQUIRED` as comparable, +merge family metric labels, or invent a bridge. The existing generic +`ComparabilityAuthority` remains the no-rule/one-rule deterministic dispatch +primitive. + +## 2. Bridge and transformation authority + +### 2.1 Registered bridge contract + +The future immutable `BridgeRegistration` contains: + +```text +BridgeRegistration { + bridge_reference: RegistryReference(object_type="comparability-bridge") + source_semantic_key: complete identity key, no observation instances + target_semantic_key: complete identity key, no observation instances + claim_scope: exact claim/analysis intent references + bridge_mode: DECLARATIVE_EQUIVALENCE | NUMERICAL_TRANSFORMATION + transformation_operation: registered deterministic operation reference + source_units: exact UnitReference tuple + target_units: exact UnitReference tuple + fixed_parameters: canonical MetadataEntry tuple + domain_constraints: registered typed range/boundary contract + applicability_conditions: registered typed context/evidence conditions + method_version: registry/method reference and version + evidence_references: exact EvidenceReference tuple + evidence_applicability: claim-relative applicability references + uncertainty_model: registered propagation/limitation reference + invertibility: EXACT | APPROXIMATE | NON_INVERTIBLE_LOSSY + lossiness_description: required for non-exact modes + provenance_rule: registered lineage/provenance method reference + registry_version + bridge_hash +} +``` + +The bridge hash is derived from all material fields. Duplicate source/target +keys, duplicate stable references, conflicting versions, absent evidence, or +an operation that is not registered as deterministic fail registry integrity. +The canonical production bridge registry is the only production authority; +caller-provided registries and synthetic test bridges cannot authorize public +claims. + +### 2.2 Declaration is not execution + +The following invariant is explicit: + +```text +DECLARED_COMPATIBILITY != EXECUTED_TRANSFORMATION +``` + +`TransformationRequest` remains only a request. It cannot change a value, +unit, identity, comparability state, or provenance. `BridgeRegistration` +declares what a registered operation is allowed to do; it does not execute it. + +For `NUMERICAL_TRANSFORMATION`, the only claim-eligible path is: + +```text +BridgeRequest + → resolve canonical BridgeRegistration + → validate source semantic key, domain, units, parameters, and conditions + → execute the registered deterministic operation + → create a new derived observation/result + → create a new ProcessingRun and explicit lineage edges + → bind uncertainty/lossiness output + → re-run cross-source comparability on the transformed observation +``` + +The source observation is immutable. The transformed value receives a new +observation/result identity and `DYNAMISLM_DERIVED`/appropriate derived origin +classification. The output identity binds source observation hash, bridge +reference/hash, operation/version, parameters, software version, target +identity, and uncertainty policy. Re-running the same source with changed +bridge version or parameters produces a distinct output; it never overwrites +the previous one. + +For `DECLARATIVE_EQUIVALENCE`, no numeric value is changed. The bridge may +support `COMPARABLE_WITH_CONDITIONS` only when its evidence and applicability +conditions are resolved for the named claim. It cannot be presented as an +executed transformation or as direct equality. + +No caller field named `bridge_valid`, `force_comparable`, `conversion_applied`, +`transformed`, `agreement_ok`, or equivalent is accepted as authority. The +validator recomputes execution status and all hashes. + +### 2.3 Uncertainty and lossiness + +A numerical bridge must either execute a registered uncertainty propagation +method or explicitly emit a registered limited/unknown uncertainty status. +Unknown or material unpropagated uncertainty blocks every claim requiring +measurement-error comparison or practical meaning. A lossy bridge must state +the loss and its claim conditions; it cannot be used as a reversible identity +alias. A unit-only conversion remains a method operation, not proof of method +equivalence. + +The existing RES-64 unit operations may be reused as bridge operations only +through this execution boundary when transformed values are used in a later +analysis. Existing family comparison behavior is not rewritten in this +design. + +## 3. Analysis capability and prerequisite validation + +### 3.1 Analysis classes + +The RES-70 capability registry covers at least: + +```text +SCALAR_ABSOLUTE_CHANGE +SCALAR_RELATIVE_CHANGE +SCALAR_LOG_CHANGE +BASELINE_REFERENCE_WINDOW_DEVIATION +RELIABILITY_RANDOM_ERROR_COMPARISON +METHOD_AGREEMENT_SUMMARY +WITHIN_ATHLETE_ASSOCIATION +BETWEEN_ATHLETE_ASSOCIATION +REPEATED_MEASURES_ANALYSIS +MIXED_EFFECTS_ANALYSIS +CROSS_TEST_ASSOCIATION +``` + +`METHOD_AGREEMENT_SUMMARY` is deliberately named as a summary operation. A +summary of B-minus-A bias and SD is not an automatic interchangeability or +agreement-acceptability claim. + +### 3.2 Request, registry, and authorization contracts + +```text +AnalysisLevelIdentity { + unit_of_analysis: TRIAL | TEST_INSTANCE | SESSION | ATHLETE | GROUP + estimand_level: WITHIN_ATHLETE | BETWEEN_ATHLETE | JOINT_MULTILEVEL | GROUP + subject_key: typed key + clustering_keys: typed key tuple + grouping_keys: typed key tuple + temporal_order_policy: registered reference + pooling_policy: registered reference +} + +AnalysisCapability { + capability_reference + analysis_class + registered_operation_reference: optional RES-69/future operation + estimator_reference: optional + disposition: IMPLEMENTED | REPRESENT_ONLY | DEFERRED | REJECTED + required_support_shape + required_identity_dimensions + required_comparability_states + required_bridge_execution + required_level_of_analysis + required_statistical_authority + required_evidence_axes + required_context + output_claim_floor + registry_version + capability_hash +} + +AnalysisAuthorizationRequest { + request_id + analysis_class + exact StatisticalSupport or exact support snapshot reference + exact identities/hashes and comparability decisions + requested AnalysisLevelIdentity + evidence applicability bundle + football/context constraints + requested parameters (only registered parameter names/types) +} + +AnalysisAuthorization { + authorization_id + status: AUTHORIZED | REFUSED + capability_reference/hash + operation/estimator references + support/input/identity/comparability hashes + resolved level-of-analysis identity + registry/software versions + reason_codes and missing_information + safe lower-level descriptions + authorization_hash +} +``` + +An authorization is a derived record. Public validation recomputes it from +the canonical registry and exact input hashes; a caller cannot construct an +authoritative `AUTHORIZED` value by setting a boolean or copying a registry +reference. + +The capability registry is above, not a replacement for, the RES-69 operation +registry. An operation may be registered as implemented and still be refused +because support, scale, reliability, level, comparability, or applicability +authority is absent. + +### 3.3 Prerequisite matrix + +| Analysis class | Structural prerequisites | Existing V1 production disposition | +| --- | --- | --- | +| Scalar absolute change | Exactly two valid scalar entries; explicit baseline/follow-up temporal order; exact target identity and unit; required pairwise comparability evidence for a comparable-change claim; no hidden conversion. | RES-69 arithmetic operation exists. RES-70 may authorize only after the exact support/comparability gates pass. | +| Scalar relative change | Absolute-change prerequisites; canonical production scale semantics; nonzero denominator; registered ratio-scale policy. | Operation exists, but the production scale registry has zero keys, so public production authorization refuses until an owning scale authority is registered. | +| Scalar log change | Absolute-change prerequisites; canonical strictly-positive ratio-scale semantics; positive values; registered log estimator. | Operation exists, but the current production scale registry is empty; refuse production claims. | +| Baseline/reference-window deviation | One current entry; explicit prior reference entries; current excluded from reference; at least two reference entries; positive reference SD; exact window/exclusion policy; comparable target support. | RES-69 operation exists and is bounded to descriptive deviation, not meaningfulness or readiness. | +| Reliability/random-error comparison | Source-bound reliability design authority; exact replicate/subject mapping; stable underlying quantity declaration; systematic trial-effect assessment; exact error scale; balanced/complete support; independent subject units. | RES-69 numerical operation exists, but the canonical production reliability-assumption registry is empty; public reliability authorization refuses until an owner supplies a declaration. | +| Method-agreement summary | Source-bound method-comparison design authority; same target construct/measurand; exact pairings; one pair per independent subject; fixed B-minus-A sign; exact common units; no hidden transformation. | RES-69 narrow bias/SD summary is implemented. It does not authorize LoA, interchangeability, equivalence, or acceptability. | +| Within-athlete association | Repeated observations for the same athlete; explicit time/occasion identity; within-person estimand; repeated-measures operation and design authority; no between-person substitution. | RES-69 repeated-measures correlation is deferred; refuse `COMPUTATION_NOT_REGISTERED`. | +| Between-athlete association | Independent athlete units; explicit one-unit-per-athlete/aggregation policy; cross-sectional estimand; population/source applicability; registered association operation. | No RES-69 operation authorizes this class; refuse. | +| Repeated-measures analysis | Repeated observations, exact clustering/occasion structure, missingness policy, temporal support, registered model/estimator and design authority. | Deferred in RES-69; RES-70 registers prerequisites only and does not implement it. | +| Mixed-effects analysis | Explicit fixed/random effects, nesting, grouping, covariance/estimator, convergence/adequacy policy, level identity, and source/design authority. | `MIXED_EFFECTS` is deferred in RES-69; refuse. | +| Cross-test association | Two distinct registered test/construct identities, explicit temporal/context alignment or lag policy, athlete/occasion structure, cross-test association operation, and evidence applicability. | No cross-test association operation is registered; refuse. | + +The matrix is intentionally asymmetric: an exact scalar change may be +computable while the stronger comparable, error-relative, or practical claim +is blocked. A deferred operation never becomes executable merely because its +prerequisites are represented. + +### 3.4 Capability decision flow + +Python evaluates, in order: + +1. request shape and canonical support/hash integrity; +2. operation/capability registry integrity and disposition; +3. exact observation/result/identity/provenance resolution; +4. source/population/football-context qualification; +5. pairwise comparability and bridge-execution requirements; +6. level-of-analysis and independence/cluster requirements; +7. scale, reliability, method-comparison, or other RES-69 authority; +8. minimum support shape and explicit temporal/missingness policy; and +9. software/registry/version consistency. + +The first unmet prerequisite is the primary refusal cause. All deterministically +discovered material causes may be retained in stable canonical order. If any +gate fails, no registered operation is dispatched through the RES-70 analysis +boundary. + +## 4. Claim authority + +### 4.1 Two orthogonal axes + +RES-70 does not place every claim on one line. It uses a product of two ordered +axes plus an optional prediction axis: + +```text +Measurement/change axis: +OBSERVED_VALUE + < NUMERICAL_CHANGE + < COMPARABLE_CHANGE + < CHANGE_RELATIVE_TO_MEASUREMENT_ERROR + < PRACTICAL_OR_DECISION_MEANINGFULNESS + +Relationship/causal axis: +OBSERVATION + < DESCRIPTIVE_CHANGE + < ASSOCIATION + < TEMPORAL_ASSOCIATION + < MECHANISTIC_HYPOTHESIS + < CAUSAL_EVIDENCE + +Prediction: separate registered predictive-validity contract; not a causal level. +``` + +The two base labels are intentionally not equated: `OBSERVED_VALUE` is a +measurement-result claim, while `OBSERVATION` is a relationship/causal-axis +base description. A claim can request only one axis, both axes, or neither +when it asks for identity/provenance description. + +The ordered axes form a partial order, not a single ladder. For example, +`CHANGE_RELATIVE_TO_MEASUREMENT_ERROR` does not imply `ASSOCIATION`, and +`TEMPORAL_ASSOCIATION` does not imply `PRACTICAL_OR_DECISION_MEANINGFULNESS`. + +### 4.2 Typed claim contracts + +```text +ClaimIntent { + claim_reference + measurement_level: optional MeasurementClaimLevel + relationship_level: optional RelationshipClaimLevel + predictive_intent: optional registered prediction intent + target: INDIVIDUAL | ATHLETE_WITHIN | ATHLETE_BETWEEN | GROUP | POPULATION + analysis_reference: optional AnalysisAuthorization reference + evidence_applicability_reference + decision_criterion_reference: optional +} + +ClaimAuthorityResult { + decision_id + status: AUTHORIZED | PARTIALLY_AUTHORIZED | REFUSED + allowed_measurement_levels[] + allowed_relationship_levels[] + prediction_status + blocked_claims[] + first_blocking_prerequisite + reason_codes[] + missing_information[] + safe_descriptions[] + support/analysis/comparability/bridge/evidence hashes + registry/software versions + decision_hash +} +``` + +`PARTIALLY_AUTHORIZED` means a requested strong claim is blocked while a +weaker claim about the same valid observation/result remains authorized. It is +not permission to phrase the blocked claim indirectly. + +### 4.3 Exact claim prerequisites + +| Claim level | Required evidence/authority | Explicit non-implication | +| --- | --- | --- | +| `OBSERVED_VALUE` | Exact valid observation, complete identity, result status/quality, source/provenance integrity. | Does not imply change, comparability, error-relative change, or mechanism. | +| `NUMERICAL_CHANGE` | Exact registered change operation; valid scalar support; exact units or an executed registered conversion; declared baseline/follow-up or reference window. | Does not imply comparable scientific change. | +| `COMPARABLE_CHANGE` | Numerical-change authority plus affirmative pairwise comparability (`COMPARABLE` or fulfilled `COMPARABLE_WITH_CONDITIONS`), exact bridge bindings if applicable, and claim-relevant context/evidence. | Does not imply change beyond error or practical importance. | +| `CHANGE_RELATIVE_TO_MEASUREMENT_ERROR` | Comparable change plus source/protocol-bound registered error/reliability authority, applicable error scale, uncertainty propagation, and exact support/design match. | Does not imply practical meaning, fatigue, readiness, injury, or mechanism. | +| `PRACTICAL_OR_DECISION_MEANINGFULNESS` | Error-relative or otherwise justified change plus an explicit registered decision/utility criterion and applicable population/context evidence. No generic threshold or p-value may substitute. | Does not imply a physiological mechanism or causal effect. | +| `OBSERVATION` | Exact typed observation/relationship description. | Does not imply descriptive change, association, or causality. | +| `DESCRIPTIVE_CHANGE` | Exact temporal support and descriptive change authority; no causal wording. | Temporal order alone is not causality. | +| `ASSOCIATION` | Registered association operation, declared estimand, adequate independent units/level, exact support, and statistical authority. | Association is not a causal effect. | +| `TEMPORAL_ASSOCIATION` | Association prerequisites plus explicit time ordering, repeated-measures design, and registered temporal/lag estimator. | Temporal precedence is not identification of a causal effect. | +| `MECHANISTIC_HYPOTHESIS` | Association/temporal result plus external mechanism evidence with explicit applicability and hypothesis status. | A plausible mechanism is not causal evidence. | +| `CAUSAL_EVIDENCE` | Explicit causal estimand, design/identification strategy, temporal structure, confounding/control assumptions, target-population applicability, registered causal operation, and uncertainty. | Routine monitoring, correlation, temporal order, or a p-value cannot promote to this level. | + +The following boundaries are invariants: + +```text +NUMERICAL_CHANGE != COMPARABLE_CHANGE +COMPARABLE_CHANGE != CHANGE_RELATIVE_TO_MEASUREMENT_ERROR +CHANGE_RELATIVE_TO_MEASUREMENT_ERROR != PRACTICAL_OR_DECISION_MEANINGFULNESS +ASSOCIATION != CAUSAL_EFFECT +TEMPORAL_ORDER != CAUSALITY +STATISTICAL_SIGNIFICANCE != PRACTICAL_IMPORTANCE +METHOD_VALIDITY != POPULATION_APPLICABILITY +``` + +## 5. Evidence applicability + +### 5.1 Orthogonal applicability vector + +RES-70 introduces an additive `ClaimEvidenceApplicability` composition. It +reuses `ApplicabilityDecision`, `V2EvidenceApplicability`, canonical source +decisions, evidence references, and RES-69 support/design evidence. + +```text +ApplicabilityAssessment { + axis: METHOD_VALIDITY | SOURCE_QUALITY | POPULATION_RELEVANCE | + CONTEXTUAL_RELEVANCE | STATISTICAL_ADEQUACY + decision: SUPPORTED | LIMITED | UNSUPPORTED | UNASSESSED + required_for_claim: bool + evidence_references[] + source/decision/authority references[] + conditions[] + rationale +} + +ClaimEvidenceApplicability { + claim_intent_reference + assessments: one typed assessment per required axis + registry_version/hash + applicability_hash +} +``` + +There is no `confidence_score`, weighted average, or single evidence grade +that can hide an unsupported axis. A limited method can be valid in isolation +while population relevance is unsupported; that combination can support a +method description but not a target-population norm or decision claim. + +### 5.2 Canonical source and target rules + +- `CANONICAL_EMPIRICAL_TARGET` and + `DIRECT_TARGET_POPULATION_EVIDENCE` can satisfy target-population + applicability only when their RES-60 decisions and claim role support it. +- `INDIRECT_MEASUREMENT_EVIDENCE` may support mechanics, metrology, signal + processing, reliability methodology, statistics, or device behavior when an + explicit V2 applicability record says so. It cannot silently establish + canonical football norms, practical thresholds, or population expectations. +- `NONCANONICAL_CONTEXT_ONLY` and `REJECTED_OR_UNRESOLVED` cannot satisfy a + required claim axis. +- Source quality/integrity is independent of population relevance. A source can + be high-quality but non-target, or target-relevant but too poorly identified + for a particular claim. +- Match/training contextual relevance is evaluated from typed RES-61 context + and exposure, not a free-text session label. +- Statistical adequacy is the design/support question for the requested + estimand; it is not a substitute for method validity or source quality. + +## 6. Within-athlete versus between-athlete authority + +### 6.1 Explicit level identity + +Every analysis request must bind: + +```text +unit_of_analysis +subject_key +repeated-measure key +cluster/nesting keys +grouping keys +temporal ordering/lag policy +pooling/aggregation policy +requested estimand level +``` + +The minimum level vocabulary is: + +```text +TRIAL +TEST_INSTANCE +SESSION +ATHLETE_WITHIN +ATHLETE_BETWEEN +GROUP_OR_SQUAD +POPULATION +JOINT_MULTILEVEL +``` + +### 6.2 Non-negotiable level rules + +- A between-athlete association requires independent athlete units. Repeated + rows from one athlete cannot increase the subject count unless a registered + model explicitly represents the clustering. +- A within-athlete association requires repeated observations linked to the + same athlete and a registered within-person estimand. A pooled correlation + cannot be relabelled as within-athlete behavior. +- Repeated measurements are not independent subjects. The support must retain + athlete/session/trial structure and the analysis must declare the dependence + policy. +- A within-athlete change is not a population effect. Population or squad + generalization requires separate population/source applicability and a + corresponding estimand. +- Cross-test association does not require the tests to be comparable measures; + it requires distinct exact identities plus an association design. Association + does not authorize substituting one test for another. +- `JOINT_MULTILEVEL` is permitted only when a registered mixed/multilevel + estimator and its random/fixed effect design exist. RES-70 does not implement + one. + +## 7. Refusal taxonomy and fail-closed behavior + +### 7.1 Reuse + +Every RES-70 refusal uses the existing `RefusalResult` envelope and high-level +classes. It retains exact observation IDs where available and states what can +still be safely described. + +### 7.2 RES-70-local reason codes + +Only reasons not already expressible by the generic/ref-69 taxonomy are added: + +```text +RES70_UNRESOLVED_IDENTITY +RES70_COMPARABILITY_AUTHORITY_MISSING +RES70_BRIDGE_REQUIRED +RES70_BRIDGE_NOT_EXECUTED +RES70_BRIDGE_EXECUTION_FAILED +RES70_BRIDGE_CONDITIONS_UNSATISFIED +RES70_INCOMPATIBLE_CONTEXT +RES70_NORMALIZATION_MISMATCH +RES70_METHOD_VERSION_MISMATCH +RES70_THRESHOLD_IDENTITY_MISMATCH +RES70_STATISTICAL_AUTHORITY_INSUFFICIENT +RES70_WRONG_LEVEL_OF_ANALYSIS +RES70_PSEUDOREPLICATION_RISK +RES70_UNSUPPORTED_CLAIM_ESCALATION +RES70_UNSUPPORTED_CAUSAL_CLAIM +RES70_INSUFFICIENT_EVIDENCE_APPLICABILITY +RES70_APPLICABILITY_AXIS_UNASSESSED +RES70_TRANSITIVITY_NOT_ESTABLISHED +RES70_REGISTRY_INTEGRITY_FAILURE +``` + +Mapping examples: + +| RES-70 reason | Generic refusal class | +| --- | --- | +| unresolved identity, context, threshold, or method version | `IDENTITY_UNRESOLVED` or `COMPARABILITY_UNESTABLISHED` | +| bridge required/not executed/conditions unsatisfied | `COMPARABILITY_UNESTABLISHED` | +| statistical authority, wrong level, pseudo-replication | `ANALYSIS_DESIGN_MISMATCH` or `DATA_ADEQUACY_INSUFFICIENT` | +| unsupported evidence axis | `EVIDENCE_SCOPE_UNSUPPORTED` | +| claim escalation or practical meaning without criterion | `UNCERTAINTY_LIMITS_CLAIM` | +| unsupported causal claim | `CAUSAL_IDENTIFICATION_UNSUPPORTED` | +| deferred/unregistered operation or bad registry | `COMPUTATION_NOT_REGISTERED` | + +### 7.3 Safe fallback behavior + +Examples of safe partial output: + +- blocked cross-device change → retain each exact observation and its identity; +- blocked bridge → retain source/provider values as source/provider outputs; +- blocked error-relative claim → retain a valid comparable/numerical result if + that lower claim was independently authorized; +- blocked within-athlete inference → retain descriptive per-athlete values and + the between-athlete result only if that was the requested/authorized level; +- blocked causal claim → retain observation, descriptive change, or association + output with the causal level explicitly refused. + +No refusal erases an independently valid observation. No safe description may +use wording that implies the blocked stronger claim. + +## 8. Provenance and determinism + +Every authorized RES-70 decision/result must be reproducible from: + +```text +exact source observation/entry IDs and canonical hashes +measurement identity/result/context/provenance hashes +RES-60/61 source and context decisions +pairwise comparability decisions and rule references +bridge registration and executed output/provenance, if any +RES-69 support/scale/reliability/method-comparison authority +analysis capability registry version/hash +requested analysis and level-of-analysis identity +claim intent and evidence-applicability bundle +software and registry versions +processing/provenance graph references +authorization/refusal/claim decision hashes +``` + +Rules: + +- No stochastic or model-dependent decision logic exists in RES-70 V1. +- No caller-supplied formula, threshold, unit conversion, scale flag, bridge + validity flag, sample-size verdict, or numerical result is authoritative. +- Every numerical transformation creates an append-only derived observation. +- Every analysis result reuses RES-69 `StatisticalAnalysisRun`/provenance + semantics rather than inventing a second run graph. +- Every registry is immutable, versioned, hashable, duplicate-free, and + resolved from the canonical production instance. +- Canonical ordering and V3 serialization are used for all new contracts. + +## 9. LM boundary + +| LM may | LM may not | +| --- | --- | +| propose an analysis class | mint comparability | +| propose a claim class/axis level | select an arbitrary transformation | +| request a named registered bridge | bypass support, identity, or level prerequisites | +| provide a scientific question/context candidate | promote claim authority | +| extract candidate protocol/context metadata for typed validation | declare causality, readiness, fatigue, injury, or population applicability | +| interpret an authorized structured result | fabricate a formula, threshold, evidence grade, or numerical result | + +The LM request is data. Python decides whether the request is admissible, +whether a bridge executes, which operation is allowed, and which claim levels +are supported. + +## 10. Proposed future public API + +The following is an additive API plan, not code in this mission: + +```python +# dynamislm.comparability +assess_cross_source_comparability(request) -> CrossSourceComparabilityDecision +execute_registered_bridge(request) -> BridgeExecutionResult | RefusalResult +validate_cross_source_decision(decision) -> None + +# dynamislm.analysis +authorize_analysis(request) -> AnalysisAuthorization | RefusalResult +execute_authorized_analysis(authorization) -> StatisticalResult | RefusalResult +validate_analysis_authorization(authorization) -> None + +# dynamislm.claims +authorize_claim(intent) -> ClaimAuthorityResult +validate_claim_authority(result) -> None + +# dynamislm.evidence +build_claim_evidence_applicability(...) -> ClaimEvidenceApplicability +validate_claim_evidence_applicability(...) -> None +``` + +The public functions accept typed immutable contracts and canonical registries +only. A low-level operation may still be tested independently under its owning +RES authority, but a RES-70 claim-eligible result must carry an authorized +RES-70 decision reference. + +## 11. Exact implementation file plan + +This is the authorized future implementation shape. None of these production +files is created by RES-70-DR-001. + +### New files + +```text +src/dynamislm/comparability/res70_models.py + ObservationAuthorityReference, ComparabilityDimension, + DimensionFinding, CrossSourceComparabilityRequest/Decision, + BridgeRegistration, BridgeApplicationRequest/ExecutionResult. + +src/dynamislm/comparability/res70_registry.py + bridge references, bridge registry, comparability rule registry/version/hash. + +src/dynamislm/comparability/res70_authority.py + pairwise-only adjudication, bridge resolution, no-transitive-closure gate. + +src/dynamislm/comparability/res70_validation.py + hash/relation/registry/provenance and execution validation. + +src/dynamislm/analysis/models.py + AnalysisClass, AnalysisLevelIdentity, AnalysisCapability, + AnalysisAuthorizationRequest, AnalysisAuthorization. + +src/dynamislm/analysis/registry.py + canonical RES-70 capability matrix and registry integrity/version/hash. + +src/dynamislm/analysis/authority.py + deterministic prerequisite evaluation and operation dispatch gate. + +src/dynamislm/analysis/validation.py + support, level, evidence, comparability, registry and hash validation. + +src/dynamislm/claims/models.py + MeasurementClaimLevel, RelationshipClaimLevel, ClaimIntent, + ClaimAuthorityResult, prediction-axis contract. + +src/dynamislm/claims/registry.py + claim prerequisite policies and decision-criterion references. + +src/dynamislm/claims/authority.py + two-axis claim authorization and escalation refusal. + +src/dynamislm/claims/validation.py + claim-result recomputation and V3/tamper validation. + +src/dynamislm/evidence/res70.py + ApplicabilityAxis, ApplicabilityAssessment, + ClaimEvidenceApplicability and validation. +``` + +### Additive edits in the implementation mission + +```text +src/dynamislm/comparability/__init__.py +src/dynamislm/analysis/__init__.py +src/dynamislm/claims/__init__.py +src/dynamislm/evidence/__init__.py +src/dynamislm/__init__.py +``` + +Only exports and registrations are additive. `serialization.py`, existing +RES-34→69 dataclasses, family comparators, and historical hashes are not +rewritten. + +### Future focused tests + +```text +tests/test_res70_comparability.py +tests/test_res70_bridges.py +tests/test_res70_analysis_capability.py +tests/test_res70_claim_authority.py +tests/test_res70_evidence_and_levels.py +tests/test_res70_adversarial.py +``` + +Tests must use clearly synthetic fixtures only and must attempt to falsify the +authority boundaries. They are not part of this design mission. + +## 12. Adversarial qualification matrix + +| Attack/negative case | Required deterministic response | +| --- | --- | +| Same metric label, different measurand | `NOT_COMPARABLE`; label ignored. | +| Same unit, different construct/protocol/metric definition | `NOT_COMPARABLE` or `BRIDGE_VALIDATION_REQUIRED` according to the registered rule; never direct comparison. | +| Same values, different observation IDs | Preserve both observations; no deduplication by value. | +| A~B and B~C offered as an A~C shortcut | `RES70_TRANSITIVITY_NOT_ESTABLISHED`; require direct A/C adjudication. | +| Caller passes `force_comparable=True` | Reject unknown/non-authoritative input; no state change. | +| Caller asks for a unit conversion but does not provide executed output | `REQUIRES_TRANSFORMATION` / `RES70_BRIDGE_NOT_EXECUTED`. | +| Hidden conversion in a derived scalar | Refuse; output must have a registered operation and new provenance. | +| Bridge source/target key contains observation-instance ID | Registry integrity failure. | +| Bridge declaration exists but domain/range/evidence does not apply | `INSUFFICIENT_INFORMATION` or `BRIDGE_CONDITIONS_UNSATISFIED`. | +| Bridge operation changes value but returns original observation ID | Reject forged output/provenance linkage. | +| Lossy bridge with no uncertainty/limitation policy | Refuse error-relative/practical claims. | +| Caller selects an unregistered estimator/formula/threshold | `COMPUTATION_NOT_REGISTERED`. | +| Caller supplies scale/ratiometric/positive flag | Ignore flag; resolve canonical RES-69 scale authority. | +| Caller names deferred RES-69 repeated correlation/mixed-effects method | Refuse; do not implement or dispatch it. | +| Between-athlete rows relabelled as within-athlete | `RES70_WRONG_LEVEL_OF_ANALYSIS`. | +| Multiple trials counted as independent subjects | `RES70_PSEUDOREPLICATION_RISK`. | +| Repeated measurements treated as independent observations | Refuse unless registered dependence model is present. | +| Within-athlete change generalized to the population | Block population claim; retain within-athlete description. | +| Match observation compared with training observation for a match claim | `RES70_INCOMPATIBLE_CONTEXT` or conditional result only under a registered policy. | +| Method validity used as population applicability | Block target-population claim; keep method statement. | +| Indirect method evidence used for canonical football norm | `RES70_INSUFFICIENT_EVIDENCE_APPLICABILITY`. | +| Statistical significance used as practical meaning | Block practical claim without a decision criterion. | +| Comparable change relabelled as physiological mechanism | Block latent/physiological escalation. | +| Association relabelled as causal effect | `RES70_UNSUPPORTED_CAUSAL_CLAIM`. | +| Temporal order relabelled as causal identification | `CAUSAL_IDENTIFICATION_UNSUPPORTED`. | +| Forged registry version/hash or duplicate key | `RES70_REGISTRY_INTEGRITY_FAILURE`. | +| Tampered support/identity/bridge/decision hash on V3 decode | Decode/construction failure. | +| Valid source observation plus blocked comparison | Preserve safe independent source description. | + +## 13. Explicit reuse, new work, deferral, and rejection + +### REUSE_FROM_RES69 + +- `StatisticalSupport`, exact RES-62 input/entry references, exclusions, + missingness, and support hashes. +- `StatisticalComparabilityEvidence` as the only affirmative evidence path + into existing RES-69 calculations. +- `StatisticalAnalysisRun`, `StatisticalResult`, `StatisticalEstimate`, + `StatisticalNonComputable`, and existing deterministic provenance graphs. +- RES-69 scale-semantic registry, reliability-assumption authority, + method-comparison authority, operation registry/disposition, exact-unit rule, + and refusal factories. +- RES-69 distinctions between descriptive within-athlete SD, random error, + method-comparison bias/SD, and claim interpretation. + +### NEW_IN_RES70 + +- Claim-relative cross-source wrapper and material-dimension findings. +- Pairwise comparability matrix with an explicit no-transitivity invariant. +- Registered bridge declaration, execution, uncertainty/lossiness, and + append-only transformed-observation contract. +- Analysis-capability registry above the RES-69 operation registry. +- Explicit level-of-analysis/estimand/cluster identity and pseudo-replication + gate. +- Two-axis claim model, typed claim intents/results, claim escalation rules, + and prediction separation. +- Orthogonal evidence-applicability bundle and RES-70 decision provenance. +- RES-70-local granular refusal codes and registry integrity checks. + +### DEFER_TO_LATER + +- Any RES-69 operation currently `DEFER`, `REPRESENT_BUT_DO_NOT_COMPUTE`, or + `REJECT`, including repeated-measures correlation, mixed effects, ICC/MDC, + confidence intervals, covariance propagation, and classical agreement limits. +- Family-specific cross-device/provider/protocol bridges until each owning + scientific decision supplies evidence, domain limits, uncertainty, and tests. +- Generic meaningful-change thresholds, practical utility policies, readiness, + fatigue, injury, and physiological interpretation. +- A general causal-inference engine, transport estimator, or predictive model. +- LM runtime/tool-calling/model-training integration. +- Persistence/database implementation beyond existing immutable contracts. + +### REJECTED + +- Label/name, unit-only, numeric-equality, or correlation-based comparability. +- Transitive comparability or bridge chaining without direct registered support. +- Caller-minted bridge validity, formulas, thresholds, units, scale semantics, + sample-size adequacy, population applicability, or causal status. +- Hidden value transformation or in-place reprocessing. +- A scalar confidence score collapsing evidence axes. +- A single linear claim ladder that conflates measurement change and causal + inference. +- Between/within substitution, pseudo-replication, or observation-instance + semantic registry keys. +- Any RES-70 implementation that opportunistically fills a deferred RES-69 + method. + +## 14. Targeted evidence table + +| Source | Type | Finding used | RES-70 consequence | Limits/applicability | +| --- | --- | --- | --- | --- | +| [Bland & Altman, 1986, *Lancet*, PMID 2868172, DOI 10.1016/S0140-6736(86)90837-8](https://pubmed.ncbi.nlm.nih.gov/2868172/) | Primary methodological paper | Correlation is not an agreement/interchangeability analysis; method comparison needs differences/repeatability context. | Keep association and agreement/comparability separate; require method-comparison authority and do not promote correlation. | Developed for clinical measurement-method comparison; RES-70 uses the boundary, not a universal acceptance limit. | +| [Bland & Altman, 1999, *Statistical Methods in Medical Research*, PMID 10501650, DOI 10.1177/096228029900800204](https://pubmed.ncbi.nlm.nih.gov/10501650/) | Methodological paper | Agreement requires paired same-subject method differences and must account for repeated measurements. | Bind exact pairing, method identity, replicate design, and authority; do not infer interchangeability from a summary alone. | No generic football/device limits are adopted. | +| [Vandenberg & Lance, 2000, DOI 10.1177/109442810031002](https://doi.org/10.1177/109442810031002) | Methodological review with longitudinal example | Measurement invariance is a prerequisite for substantive cross-group comparisons. | Treat cross-source equivalence as an identity/evidence problem; a bridge must establish claim-relative invariance/equivalence where relevant. | Psychometric invariance tests do not mechanically replace physical-method validation. | +| [Lazic, 2010, *BMC Neuroscience*, DOI 10.1186/1471-2202-11-5](https://pmc.ncbi.nlm.nih.gov/articles/PMC2817684/) | Methodological analysis | Pseudoreplication arises when non-independent replicates are used as independent units. | Require explicit experimental unit, clustering, and independence/replicate policy. | Domain is neuroscience; the dependence principle is general. | +| [Curran & Bauer, 2011, DOI 10.1146/annurev.psych.093008.100356](https://doi.org/10.1146/annurev.psych.093008.100356) | Longitudinal methods review | Within-person and between-person effects must be disaggregated in longitudinal models. | Make the estimand/level identity mandatory and refuse pooled relabelling. | The review does not select one universal model for football monitoring. | +| [Hopkins, 2000, *Sports Medicine*, PMID 10907753, DOI 10.2165/00007256-200030010-00001](https://pubmed.ncbi.nlm.nih.gov/10907753/) | Sports-methods reliability paper | Reliability includes within-subject random variation, systematic trial change, and retest correlation; typical error and correlation answer different questions. | Reuse RES-69 reliability/error distinctions; require source-bound design authority and block generic SEM/MDC/meaningful change. | No universal error scale or threshold is imported into RES-70. | +| [Wasserstein & Lazar, 2016, ASA p-value statement, DOI 10.1080/00031305.2016.1154108](https://doi.org/10.1080/00031305.2016.1154108) | Professional methodological statement | P-values require context and do not supply effect magnitude or practical importance by themselves. | Keep statistical evidence, measurement error, and practical/decision criteria as separate claim prerequisites. | This is not a football-specific decision threshold. | +| [Hernán & Robins, 2020, *Causal Inference: What If*](https://miguelhernan.org/whatifbook) | Authoritative methodological book | Causal claims require an explicit causal question/design/estimand and assumptions, including for longitudinal data. | Keep causal evidence separate from observation, association, and temporal association; require a registered causal operation. | RES-70 does not implement causal inference. | +| [Bareinboim & Pearl, 2016, *PNAS*, PMID 27382148, DOI 10.1073/pnas.1510507113](https://pubmed.ncbi.nlm.nih.gov/27382148/) | Primary causal/transportability paper | Transporting causal information across populations requires explicit assumptions about source/target differences and data fusion. | Keep population relevance separate from method validity; no silent target-population generalization. | Transportability theory is not a license for routine athlete-monitoring causal claims. | +| [Degtiar & Rose, 2023, DOI 10.1146/annurev-statistics-042522-103837](https://doi.org/10.1146/annurev-statistics-042522-103837) | Methodological review | Internal validity and external validity/target-population applicability are distinct and require assumptions. | Use separate population/context applicability axes; a valid method can have limited target applicability. | The review concerns causal effect generalization; RES-70 applies the distinction more broadly and conservatively. | + +## 15. Deferred scientific questions, not RES-70 blockers + +The generic architecture is frozen without pretending to answer operation- or +bridge-specific scientific questions. The following are explicitly deferred +to the owning future implementation/decision record; RES-70 V1 refuses any +claim that needs them: + +1. What agreement/equivalence limits are acceptable for a particular device, + provider, protocol, population, and claim? A future bridge owner must supply + claim-specific evidence and uncertainty; there is no universal limit. +2. What sample-size, covariance, missingness, and convergence rules are needed + for a particular repeated-measures or mixed-effects estimator? The operation + is not registered in RES-69, so RES-70 does not choose a number now. +3. What practical/decision criterion applies to a particular staff decision? + A future criterion must be explicitly registered with its population/context + applicability; no generic smallest worthwhile change is adopted. +4. What causal identification strategy, if any, is appropriate for a future + question? A future causal mission must define the estimand/design and + evidence; RES-70 only enforces the boundary. + +These are not missing decisions needed to freeze the RES-70 generic authority +architecture. They are intentionally typed `DEFERRED`/`REJECTED` capability +entries, so no production claim can proceed while they are absent. + +## 16. Acceptance criteria for the future implementation + +The RES-70 implementation may be declared complete only if deterministic +tests demonstrate: + +1. no label, unit, correlation, caller flag, or transitive path mints + comparability; +2. every numerical bridge output has a new output identity, executed method, + uncertainty/lossiness status, and complete provenance; +3. a declared but unexecuted transformation cannot enter statistical support; +4. unknown, conflicting, or missing material dimensions fail closed; +5. deferred RES-69 operations remain refused; +6. within/between/pseudo-replication attacks are blocked; +7. evidence axes remain separately inspectable and no scalar confidence score + is emitted as authority; +8. claim promotion stops at the first unsupported prerequisite and preserves + safe lower descriptions; +9. causal/readiness/fatigue/injury escalation is refused without the required + registered design and evidence; +10. V3 round-trip/hash/tamper tests cover every new type and all historical + RES-34→69 hashes remain unchanged; and +11. all authorized decisions can be reproduced from their recorded hashes, + registry versions, software version, and provenance references. + +## Design freeze disposition + +```text +RES70_DESIGN_STATUS=FROZEN +RES70_PRODUCTION_IMPLEMENTATION=NOT_STARTED +SCIENTIFIC_NUMERICAL_AUTHORITY_CHANGED=NO +SERIALIZATION_VERSION_CHANGED=NO +RES34_TO_RES69_AUTHORITY_CHANGED=NO +``` + +The next authorized action is a separate implementation mission that realizes +this record and adds adversarial tests. RES-70-DR-001 itself does not authorize +production code, tests, bridges, deferred statistics, or model work. From e7223dca21356f19aad86be3544d8ab9efa89d89 Mon Sep 17 00:00:00 2001 From: Julio Rodriguez <144072916+Litju@users.noreply.github.com> Date: Fri, 18 Sep 2026 19:26:16 +0000 Subject: [PATCH 02/18] feat(res70): add cross-source authority contracts --- src/dynamislm/__init__.py | 80 +++ src/dynamislm/analysis/__init__.py | 29 + src/dynamislm/analysis/models.py | 408 +++++++++++ src/dynamislm/claims/__init__.py | 21 + src/dynamislm/claims/models.py | 284 ++++++++ src/dynamislm/comparability/__init__.py | 32 + src/dynamislm/comparability/res70_models.py | 748 ++++++++++++++++++++ src/dynamislm/evidence/__init__.py | 18 +- src/dynamislm/evidence/res70.py | 159 +++++ src/dynamislm/refusal/models.py | 19 + tests/test_res70_models.py | 197 ++++++ 11 files changed, 1994 insertions(+), 1 deletion(-) create mode 100644 src/dynamislm/analysis/__init__.py create mode 100644 src/dynamislm/analysis/models.py create mode 100644 src/dynamislm/claims/__init__.py create mode 100644 src/dynamislm/claims/models.py create mode 100644 src/dynamislm/comparability/res70_models.py create mode 100644 src/dynamislm/evidence/res70.py create mode 100644 tests/test_res70_models.py diff --git a/src/dynamislm/__init__.py b/src/dynamislm/__init__.py index 66c078e..0b12303 100644 --- a/src/dynamislm/__init__.py +++ b/src/dynamislm/__init__.py @@ -1,20 +1,62 @@ """DynamisLM's generic scientific-contract kernel.""" +from dynamislm.analysis import ( + AnalysisAuthorization, + AnalysisAuthorizationRequest, + AnalysisAuthorizationStatus, + AnalysisCapability, + AnalysisCapabilityDisposition, + AnalysisClass, + AnalysisEstimandLevel, + AnalysisLevelIdentity, + AnalysisUnit, + AnalysisUnitOfAnalysis, + EstimandLevel, +) +from dynamislm.claims import ( + ClaimAuthorityResult, + ClaimAuthorityStatus, + ClaimIntent, + ClaimTarget, + MeasurementClaimLevel, + PredictionStatus, + RelationshipClaimLevel, +) from dynamislm.comparability import ( + BridgeApplicationRequest, + BridgeAuthorityOrigin, + BridgeExecutionResult, + BridgeExecutionStatus, + BridgeInvertibility, + BridgeMode, + BridgeRegistration, + ClaimContext, ComparabilityAuthority, ComparabilityAuthorityError, ComparabilityDecisionSource, + ComparabilityDimension, ComparabilityReasonCode, ComparabilityRequest, ComparabilityResult, ComparabilityState, + CrossSourceComparabilityDecision, + CrossSourceComparabilityRequest, + DimensionFinding, + DimensionFindingStatus, + ObservationAuthorityReference, RegisteredComparabilityRule, + SemanticIdentityKey, TransformationRequest, ) from dynamislm.evidence import ( + ApplicabilityAssessment, + ApplicabilityAxis, ApplicabilityDecision, + ClaimEvidenceApplicability, EvidenceApplicability, EvidenceDecisionRecord, + build_claim_evidence_applicability, + validate_claim_evidence_applicability, ) from dynamislm.external_load import ( EXTERNAL_LOAD_ACCELERATION_EVENT_COUNT_METRIC, @@ -434,6 +476,18 @@ __all__ = [ *_field_testing.__all__, + "AnalysisAuthorization", + "AnalysisAuthorizationRequest", + "AnalysisAuthorizationStatus", + "AnalysisCapability", + "AnalysisCapabilityDisposition", + "AnalysisClass", + "AnalysisEstimandLevel", + "AnalysisLevelIdentity", + "AnalysisUnit", + "AnalysisUnitOfAnalysis", + "ApplicabilityAssessment", + "ApplicabilityAxis", "BUILD_LONGITUDINAL_SOURCE_MANIFEST", "CANONICAL_POPULATION_DIMENSIONS", "CANONICAL_POPULATION_QUALIFICATION_METHOD", @@ -478,10 +532,29 @@ "ComparabilityAuthority", "ComparabilityAuthorityError", "ComparabilityDecisionSource", + "ComparabilityDimension", "ComparabilityReasonCode", "ComparabilityRequest", "ComparabilityResult", "ComparabilityState", + "BridgeApplicationRequest", + "BridgeAuthorityOrigin", + "BridgeExecutionResult", + "BridgeExecutionStatus", + "BridgeInvertibility", + "BridgeMode", + "BridgeRegistration", + "ClaimAuthorityResult", + "ClaimAuthorityStatus", + "ClaimContext", + "ClaimEvidenceApplicability", + "ClaimIntent", + "ClaimTarget", + "CrossSourceComparabilityDecision", + "CrossSourceComparabilityRequest", + "DimensionFinding", + "DimensionFindingStatus", + "EstimandLevel", "CompetitionContext", "CompetitionIdentity", "CompetitionKind", @@ -525,6 +598,7 @@ "MatchSession", "MatchVenueRole", "MeasurementIdentity", + "MeasurementClaimLevel", "MeasurementQuality", "MeasurementResult", "MetadataEntry", @@ -535,8 +609,10 @@ "MultiSourceProvenanceGraph", "NormalizationSpec", "ObservationContext", + "ObservationAuthorityReference", "ParticipationState", "PopulationDimension", + "PredictionStatus", "PopulationEvidenceBinding", "PopulationEvidenceStatus", "PopulationIdentity", @@ -556,6 +632,7 @@ "RefusalReasonCode", "RefusalResult", "RefusalStatus", + "RelationshipClaimLevel", "RegisteredComparabilityRule", "RegisteredDatasetFileIdentity", "RegistryReference", @@ -573,6 +650,7 @@ "SeasonContext", "SeasonIdentity", "SemanticIdentity", + "SemanticIdentityKey", "SerializationError", "Sex", "SignConvention", @@ -620,6 +698,7 @@ "assert_data_path_contained", "assert_external_data_root", "build_canonical_source", + "build_claim_evidence_applicability", "build_complete_multi_source_provenance_graph", "build_contextualized_scientific_observation", "build_longitudinal_observation_entry", @@ -671,6 +750,7 @@ "store_verified_temporary_file", "tab_header", "validate_canonical_records", + "validate_claim_evidence_applicability", "validate_canonical_replay", "validate_complete_multi_source_provenance_graph", "validate_football_context_for_observation", diff --git a/src/dynamislm/analysis/__init__.py b/src/dynamislm/analysis/__init__.py new file mode 100644 index 0000000..22bedd0 --- /dev/null +++ b/src/dynamislm/analysis/__init__.py @@ -0,0 +1,29 @@ +"""RES-70 deterministic analysis-capability contracts.""" + +from dynamislm.analysis.models import ( + AnalysisAuthorization, + AnalysisAuthorizationRequest, + AnalysisAuthorizationStatus, + AnalysisCapability, + AnalysisCapabilityDisposition, + AnalysisClass, + AnalysisEstimandLevel, + AnalysisLevelIdentity, + AnalysisUnit, + AnalysisUnitOfAnalysis, + EstimandLevel, +) + +__all__ = [ + "AnalysisAuthorization", + "AnalysisAuthorizationRequest", + "AnalysisAuthorizationStatus", + "AnalysisCapability", + "AnalysisCapabilityDisposition", + "AnalysisClass", + "AnalysisEstimandLevel", + "AnalysisLevelIdentity", + "AnalysisUnit", + "AnalysisUnitOfAnalysis", + "EstimandLevel", +] diff --git a/src/dynamislm/analysis/models.py b/src/dynamislm/analysis/models.py new file mode 100644 index 0000000..393ff9b --- /dev/null +++ b/src/dynamislm/analysis/models.py @@ -0,0 +1,408 @@ +"""RES-70 analysis-capability and level-of-analysis contracts.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum + +from dynamislm.comparability.models import ComparabilityState +from dynamislm.comparability.res70_models import CrossSourceComparabilityDecision +from dynamislm.evidence.res70 import ClaimEvidenceApplicability +from dynamislm.longitudinal.statistics.models import StatisticalSupport +from dynamislm.measurement.identity import ( + InstanceIdentifier, + MetadataEntry, + RegistryReference, + ScientificIdentifier, + _require_enum, + _require_instance, + _require_optional_instance, + _require_text, + _require_tuple_items, + require_tuple, +) +from dynamislm.measurement.observation import ScientificMeasurementObservation +from dynamislm.refusal.models import RefusalResult +from dynamislm.serialization import canonical_hash, register_serializable_type + +_SHA256_PREFIX = "sha256:" + + +def _require_hash(value: str, field_name: str) -> None: + _require_text(value, field_name) + digest = value.removeprefix(_SHA256_PREFIX) + if ( + not value.startswith(_SHA256_PREFIX) + or len(digest) != 64 + or any(character not in "0123456789abcdef" for character in digest) + ): + raise ValueError(f"{field_name} must be a canonical sha256 hash") + + +def _require_string_tuple(value: object, field_name: str) -> None: + require_tuple(value, field_name) + assert isinstance(value, tuple) + if any(not isinstance(item, str) or not item.strip() for item in value): + raise ValueError(f"{field_name} must contain non-empty strings") + + +class AnalysisClass(StrEnum): + SCALAR_ABSOLUTE_CHANGE = "SCALAR_ABSOLUTE_CHANGE" + SCALAR_RELATIVE_CHANGE = "SCALAR_RELATIVE_CHANGE" + SCALAR_LOG_CHANGE = "SCALAR_LOG_CHANGE" + BASELINE_REFERENCE_WINDOW_DEVIATION = "BASELINE_REFERENCE_WINDOW_DEVIATION" + RELIABILITY_RANDOM_ERROR_COMPARISON = "RELIABILITY_RANDOM_ERROR_COMPARISON" + METHOD_AGREEMENT_SUMMARY = "METHOD_AGREEMENT_SUMMARY" + WITHIN_ATHLETE_ASSOCIATION = "WITHIN_ATHLETE_ASSOCIATION" + BETWEEN_ATHLETE_ASSOCIATION = "BETWEEN_ATHLETE_ASSOCIATION" + REPEATED_MEASURES_ANALYSIS = "REPEATED_MEASURES_ANALYSIS" + MIXED_EFFECTS_ANALYSIS = "MIXED_EFFECTS_ANALYSIS" + CROSS_TEST_ASSOCIATION = "CROSS_TEST_ASSOCIATION" + + +class AnalysisCapabilityDisposition(StrEnum): + IMPLEMENTED = "IMPLEMENTED" + REPRESENT_ONLY = "REPRESENT_ONLY" + DEFERRED = "DEFERRED" + REJECTED = "REJECTED" + + +class AnalysisUnitOfAnalysis(StrEnum): + TRIAL = "TRIAL" + TEST_INSTANCE = "TEST_INSTANCE" + SESSION = "SESSION" + ATHLETE = "ATHLETE" + GROUP = "GROUP" + + +class AnalysisEstimandLevel(StrEnum): + WITHIN_ATHLETE = "WITHIN_ATHLETE" + BETWEEN_ATHLETE = "BETWEEN_ATHLETE" + JOINT_MULTILEVEL = "JOINT_MULTILEVEL" + GROUP = "GROUP" + + # Stable terminology used by the design's level-of-analysis vocabulary. + ATHLETE_WITHIN = "WITHIN_ATHLETE" + ATHLETE_BETWEEN = "BETWEEN_ATHLETE" + GROUP_OR_SQUAD = "GROUP" + + +class AnalysisAuthorizationStatus(StrEnum): + AUTHORIZED = "AUTHORIZED" + REFUSED = "REFUSED" + + +AnalysisUnit = AnalysisUnitOfAnalysis +EstimandLevel = AnalysisEstimandLevel + +AnalysisKey = str | InstanceIdentifier | ScientificIdentifier | RegistryReference + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class AnalysisLevelIdentity: + """Explicit unit, clustering, pooling, and estimand identity.""" + + unit_of_analysis: AnalysisUnitOfAnalysis + estimand_level: AnalysisEstimandLevel + subject_key: AnalysisKey + clustering_keys: tuple[AnalysisKey, ...] = () + grouping_keys: tuple[AnalysisKey, ...] = () + temporal_order_policy: RegistryReference | None = None + pooling_policy: RegistryReference | None = None + + def __post_init__(self) -> None: + _require_enum(self.unit_of_analysis, AnalysisUnitOfAnalysis, "unit_of_analysis") + _require_enum(self.estimand_level, AnalysisEstimandLevel, "estimand_level") + if not isinstance( + self.subject_key, + str | InstanceIdentifier | ScientificIdentifier | RegistryReference, + ): + raise ValueError("subject_key must be a typed analysis key") + if isinstance(self.subject_key, str) and not self.subject_key.strip(): + raise ValueError("subject_key must not be empty") + for field_name, value in ( + ("clustering_keys", self.clustering_keys), + ("grouping_keys", self.grouping_keys), + ): + require_tuple(value, field_name) + if any( + not isinstance( + item, + str | InstanceIdentifier | ScientificIdentifier | RegistryReference, + ) + for item in value + ): + raise ValueError(f"{field_name} must contain typed analysis keys") + _require_optional_instance( + self.temporal_order_policy, + RegistryReference, + "temporal_order_policy", + ) + _require_optional_instance(self.pooling_policy, RegistryReference, "pooling_policy") + + @property + def level_key(self) -> str: + return f"{self.unit_of_analysis.value}:{self.estimand_level.value}" + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class AnalysisCapability: + """One immutable entry in the RES-70 capability matrix.""" + + capability_reference: RegistryReference + analysis_class: AnalysisClass + registered_operation_reference: RegistryReference | None + estimator_reference: RegistryReference | None + disposition: AnalysisCapabilityDisposition + required_support_shape: tuple[str, ...] + required_identity_dimensions: tuple[str, ...] + required_comparability_states: tuple[ComparabilityState, ...] + required_bridge_execution: bool + required_level_of_analysis: tuple[AnalysisEstimandLevel, ...] + required_statistical_authority: tuple[RegistryReference, ...] + required_evidence_axes: tuple[str, ...] + required_context: tuple[str, ...] + output_claim_floor: str | None + registry_version: str + capability_hash: str | None = None + + def __post_init__(self) -> None: + _require_instance(self.capability_reference, RegistryReference, "capability_reference") + if self.capability_reference.identifier.object_type != "analysis-capability": + raise ValueError("capability_reference must identify an analysis capability") + _require_enum(self.analysis_class, AnalysisClass, "analysis_class") + _require_optional_instance( + self.registered_operation_reference, + RegistryReference, + "registered_operation_reference", + ) + _require_optional_instance( + self.estimator_reference, RegistryReference, "estimator_reference" + ) + _require_enum(self.disposition, AnalysisCapabilityDisposition, "disposition") + _require_string_tuple(self.required_support_shape, "required_support_shape") + _require_string_tuple(self.required_identity_dimensions, "required_identity_dimensions") + require_tuple(self.required_comparability_states, "required_comparability_states") + if any( + not isinstance(item, ComparabilityState) for item in self.required_comparability_states + ): + raise ValueError("required_comparability_states must contain ComparabilityState values") + if not isinstance(self.required_bridge_execution, bool): + raise ValueError("required_bridge_execution must be a boolean") + require_tuple(self.required_level_of_analysis, "required_level_of_analysis") + if any( + not isinstance(item, AnalysisEstimandLevel) for item in self.required_level_of_analysis + ): + raise ValueError("required_level_of_analysis must contain AnalysisEstimandLevel values") + _require_tuple_items( + self.required_statistical_authority, + RegistryReference, + "required_statistical_authority", + ) + _require_string_tuple(self.required_evidence_axes, "required_evidence_axes") + _require_string_tuple(self.required_context, "required_context") + if self.output_claim_floor is not None: + _require_text(self.output_claim_floor, "output_claim_floor") + _require_text(self.registry_version, "registry_version") + expected_hash = canonical_hash( + { + "capability_reference": self.capability_reference, + "analysis_class": self.analysis_class, + "registered_operation_reference": self.registered_operation_reference, + "estimator_reference": self.estimator_reference, + "disposition": self.disposition, + "required_support_shape": self.required_support_shape, + "required_identity_dimensions": self.required_identity_dimensions, + "required_comparability_states": self.required_comparability_states, + "required_bridge_execution": self.required_bridge_execution, + "required_level_of_analysis": self.required_level_of_analysis, + "required_statistical_authority": self.required_statistical_authority, + "required_evidence_axes": self.required_evidence_axes, + "required_context": self.required_context, + "output_claim_floor": self.output_claim_floor, + "registry_version": self.registry_version, + } + ) + if self.capability_hash is None: + object.__setattr__(self, "capability_hash", expected_hash) + elif self.capability_hash != expected_hash: + raise ValueError("capability_hash does not match immutable capability content") + + @property + def canonical_capability_hash(self) -> str: + assert self.capability_hash is not None + return self.capability_hash + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class AnalysisAuthorizationRequest: + """Typed request proposed by a caller and adjudicated by Python.""" + + request_id: InstanceIdentifier + analysis_class: AnalysisClass + support: StatisticalSupport | None + support_reference: RegistryReference | None + observations: tuple[ScientificMeasurementObservation, ...] + identity_hashes: tuple[str, ...] + comparability_decisions: tuple[CrossSourceComparabilityDecision, ...] + requested_level: AnalysisLevelIdentity + evidence_applicability: ClaimEvidenceApplicability | None + context_references: tuple[RegistryReference, ...] + requested_parameters: tuple[MetadataEntry, ...] = () + + def __post_init__(self) -> None: + _require_instance(self.request_id, InstanceIdentifier, "request_id") + if self.request_id.instance_type != "analysis-authorization-request": + raise ValueError("request_id must identify an analysis-authorization-request") + _require_enum(self.analysis_class, AnalysisClass, "analysis_class") + _require_optional_instance(self.support, StatisticalSupport, "support") + _require_optional_instance(self.support_reference, RegistryReference, "support_reference") + if self.support is None and self.support_reference is None: + raise ValueError("analysis authorization requires exact support or support_reference") + _require_tuple_items(self.observations, ScientificMeasurementObservation, "observations") + _require_string_tuple(self.identity_hashes, "identity_hashes") + for item in self.identity_hashes: + _require_hash(item, "identity_hashes item") + _require_tuple_items( + self.comparability_decisions, + CrossSourceComparabilityDecision, + "comparability_decisions", + ) + _require_instance(self.requested_level, AnalysisLevelIdentity, "requested_level") + _require_optional_instance( + self.evidence_applicability, + ClaimEvidenceApplicability, + "evidence_applicability", + ) + _require_tuple_items(self.context_references, RegistryReference, "context_references") + _require_tuple_items(self.requested_parameters, MetadataEntry, "requested_parameters") + + @property + def request_hash(self) -> str: + return canonical_hash(self) + + @property + def support_hashes(self) -> tuple[str, ...]: + if self.support is None: + return () + return (self.support.canonical_support_hash,) + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class AnalysisAuthorization: + """Reproducible authorized/refused analysis-capability decision.""" + + authorization_id: InstanceIdentifier + status: AnalysisAuthorizationStatus + request_id: InstanceIdentifier + analysis_class: AnalysisClass + capability_reference: RegistryReference + capability_hash: str + operation_reference: RegistryReference | None + estimator_reference: RegistryReference | None + support_hashes: tuple[str, ...] + identity_hashes: tuple[str, ...] + comparability_hashes: tuple[str, ...] + resolved_level: AnalysisLevelIdentity + registry_version: str + software_version: str + reason_codes: tuple[str, ...] + missing_information: tuple[str, ...] + safe_descriptions: tuple[str, ...] + refusal_result: RefusalResult | None = None + authorization_hash: str | None = None + + def __post_init__(self) -> None: + _require_instance(self.authorization_id, InstanceIdentifier, "authorization_id") + if self.authorization_id.instance_type != "analysis-authorization": + raise ValueError("authorization_id must identify an analysis authorization") + _require_enum(self.status, AnalysisAuthorizationStatus, "status") + _require_instance(self.request_id, InstanceIdentifier, "request_id") + if self.request_id.instance_type != "analysis-authorization-request": + raise ValueError("request_id must identify an analysis authorization request") + _require_enum(self.analysis_class, AnalysisClass, "analysis_class") + _require_instance(self.capability_reference, RegistryReference, "capability_reference") + _require_hash(self.capability_hash, "capability_hash") + _require_optional_instance( + self.operation_reference, RegistryReference, "operation_reference" + ) + _require_optional_instance( + self.estimator_reference, RegistryReference, "estimator_reference" + ) + for field_name, values in ( + ("support_hashes", self.support_hashes), + ("identity_hashes", self.identity_hashes), + ("comparability_hashes", self.comparability_hashes), + ): + require_tuple(values, field_name) + for item in values: + _require_hash(item, f"{field_name} item") + _require_instance(self.resolved_level, AnalysisLevelIdentity, "resolved_level") + _require_text(self.registry_version, "registry_version") + _require_text(self.software_version, "software_version") + _require_string_tuple(self.reason_codes, "reason_codes") + _require_string_tuple(self.missing_information, "missing_information") + _require_string_tuple(self.safe_descriptions, "safe_descriptions") + _require_optional_instance(self.refusal_result, RefusalResult, "refusal_result") + if ( + self.status is AnalysisAuthorizationStatus.AUTHORIZED + and self.refusal_result is not None + ): + raise ValueError("authorized analysis cannot contain a refusal result") + if self.status is AnalysisAuthorizationStatus.REFUSED and self.refusal_result is None: + raise ValueError("refused analysis must retain its structured refusal result") + expected_hash = canonical_hash( + { + "status": self.status, + "request_id": self.request_id, + "analysis_class": self.analysis_class, + "capability_reference": self.capability_reference, + "capability_hash": self.capability_hash, + "operation_reference": self.operation_reference, + "estimator_reference": self.estimator_reference, + "support_hashes": self.support_hashes, + "identity_hashes": self.identity_hashes, + "comparability_hashes": self.comparability_hashes, + "resolved_level": self.resolved_level, + "registry_version": self.registry_version, + "software_version": self.software_version, + "reason_codes": self.reason_codes, + "missing_information": self.missing_information, + "safe_descriptions": self.safe_descriptions, + "refusal_result": self.refusal_result, + } + ) + if self.authorization_hash is None: + object.__setattr__(self, "authorization_hash", expected_hash) + elif self.authorization_hash != expected_hash: + raise ValueError("authorization_hash does not match immutable authorization content") + expected_id = InstanceIdentifier( + "analysis-authorization", expected_hash.removeprefix(_SHA256_PREFIX) + ) + if self.authorization_id != expected_id: + raise ValueError("authorization_id does not match immutable authorization content") + + @property + def canonical_authorization_hash(self) -> str: + assert self.authorization_hash is not None + return self.authorization_hash + + +__all__ = [ + "AnalysisAuthorization", + "AnalysisAuthorizationRequest", + "AnalysisAuthorizationStatus", + "AnalysisCapability", + "AnalysisCapabilityDisposition", + "AnalysisClass", + "AnalysisEstimandLevel", + "AnalysisKey", + "AnalysisLevelIdentity", + "AnalysisUnit", + "AnalysisUnitOfAnalysis", + "EstimandLevel", +] diff --git a/src/dynamislm/claims/__init__.py b/src/dynamislm/claims/__init__.py new file mode 100644 index 0000000..ffcc2a9 --- /dev/null +++ b/src/dynamislm/claims/__init__.py @@ -0,0 +1,21 @@ +"""RES-70 two-axis claim-authority contracts.""" + +from dynamislm.claims.models import ( + ClaimAuthorityResult, + ClaimAuthorityStatus, + ClaimIntent, + ClaimTarget, + MeasurementClaimLevel, + PredictionStatus, + RelationshipClaimLevel, +) + +__all__ = [ + "ClaimAuthorityResult", + "ClaimAuthorityStatus", + "ClaimIntent", + "ClaimTarget", + "MeasurementClaimLevel", + "PredictionStatus", + "RelationshipClaimLevel", +] diff --git a/src/dynamislm/claims/models.py b/src/dynamislm/claims/models.py new file mode 100644 index 0000000..92b83e0 --- /dev/null +++ b/src/dynamislm/claims/models.py @@ -0,0 +1,284 @@ +"""RES-70 two-axis claim-authority contracts.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum + +from dynamislm.analysis.models import AnalysisAuthorization +from dynamislm.comparability.res70_models import CrossSourceComparabilityDecision +from dynamislm.evidence.res70 import ClaimEvidenceApplicability +from dynamislm.longitudinal.statistics.models import StatisticalResult +from dynamislm.measurement.identity import ( + InstanceIdentifier, + RegistryReference, + _require_enum, + _require_instance, + _require_optional_instance, + _require_text, + _require_tuple_items, + require_tuple, +) +from dynamislm.measurement.observation import ScientificMeasurementObservation +from dynamislm.refusal.models import RefusalResult +from dynamislm.serialization import canonical_hash, register_serializable_type + +_SHA256_PREFIX = "sha256:" + + +def _require_hash(value: str, field_name: str) -> None: + _require_text(value, field_name) + digest = value.removeprefix(_SHA256_PREFIX) + if ( + not value.startswith(_SHA256_PREFIX) + or len(digest) != 64 + or any(character not in "0123456789abcdef" for character in digest) + ): + raise ValueError(f"{field_name} must be a canonical sha256 hash") + + +def _require_string_tuple(value: object, field_name: str) -> None: + require_tuple(value, field_name) + assert isinstance(value, tuple) + if any(not isinstance(item, str) or not item.strip() for item in value): + raise ValueError(f"{field_name} must contain non-empty strings") + + +class MeasurementClaimLevel(StrEnum): + OBSERVED_VALUE = "OBSERVED_VALUE" + NUMERICAL_CHANGE = "NUMERICAL_CHANGE" + COMPARABLE_CHANGE = "COMPARABLE_CHANGE" + CHANGE_RELATIVE_TO_MEASUREMENT_ERROR = "CHANGE_RELATIVE_TO_MEASUREMENT_ERROR" + PRACTICAL_OR_DECISION_MEANINGFULNESS = "PRACTICAL_OR_DECISION_MEANINGFULNESS" + + +class RelationshipClaimLevel(StrEnum): + OBSERVATION = "OBSERVATION" + DESCRIPTIVE_CHANGE = "DESCRIPTIVE_CHANGE" + ASSOCIATION = "ASSOCIATION" + TEMPORAL_ASSOCIATION = "TEMPORAL_ASSOCIATION" + MECHANISTIC_HYPOTHESIS = "MECHANISTIC_HYPOTHESIS" + CAUSAL_EVIDENCE = "CAUSAL_EVIDENCE" + + +class ClaimTarget(StrEnum): + INDIVIDUAL = "INDIVIDUAL" + ATHLETE_WITHIN = "ATHLETE_WITHIN" + ATHLETE_BETWEEN = "ATHLETE_BETWEEN" + GROUP = "GROUP" + POPULATION = "POPULATION" + + +class PredictionStatus(StrEnum): + NOT_REQUESTED = "NOT_REQUESTED" + AUTHORIZED = "AUTHORIZED" + REFUSED = "REFUSED" + + +class ClaimAuthorityStatus(StrEnum): + AUTHORIZED = "AUTHORIZED" + PARTIALLY_AUTHORIZED = "PARTIALLY_AUTHORIZED" + REFUSED = "REFUSED" + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class ClaimIntent: + """A typed request for one or both orthogonal claim axes.""" + + claim_reference: RegistryReference + measurement_level: MeasurementClaimLevel | None + relationship_level: RelationshipClaimLevel | None + predictive_intent: RegistryReference | None + target: ClaimTarget + analysis_reference: RegistryReference | None + evidence_applicability_reference: RegistryReference | None + decision_criterion_reference: RegistryReference | None + observations: tuple[ScientificMeasurementObservation, ...] = () + analysis_authorization: AnalysisAuthorization | None = None + comparability_decisions: tuple[CrossSourceComparabilityDecision, ...] = () + statistical_result: StatisticalResult | None = None + evidence_applicability: ClaimEvidenceApplicability | None = None + + def __post_init__(self) -> None: + _require_instance(self.claim_reference, RegistryReference, "claim_reference") + _require_optional_instance( + self.measurement_level, + MeasurementClaimLevel, + "measurement_level", + ) + _require_optional_instance( + self.relationship_level, + RelationshipClaimLevel, + "relationship_level", + ) + _require_optional_instance(self.predictive_intent, RegistryReference, "predictive_intent") + _require_enum(self.target, ClaimTarget, "target") + _require_optional_instance(self.analysis_reference, RegistryReference, "analysis_reference") + _require_optional_instance( + self.evidence_applicability_reference, + RegistryReference, + "evidence_applicability_reference", + ) + _require_optional_instance( + self.decision_criterion_reference, + RegistryReference, + "decision_criterion_reference", + ) + if ( + self.measurement_level is None + and self.relationship_level is None + and self.predictive_intent is None + ): + raise ValueError("claim intent must request at least one claim axis") + _require_tuple_items(self.observations, ScientificMeasurementObservation, "observations") + _require_optional_instance( + self.analysis_authorization, + AnalysisAuthorization, + "analysis_authorization", + ) + _require_tuple_items( + self.comparability_decisions, + CrossSourceComparabilityDecision, + "comparability_decisions", + ) + _require_optional_instance(self.statistical_result, StatisticalResult, "statistical_result") + _require_optional_instance( + self.evidence_applicability, + ClaimEvidenceApplicability, + "evidence_applicability", + ) + if self.evidence_applicability is not None: + if ( + self.evidence_applicability_reference is not None + and self.evidence_applicability.claim_intent_reference + != self.evidence_applicability_reference + ): + raise ValueError("evidence applicability reference does not match its bundle") + if self.analysis_authorization is not None and self.analysis_reference is not None: + if self.analysis_authorization.capability_reference != self.analysis_reference: + raise ValueError("analysis reference does not match its authorization") + + @property + def intent_hash(self) -> str: + return canonical_hash(self) + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class ClaimAuthorityResult: + """Reproducible allowed/blocked claim authority for both axes.""" + + decision_id: InstanceIdentifier + status: ClaimAuthorityStatus + claim_intent_reference: RegistryReference + allowed_measurement_levels: tuple[MeasurementClaimLevel, ...] + allowed_relationship_levels: tuple[RelationshipClaimLevel, ...] + prediction_status: PredictionStatus + blocked_claims: tuple[str, ...] + first_blocking_prerequisite: str | None + reason_codes: tuple[str, ...] + missing_information: tuple[str, ...] + safe_descriptions: tuple[str, ...] + support_hashes: tuple[str, ...] + analysis_hashes: tuple[str, ...] + comparability_hashes: tuple[str, ...] + bridge_hashes: tuple[str, ...] + evidence_applicability_hash: str | None + registry_version: str + software_version: str + refusal_result: RefusalResult | None = None + decision_hash: str | None = None + + def __post_init__(self) -> None: + _require_instance(self.decision_id, InstanceIdentifier, "decision_id") + if self.decision_id.instance_type != "claim-authority-decision": + raise ValueError("decision_id must identify a claim-authority-decision") + _require_enum(self.status, ClaimAuthorityStatus, "status") + _require_instance( + self.claim_intent_reference, + RegistryReference, + "claim_intent_reference", + ) + _require_tuple_items( + self.allowed_measurement_levels, + MeasurementClaimLevel, + "allowed_measurement_levels", + ) + _require_tuple_items( + self.allowed_relationship_levels, + RelationshipClaimLevel, + "allowed_relationship_levels", + ) + _require_enum(self.prediction_status, PredictionStatus, "prediction_status") + _require_string_tuple(self.blocked_claims, "blocked_claims") + if self.first_blocking_prerequisite is not None: + _require_text(self.first_blocking_prerequisite, "first_blocking_prerequisite") + _require_string_tuple(self.reason_codes, "reason_codes") + _require_string_tuple(self.missing_information, "missing_information") + _require_string_tuple(self.safe_descriptions, "safe_descriptions") + for field_name, values in ( + ("support_hashes", self.support_hashes), + ("analysis_hashes", self.analysis_hashes), + ("comparability_hashes", self.comparability_hashes), + ("bridge_hashes", self.bridge_hashes), + ): + require_tuple(values, field_name) + for item in values: + _require_hash(item, f"{field_name} item") + if self.evidence_applicability_hash is not None: + _require_hash(self.evidence_applicability_hash, "evidence_applicability_hash") + _require_text(self.registry_version, "registry_version") + _require_text(self.software_version, "software_version") + _require_optional_instance(self.refusal_result, RefusalResult, "refusal_result") + if self.status is ClaimAuthorityStatus.AUTHORIZED and self.refusal_result is not None: + raise ValueError("fully authorized claim cannot contain a refusal result") + if self.status is not ClaimAuthorityStatus.AUTHORIZED and self.refusal_result is None: + raise ValueError("blocked claim authority must retain a structured refusal result") + expected_hash = canonical_hash( + { + "status": self.status, + "claim_intent_reference": self.claim_intent_reference, + "allowed_measurement_levels": self.allowed_measurement_levels, + "allowed_relationship_levels": self.allowed_relationship_levels, + "prediction_status": self.prediction_status, + "blocked_claims": self.blocked_claims, + "first_blocking_prerequisite": self.first_blocking_prerequisite, + "reason_codes": self.reason_codes, + "missing_information": self.missing_information, + "safe_descriptions": self.safe_descriptions, + "support_hashes": self.support_hashes, + "analysis_hashes": self.analysis_hashes, + "comparability_hashes": self.comparability_hashes, + "bridge_hashes": self.bridge_hashes, + "evidence_applicability_hash": self.evidence_applicability_hash, + "registry_version": self.registry_version, + "software_version": self.software_version, + "refusal_result": self.refusal_result, + } + ) + if self.decision_hash is None: + object.__setattr__(self, "decision_hash", expected_hash) + elif self.decision_hash != expected_hash: + raise ValueError("decision_hash does not match immutable claim decision content") + expected_id = InstanceIdentifier( + "claim-authority-decision", expected_hash.removeprefix(_SHA256_PREFIX) + ) + if self.decision_id != expected_id: + raise ValueError("decision_id does not match immutable claim decision content") + + @property + def canonical_decision_hash(self) -> str: + assert self.decision_hash is not None + return self.decision_hash + + +__all__ = [ + "ClaimAuthorityResult", + "ClaimAuthorityStatus", + "ClaimIntent", + "ClaimTarget", + "MeasurementClaimLevel", + "PredictionStatus", + "RelationshipClaimLevel", +] diff --git a/src/dynamislm/comparability/__init__.py b/src/dynamislm/comparability/__init__.py index 29cf2dd..9746fce 100644 --- a/src/dynamislm/comparability/__init__.py +++ b/src/dynamislm/comparability/__init__.py @@ -13,15 +13,47 @@ ComparabilityState, TransformationRequest, ) +from dynamislm.comparability.res70_models import ( + BridgeApplicationRequest, + BridgeAuthorityOrigin, + BridgeExecutionResult, + BridgeExecutionStatus, + BridgeInvertibility, + BridgeMode, + BridgeRegistration, + ClaimContext, + ComparabilityDimension, + CrossSourceComparabilityDecision, + CrossSourceComparabilityRequest, + DimensionFinding, + DimensionFindingStatus, + ObservationAuthorityReference, + SemanticIdentityKey, +) __all__ = [ + "BridgeApplicationRequest", + "BridgeAuthorityOrigin", + "BridgeExecutionResult", + "BridgeExecutionStatus", + "BridgeInvertibility", + "BridgeMode", + "BridgeRegistration", + "ClaimContext", "ComparabilityAuthority", "ComparabilityAuthorityError", "ComparabilityDecisionSource", + "ComparabilityDimension", "ComparabilityReasonCode", "ComparabilityRequest", "ComparabilityResult", "ComparabilityState", + "CrossSourceComparabilityDecision", + "CrossSourceComparabilityRequest", + "DimensionFinding", + "DimensionFindingStatus", + "ObservationAuthorityReference", "RegisteredComparabilityRule", + "SemanticIdentityKey", "TransformationRequest", ] diff --git a/src/dynamislm/comparability/res70_models.py b/src/dynamislm/comparability/res70_models.py new file mode 100644 index 0000000..c729e85 --- /dev/null +++ b/src/dynamislm/comparability/res70_models.py @@ -0,0 +1,748 @@ +"""Immutable RES-70 cross-source comparability and bridge contracts. + +The contracts in this module deliberately carry references and hashes rather +than accepting caller-supplied scientific verdicts. Deterministic authority +is implemented in :mod:`res70_authority`; these objects only make the +authority inputs and outputs explicit and serializable. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum + +from dynamislm.comparability.models import ( + ComparabilityResult, + ComparabilityState, + TransformationRequest, +) +from dynamislm.measurement.identity import ( + InstanceIdentifier, + MeasurementIdentity, + MetadataEntry, + RegistryReference, + UnitReference, + _require_enum, + _require_instance, + _require_optional_instance, + _require_text, + _require_tuple_items, + require_tuple, +) +from dynamislm.measurement.observation import ScientificMeasurementObservation +from dynamislm.provenance.models import ProcessingRun, Provenance +from dynamislm.serialization import canonical_hash, register_serializable_type + +_SHA256_PREFIX = "sha256:" + + +def _require_hash(value: str, field_name: str) -> None: + _require_text(value, field_name) + digest = value.removeprefix(_SHA256_PREFIX) + if ( + not value.startswith(_SHA256_PREFIX) + or len(digest) != 64 + or any(character not in "0123456789abcdef" for character in digest) + ): + raise ValueError(f"{field_name} must be a canonical sha256 hash") + + +def _require_string_tuple(value: object, field_name: str) -> None: + require_tuple(value, field_name) + assert isinstance(value, tuple) + if any(not isinstance(item, str) or not item.strip() for item in value): + raise ValueError(f"{field_name} must contain non-empty strings") + + +def _require_reference_tuple(value: object, field_name: str) -> None: + _require_tuple_items(value, RegistryReference, field_name) + + +def _require_hash_tuple(value: object, field_name: str) -> None: + require_tuple(value, field_name) + assert isinstance(value, tuple) + for index, item in enumerate(value): + if not isinstance(item, str): + raise ValueError(f"{field_name} must contain strings") + _require_hash(item, f"{field_name}[{index}]") + + +def _stable_ref(value: RegistryReference | None) -> str: + return value.stable_id if value is not None else "" + + +def _identity_component_entries(identity: MeasurementIdentity) -> tuple[MetadataEntry, ...]: + """Return a reusable identity key without observation-instance identity. + + Raw artifact IDs are intentionally omitted. They identify a particular + acquisition instance, not a reusable scientific method/identity key. + """ + + semantic = identity.semantic + acquisition = identity.acquisition + processing = identity.processing + version = identity.version + entries: list[MetadataEntry] = [ + MetadataEntry("semantic.construct", semantic.construct.stable_id), + MetadataEntry("semantic.test_family", semantic.test_family.stable_id), + MetadataEntry("semantic.protocol", _stable_ref(semantic.protocol)), + MetadataEntry("semantic.measurand", semantic.measurand.stable_id), + MetadataEntry("semantic.metric_definition", semantic.metric_definition.stable_id), + MetadataEntry("acquisition.device", _stable_ref(acquisition.device)), + MetadataEntry("acquisition.sensor_channel", acquisition.sensor_channel), + MetadataEntry( + "acquisition.sampling", + canonical_hash(acquisition.sampling) if acquisition.sampling is not None else "", + ), + MetadataEntry( + "acquisition.calibration_reference", + _stable_ref(acquisition.calibration_reference), + ), + MetadataEntry("acquisition.hardware_firmware", _stable_ref(acquisition.hardware_firmware)), + MetadataEntry( + "processing.event_definitions", + canonical_hash(processing.event_definitions), + ), + MetadataEntry( + "processing.phase_definitions", + canonical_hash(processing.phase_definitions), + ), + MetadataEntry("processing.estimator", _stable_ref(processing.estimator)), + MetadataEntry( + "processing.registered_operation", + _stable_ref(processing.registered_operation), + ), + MetadataEntry( + "processing.method_parameters", + canonical_hash(processing.method_parameters), + ), + MetadataEntry("processing.filtering", canonical_hash(processing.filtering)), + MetadataEntry( + "processing.differentiation_method", + _stable_ref(processing.differentiation_method), + ), + MetadataEntry( + "processing.integration_method", + _stable_ref(processing.integration_method), + ), + MetadataEntry( + "processing.unit", + canonical_hash(processing.unit) if processing.unit is not None else "", + ), + MetadataEntry( + "processing.sign_convention", + canonical_hash(processing.sign_convention) + if processing.sign_convention is not None + else "", + ), + MetadataEntry( + "processing.normalization", + canonical_hash(processing.normalization) + if processing.normalization is not None + else "", + ), + MetadataEntry("processing.trial_selection", _stable_ref(processing.trial_selection)), + MetadataEntry("processing.aggregation", _stable_ref(processing.aggregation)), + MetadataEntry("version.processing_method", version.processing_method.stable_id), + MetadataEntry("version.method_registry_version", version.method_registry_version), + MetadataEntry("version.software_version", version.software_version), + MetadataEntry("version.hardware_firmware", _stable_ref(version.hardware_firmware)), + ] + return tuple(entries) + + +class ComparabilityDimension(StrEnum): + CONSTRUCT = "CONSTRUCT" + TEST_FAMILY = "TEST_FAMILY" + MEASURAND = "MEASURAND" + METRIC_DEFINITION = "METRIC_DEFINITION" + PROTOCOL = "PROTOCOL" + EVENT_DEFINITION = "EVENT_DEFINITION" + PHASE_DEFINITION = "PHASE_DEFINITION" + UNIT = "UNIT" + NORMALIZATION = "NORMALIZATION" + ESTIMATOR = "ESTIMATOR" + REGISTERED_PROCESSING_OPERATION = "REGISTERED_PROCESSING_OPERATION" + PROCESSING_PARAMETERS = "PROCESSING_PARAMETERS" + FILTERING_SMOOTHING_RESAMPLING = "FILTERING_SMOOTHING_RESAMPLING" + SAMPLING_AND_TIMEBASE = "SAMPLING_AND_TIMEBASE" + CALIBRATION_REFERENCE = "CALIBRATION_REFERENCE" + DEVICE_MEASURING_SYSTEM = "DEVICE_MEASURING_SYSTEM" + PROVIDER = "PROVIDER" + SOFTWARE_ALGORITHM_VERSION = "SOFTWARE_ALGORITHM_VERSION" + HARDWARE_FIRMWARE_VERSION = "HARDWARE_FIRMWARE_VERSION" + SIGN_CONVENTION_AND_REFERENCE_FRAME = "SIGN_CONVENTION_AND_REFERENCE_FRAME" + THRESHOLD_IDENTITY = "THRESHOLD_IDENTITY" + TRIAL_SELECTION_POLICY = "TRIAL_SELECTION_POLICY" + AGGREGATION_POLICY = "AGGREGATION_POLICY" + SESSION_SEGMENTATION = "SESSION_SEGMENTATION" + ACQUISITION_CONTEXT = "ACQUISITION_CONTEXT" + EXPOSURE_CONTEXT_MATCH_OR_TRAINING = "EXPOSURE_CONTEXT_MATCH_OR_TRAINING" + VALUE_ORIGIN = "VALUE_ORIGIN" + UNCERTAINTY_ERROR_MODEL = "UNCERTAINTY_ERROR_MODEL" + POPULATION_APPLICABILITY = "POPULATION_APPLICABILITY" + EVIDENCE_APPLICABILITY = "EVIDENCE_APPLICABILITY" + FOOTBALL_WORLD_CONTEXT = "FOOTBALL_WORLD_CONTEXT" + + +class DimensionFindingStatus(StrEnum): + MATCH = "MATCH" + MISMATCH = "MISMATCH" + UNKNOWN = "UNKNOWN" + BRIDGED = "BRIDGED" + NOT_APPLICABLE = "NOT_APPLICABLE" + + +class BridgeMode(StrEnum): + DECLARATIVE_EQUIVALENCE = "DECLARATIVE_EQUIVALENCE" + NUMERICAL_TRANSFORMATION = "NUMERICAL_TRANSFORMATION" + + +class BridgeInvertibility(StrEnum): + EXACT = "EXACT" + APPROXIMATE = "APPROXIMATE" + NON_INVERTIBLE_LOSSY = "NON_INVERTIBLE_LOSSY" + + +class BridgeAuthorityOrigin(StrEnum): + PRODUCTION = "PRODUCTION" + SYNTHETIC_TEST = "SYNTHETIC_TEST" + + +class BridgeExecutionStatus(StrEnum): + EXECUTED = "EXECUTED" + DECLARATIVE_APPLIED = "DECLARATIVE_APPLIED" + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class SemanticIdentityKey: + """Complete reusable identity key with instance-specific IDs excluded.""" + + components: tuple[MetadataEntry, ...] + + def __post_init__(self) -> None: + _require_tuple_items(self.components, MetadataEntry, "components") + keys = tuple(item.key for item in self.components) + if len(set(keys)) != len(keys): + raise ValueError("semantic identity key components must be unique") + for item in self.components: + value = item.value + if isinstance(value, str) and any( + value.startswith(prefix) + for prefix in ("observation:", "artifact:", "acquisition:", "session:") + ): + raise ValueError("semantic identity keys cannot contain observation-instance IDs") + + @classmethod + def from_identity(cls, identity: MeasurementIdentity) -> SemanticIdentityKey: + _require_instance(identity, MeasurementIdentity, "identity") + return cls(_identity_component_entries(identity)) + + @property + def canonical_hash(self) -> str: + return canonical_hash(self) + + def as_mapping(self) -> dict[str, object]: + return {item.key: item.value for item in self.components} + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class ObservationAuthorityReference: + """Exact observation/result/context/provenance hashes used by RES-70.""" + + observation_id: InstanceIdentifier + observation_hash: str + identity_hash: str + result_hash: str + context_hash: str + provenance_hash: str + + def __post_init__(self) -> None: + _require_instance(self.observation_id, InstanceIdentifier, "observation_id") + if self.observation_id.instance_type != "observation": + raise ValueError("observation_id must identify an observation") + for field_name in ( + "observation_hash", + "identity_hash", + "result_hash", + "context_hash", + "provenance_hash", + ): + _require_hash(getattr(self, field_name), field_name) + + @classmethod + def from_observation( + cls, observation: ScientificMeasurementObservation + ) -> ObservationAuthorityReference: + _require_instance(observation, ScientificMeasurementObservation, "observation") + return cls( + observation_id=observation.observation_id, + observation_hash=canonical_hash(observation), + identity_hash=canonical_hash(observation.identity), + result_hash=canonical_hash(observation.result), + context_hash=canonical_hash(observation.context), + provenance_hash=canonical_hash(observation.provenance), + ) + + def validate_observation(self, observation: ScientificMeasurementObservation) -> None: + expected = ObservationAuthorityReference.from_observation(observation) + if self != expected: + raise ValueError("observation authority reference does not match exact observation") + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class ClaimContext: + """Typed claim-relevant context kept outside MeasurementIdentity.""" + + context_reference: RegistryReference + context_kind: str + attributes: tuple[MetadataEntry, ...] = () + + def __post_init__(self) -> None: + _require_instance(self.context_reference, RegistryReference, "context_reference") + _require_text(self.context_kind, "context_kind") + _require_tuple_items(self.attributes, MetadataEntry, "attributes") + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class DimensionFinding: + """One deterministic finding for one material comparability dimension.""" + + dimension: ComparabilityDimension + status: DimensionFindingStatus + left_value: str | None = None + right_value: str | None = None + reason_codes: tuple[str, ...] = () + + def __post_init__(self) -> None: + _require_enum(self.dimension, ComparabilityDimension, "dimension") + _require_enum(self.status, DimensionFindingStatus, "status") + if self.left_value is not None: + _require_text(self.left_value, "left_value") + if self.right_value is not None: + _require_text(self.right_value, "right_value") + _require_string_tuple(self.reason_codes, "reason_codes") + if self.status is DimensionFindingStatus.MATCH and self.left_value != self.right_value: + raise ValueError("matching dimension findings must have equal values") + if self.status is DimensionFindingStatus.MISMATCH and ( + self.left_value is None or self.right_value is None + ): + raise ValueError("mismatching dimension findings must retain both values") + + @property + def left_reference(self) -> str | None: + return self.left_value + + @property + def right_reference(self) -> str | None: + return self.right_value + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class CrossSourceComparabilityRequest: + """Claim-relative pairwise cross-source comparability request.""" + + request_id: InstanceIdentifier + left_observation: ObservationAuthorityReference + right_observation: ObservationAuthorityReference + claim_intent: RegistryReference + requested_transformations: tuple[TransformationRequest, ...] = () + requested_dimensions: tuple[ComparabilityDimension, ...] = () + claim_context: ClaimContext | None = None + + def __post_init__(self) -> None: + _require_instance(self.request_id, InstanceIdentifier, "request_id") + if self.request_id.instance_type != "cross-source-comparability-request": + raise ValueError("request_id must identify a cross-source-comparability-request") + _require_instance(self.left_observation, ObservationAuthorityReference, "left_observation") + _require_instance( + self.right_observation, ObservationAuthorityReference, "right_observation" + ) + if self.left_observation.observation_id == self.right_observation.observation_id: + raise ValueError("cross-source comparability requires two distinct observations") + _require_instance(self.claim_intent, RegistryReference, "claim_intent") + _require_tuple_items( + self.requested_transformations, + TransformationRequest, + "requested_transformations", + ) + require_tuple(self.requested_dimensions, "requested_dimensions") + if any(not isinstance(item, ComparabilityDimension) for item in self.requested_dimensions): + raise ValueError("requested_dimensions must contain ComparabilityDimension values") + if len(set(self.requested_dimensions)) != len(self.requested_dimensions): + raise ValueError("requested_dimensions must not contain duplicates") + if self.claim_context is not None: + _require_instance(self.claim_context, ClaimContext, "claim_context") + + @property + def request_hash(self) -> str: + return canonical_hash(self) + + @property + def observation_ids(self) -> tuple[InstanceIdentifier, InstanceIdentifier]: + return ( + self.left_observation.observation_id, + self.right_observation.observation_id, + ) + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class CrossSourceComparabilityDecision: + """Pairwise deterministic decision with exact input and registry bindings.""" + + decision_id: InstanceIdentifier + request_hash: str + state: ComparabilityState + dimension_findings: tuple[DimensionFinding, ...] + conditions: tuple[str, ...] + transformations_required: tuple[TransformationRequest, ...] + bridge_application_reference: RegistryReference | None + rule_reference: RegistryReference | None + evidence_references: tuple[RegistryReference, ...] + registry_version: str + registry_hash: str + left_observation: ObservationAuthorityReference + right_observation: ObservationAuthorityReference + reason_codes: tuple[str, ...] = () + missing_information: tuple[str, ...] = () + leaf_result: ComparabilityResult | None = None + decision_hash: str | None = None + + def __post_init__(self) -> None: + _require_instance(self.decision_id, InstanceIdentifier, "decision_id") + if self.decision_id.instance_type != "cross-source-comparability-decision": + raise ValueError("decision_id must identify a cross-source-comparability-decision") + _require_hash(self.request_hash, "request_hash") + _require_enum(self.state, ComparabilityState, "state") + _require_tuple_items(self.dimension_findings, DimensionFinding, "dimension_findings") + _require_string_tuple(self.conditions, "conditions") + _require_tuple_items( + self.transformations_required, + TransformationRequest, + "transformations_required", + ) + _require_optional_instance( + self.bridge_application_reference, + RegistryReference, + "bridge_application_reference", + ) + _require_optional_instance(self.rule_reference, RegistryReference, "rule_reference") + _require_reference_tuple(self.evidence_references, "evidence_references") + _require_text(self.registry_version, "registry_version") + _require_hash(self.registry_hash, "registry_hash") + _require_instance(self.left_observation, ObservationAuthorityReference, "left_observation") + _require_instance( + self.right_observation, + ObservationAuthorityReference, + "right_observation", + ) + if self.left_observation.observation_id == self.right_observation.observation_id: + raise ValueError("decision observations must be distinct") + _require_string_tuple(self.reason_codes, "reason_codes") + _require_string_tuple(self.missing_information, "missing_information") + _require_optional_instance(self.leaf_result, ComparabilityResult, "leaf_result") + if self.state is ComparabilityState.COMPARABLE_WITH_CONDITIONS and not self.conditions: + raise ValueError("conditional comparability must retain explicit conditions") + if self.state is ComparabilityState.REQUIRES_TRANSFORMATION and not ( + self.transformations_required + ): + raise ValueError("transformation-required decision must name transformations") + expected_hash = self._content_hash() + if self.decision_hash is None: + object.__setattr__(self, "decision_hash", expected_hash) + elif self.decision_hash != expected_hash: + raise ValueError("decision_hash does not match immutable decision content") + expected_id = InstanceIdentifier( + "cross-source-comparability-decision", + expected_hash.removeprefix(_SHA256_PREFIX), + ) + if self.decision_id != expected_id: + raise ValueError("decision_id does not match immutable decision content") + + def _content_hash(self) -> str: + return canonical_hash( + { + "request_hash": self.request_hash, + "state": self.state, + "dimension_findings": self.dimension_findings, + "conditions": self.conditions, + "transformations_required": self.transformations_required, + "bridge_application_reference": self.bridge_application_reference, + "rule_reference": self.rule_reference, + "evidence_references": self.evidence_references, + "registry_version": self.registry_version, + "registry_hash": self.registry_hash, + "left_observation": self.left_observation, + "right_observation": self.right_observation, + "reason_codes": self.reason_codes, + "missing_information": self.missing_information, + "leaf_result": self.leaf_result, + } + ) + + @property + def canonical_decision_hash(self) -> str: + assert self.decision_hash is not None + return self.decision_hash + + @property + def pair_key(self) -> frozenset[str]: + return frozenset( + ( + self.left_observation.observation_id.qualified, + self.right_observation.observation_id.qualified, + ) + ) + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class BridgeRegistration: + """Immutable declaration of a claim-scoped bridge or transformation.""" + + bridge_reference: RegistryReference + source_semantic_key: SemanticIdentityKey + target_semantic_key: SemanticIdentityKey + claim_scope: tuple[RegistryReference, ...] + bridge_mode: BridgeMode + transformation_operation: RegistryReference | None + source_units: tuple[UnitReference, ...] + target_units: tuple[UnitReference, ...] + fixed_parameters: tuple[MetadataEntry, ...] + domain_constraints: tuple[MetadataEntry, ...] + applicability_conditions: tuple[MetadataEntry, ...] + method_version: RegistryReference + evidence_references: tuple[RegistryReference, ...] + evidence_applicability: tuple[RegistryReference, ...] + uncertainty_model: RegistryReference | None + invertibility: BridgeInvertibility + lossiness_description: str | None + provenance_rule: RegistryReference + registry_version: str + authority_origin: BridgeAuthorityOrigin = BridgeAuthorityOrigin.PRODUCTION + bridge_hash: str | None = None + + def __post_init__(self) -> None: + _require_instance(self.bridge_reference, RegistryReference, "bridge_reference") + if self.bridge_reference.identifier.object_type != "comparability-bridge": + raise ValueError("bridge_reference must identify a comparability bridge") + _require_instance(self.source_semantic_key, SemanticIdentityKey, "source_semantic_key") + _require_instance(self.target_semantic_key, SemanticIdentityKey, "target_semantic_key") + if self.source_semantic_key == self.target_semantic_key: + raise ValueError("bridge source and target semantic keys must differ") + _require_reference_tuple(self.claim_scope, "claim_scope") + if not self.claim_scope: + raise ValueError("bridge claim_scope must not be empty") + _require_enum(self.bridge_mode, BridgeMode, "bridge_mode") + _require_optional_instance( + self.transformation_operation, + RegistryReference, + "transformation_operation", + ) + if self.bridge_mode is BridgeMode.NUMERICAL_TRANSFORMATION: + if self.transformation_operation is None: + raise ValueError("numerical bridges require a transformation operation") + if ( + self.uncertainty_model is None + and self.invertibility is not BridgeInvertibility.EXACT + ): + raise ValueError("lossy numerical bridges require an uncertainty model") + _require_tuple_items(self.source_units, UnitReference, "source_units") + _require_tuple_items(self.target_units, UnitReference, "target_units") + _require_tuple_items(self.fixed_parameters, MetadataEntry, "fixed_parameters") + _require_tuple_items(self.domain_constraints, MetadataEntry, "domain_constraints") + _require_tuple_items( + self.applicability_conditions, + MetadataEntry, + "applicability_conditions", + ) + _require_instance(self.method_version, RegistryReference, "method_version") + _require_reference_tuple(self.evidence_references, "evidence_references") + _require_reference_tuple(self.evidence_applicability, "evidence_applicability") + if ( + self.authority_origin is BridgeAuthorityOrigin.PRODUCTION + and not self.evidence_references + ): + raise ValueError("production bridges require evidence references") + _require_optional_instance(self.uncertainty_model, RegistryReference, "uncertainty_model") + _require_enum(self.invertibility, BridgeInvertibility, "invertibility") + if self.invertibility is BridgeInvertibility.NON_INVERTIBLE_LOSSY: + _require_text(self.lossiness_description or "", "lossiness_description") + elif self.lossiness_description is not None: + _require_text(self.lossiness_description, "lossiness_description") + _require_instance(self.provenance_rule, RegistryReference, "provenance_rule") + _require_text(self.registry_version, "registry_version") + _require_enum(self.authority_origin, BridgeAuthorityOrigin, "authority_origin") + expected_hash = self._content_hash() + if self.bridge_hash is None: + object.__setattr__(self, "bridge_hash", expected_hash) + elif self.bridge_hash != expected_hash: + raise ValueError("bridge_hash does not match immutable bridge content") + + def _content_hash(self) -> str: + return canonical_hash( + { + "bridge_reference": self.bridge_reference, + "source_semantic_key": self.source_semantic_key, + "target_semantic_key": self.target_semantic_key, + "claim_scope": self.claim_scope, + "bridge_mode": self.bridge_mode, + "transformation_operation": self.transformation_operation, + "source_units": self.source_units, + "target_units": self.target_units, + "fixed_parameters": self.fixed_parameters, + "domain_constraints": self.domain_constraints, + "applicability_conditions": self.applicability_conditions, + "method_version": self.method_version, + "evidence_references": self.evidence_references, + "evidence_applicability": self.evidence_applicability, + "uncertainty_model": self.uncertainty_model, + "invertibility": self.invertibility, + "lossiness_description": self.lossiness_description, + "provenance_rule": self.provenance_rule, + "registry_version": self.registry_version, + "authority_origin": self.authority_origin, + } + ) + + @property + def canonical_bridge_hash(self) -> str: + assert self.bridge_hash is not None + return self.bridge_hash + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class BridgeApplicationRequest: + """Request to resolve and, for numerical bridges, execute one bridge.""" + + request_id: InstanceIdentifier + source_observation: ObservationAuthorityReference + bridge_reference: RegistryReference + claim_intent: RegistryReference + requested_parameters: tuple[MetadataEntry, ...] = () + + def __post_init__(self) -> None: + _require_instance(self.request_id, InstanceIdentifier, "request_id") + if self.request_id.instance_type != "bridge-request": + raise ValueError("request_id must identify a bridge request") + _require_instance( + self.source_observation, ObservationAuthorityReference, "source_observation" + ) + _require_instance(self.bridge_reference, RegistryReference, "bridge_reference") + _require_instance(self.claim_intent, RegistryReference, "claim_intent") + _require_tuple_items(self.requested_parameters, MetadataEntry, "requested_parameters") + + @property + def request_hash(self) -> str: + return canonical_hash(self) + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class BridgeExecutionResult: + """Provenance-bound result of an executed or declarative bridge.""" + + execution_id: InstanceIdentifier + status: BridgeExecutionStatus + request_hash: str + bridge_reference: RegistryReference + bridge_hash: str + source_observation: ObservationAuthorityReference + transformed_observation: ScientificMeasurementObservation | None + processing_run: ProcessingRun | None + provenance: Provenance | None + output_observation_hash: str | None + uncertainty_model: RegistryReference | None + lossiness_description: str | None + execution_hash: str | None = None + + def __post_init__(self) -> None: + _require_instance(self.execution_id, InstanceIdentifier, "execution_id") + if self.execution_id.instance_type != "bridge-execution": + raise ValueError("execution_id must identify a bridge execution") + _require_enum(self.status, BridgeExecutionStatus, "status") + _require_hash(self.request_hash, "request_hash") + _require_instance(self.bridge_reference, RegistryReference, "bridge_reference") + _require_hash(self.bridge_hash, "bridge_hash") + _require_instance( + self.source_observation, ObservationAuthorityReference, "source_observation" + ) + _require_optional_instance( + self.transformed_observation, + ScientificMeasurementObservation, + "transformed_observation", + ) + _require_optional_instance(self.processing_run, ProcessingRun, "processing_run") + _require_optional_instance(self.provenance, Provenance, "provenance") + if self.output_observation_hash is not None: + _require_hash(self.output_observation_hash, "output_observation_hash") + _require_optional_instance(self.uncertainty_model, RegistryReference, "uncertainty_model") + if self.lossiness_description is not None: + _require_text(self.lossiness_description, "lossiness_description") + if self.status is BridgeExecutionStatus.EXECUTED: + if ( + self.transformed_observation is None + or self.processing_run is None + or self.provenance is None + or self.output_observation_hash is None + ): + raise ValueError("executed bridge result must contain transformed provenance") + if canonical_hash(self.transformed_observation) != self.output_observation_hash: + raise ValueError("output_observation_hash does not match transformed observation") + elif self.transformed_observation is not None: + raise ValueError("declarative bridge result cannot contain a transformed observation") + expected_hash = canonical_hash( + { + "request_hash": self.request_hash, + "bridge_reference": self.bridge_reference, + "bridge_hash": self.bridge_hash, + "source_observation": self.source_observation, + "transformed_observation": self.transformed_observation, + "processing_run": self.processing_run, + "provenance": self.provenance, + "output_observation_hash": self.output_observation_hash, + "uncertainty_model": self.uncertainty_model, + "lossiness_description": self.lossiness_description, + "status": self.status, + } + ) + if self.execution_hash is None: + object.__setattr__(self, "execution_hash", expected_hash) + elif self.execution_hash != expected_hash: + raise ValueError("execution_hash does not match immutable bridge execution") + expected_id = InstanceIdentifier( + "bridge-execution", expected_hash.removeprefix(_SHA256_PREFIX) + ) + if self.execution_id != expected_id: + raise ValueError("execution_id does not match immutable bridge execution") + + @property + def canonical_execution_hash(self) -> str: + assert self.execution_hash is not None + return self.execution_hash + + +__all__ = [ + "BridgeApplicationRequest", + "BridgeAuthorityOrigin", + "BridgeExecutionResult", + "BridgeExecutionStatus", + "BridgeInvertibility", + "BridgeMode", + "BridgeRegistration", + "ClaimContext", + "ComparabilityDimension", + "CrossSourceComparabilityDecision", + "CrossSourceComparabilityRequest", + "DimensionFinding", + "DimensionFindingStatus", + "ObservationAuthorityReference", + "SemanticIdentityKey", +] diff --git a/src/dynamislm/evidence/__init__.py b/src/dynamislm/evidence/__init__.py index fb62527..9cbf2eb 100644 --- a/src/dynamislm/evidence/__init__.py +++ b/src/dynamislm/evidence/__init__.py @@ -5,5 +5,21 @@ EvidenceApplicability, EvidenceDecisionRecord, ) +from dynamislm.evidence.res70 import ( + ApplicabilityAssessment, + ApplicabilityAxis, + ClaimEvidenceApplicability, + build_claim_evidence_applicability, + validate_claim_evidence_applicability, +) -__all__ = ["ApplicabilityDecision", "EvidenceApplicability", "EvidenceDecisionRecord"] +__all__ = [ + "ApplicabilityAssessment", + "ApplicabilityAxis", + "ApplicabilityDecision", + "ClaimEvidenceApplicability", + "EvidenceApplicability", + "EvidenceDecisionRecord", + "build_claim_evidence_applicability", + "validate_claim_evidence_applicability", +] diff --git a/src/dynamislm/evidence/res70.py b/src/dynamislm/evidence/res70.py new file mode 100644 index 0000000..d06ec91 --- /dev/null +++ b/src/dynamislm/evidence/res70.py @@ -0,0 +1,159 @@ +"""RES-70 claim-relative evidence applicability vector.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum + +from dynamislm.evidence.models import ApplicabilityDecision +from dynamislm.measurement.identity import ( + RegistryReference, + _require_enum, + _require_instance, + _require_text, + _require_tuple_items, + require_tuple, +) +from dynamislm.serialization import canonical_hash, register_serializable_type + + +def _require_string_tuple(value: object, field_name: str) -> None: + require_tuple(value, field_name) + assert isinstance(value, tuple) + if any(not isinstance(item, str) or not item.strip() for item in value): + raise ValueError(f"{field_name} must contain non-empty strings") + + +class ApplicabilityAxis(StrEnum): + METHOD_VALIDITY = "METHOD_VALIDITY" + SOURCE_QUALITY = "SOURCE_QUALITY" + POPULATION_RELEVANCE = "POPULATION_RELEVANCE" + CONTEXTUAL_RELEVANCE = "CONTEXTUAL_RELEVANCE" + STATISTICAL_ADEQUACY = "STATISTICAL_ADEQUACY" + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class ApplicabilityAssessment: + """One independent evidence-applicability judgment.""" + + axis: ApplicabilityAxis + decision: ApplicabilityDecision + required_for_claim: bool + evidence_references: tuple[RegistryReference, ...] + authority_references: tuple[RegistryReference, ...] + conditions: tuple[str, ...] + rationale: str + + def __post_init__(self) -> None: + _require_enum(self.axis, ApplicabilityAxis, "axis") + _require_enum(self.decision, ApplicabilityDecision, "decision") + if not isinstance(self.required_for_claim, bool): + raise ValueError("required_for_claim must be a boolean") + _require_tuple_items(self.evidence_references, RegistryReference, "evidence_references") + _require_tuple_items( + self.authority_references, + RegistryReference, + "authority_references", + ) + _require_string_tuple(self.conditions, "conditions") + _require_text(self.rationale, "rationale") + if self.required_for_claim and self.decision is ApplicabilityDecision.UNASSESSED: + raise ValueError("a required applicability axis cannot be unassessed") + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class ClaimEvidenceApplicability: + """Orthogonal method/source/population/context/statistical applicability.""" + + claim_intent_reference: RegistryReference + assessments: tuple[ApplicabilityAssessment, ...] + registry_version: str + applicability_hash: str | None = None + + def __post_init__(self) -> None: + _require_instance( + self.claim_intent_reference, + RegistryReference, + "claim_intent_reference", + ) + _require_tuple_items(self.assessments, ApplicabilityAssessment, "assessments") + if not self.assessments: + raise ValueError("claim evidence applicability requires assessments") + axes = tuple(item.axis for item in self.assessments) + if len(set(axes)) != len(axes): + raise ValueError("claim evidence applicability requires one assessment per axis") + _require_text(self.registry_version, "registry_version") + expected_hash = canonical_hash( + { + "claim_intent_reference": self.claim_intent_reference, + "assessments": self.assessments, + "registry_version": self.registry_version, + } + ) + if self.applicability_hash is None: + object.__setattr__(self, "applicability_hash", expected_hash) + elif self.applicability_hash != expected_hash: + raise ValueError("applicability_hash does not match immutable applicability content") + + @property + def canonical_applicability_hash(self) -> str: + assert self.applicability_hash is not None + return self.applicability_hash + + def assessment(self, axis: ApplicabilityAxis) -> ApplicabilityAssessment: + for item in self.assessments: + if item.axis is axis: + return item + raise KeyError(axis) + + def required_axes(self) -> tuple[ApplicabilityAxis, ...]: + return tuple(item.axis for item in self.assessments if item.required_for_claim) + + def supports_required_axes(self) -> bool: + return all( + item.decision in (ApplicabilityDecision.SUPPORTED, ApplicabilityDecision.LIMITED) + for item in self.assessments + if item.required_for_claim + ) + + +def build_claim_evidence_applicability( + claim_intent_reference: RegistryReference, + assessments: tuple[ApplicabilityAssessment, ...], + *, + registry_version: str = "res70-1.0.0", +) -> ClaimEvidenceApplicability: + """Construct the canonical vector without collapsing its dimensions.""" + + return ClaimEvidenceApplicability( + claim_intent_reference=claim_intent_reference, + assessments=assessments, + registry_version=registry_version, + ) + + +def validate_claim_evidence_applicability(value: ClaimEvidenceApplicability) -> None: + """Recompute the applicability hash and reject tampering.""" + + if not isinstance(value, ClaimEvidenceApplicability): + raise ValueError("value must be a ClaimEvidenceApplicability") + expected = canonical_hash( + { + "claim_intent_reference": value.claim_intent_reference, + "assessments": value.assessments, + "registry_version": value.registry_version, + } + ) + if value.canonical_applicability_hash != expected: + raise ValueError("applicability hash does not match immutable content") + + +__all__ = [ + "ApplicabilityAssessment", + "ApplicabilityAxis", + "ClaimEvidenceApplicability", + "build_claim_evidence_applicability", + "validate_claim_evidence_applicability", +] diff --git a/src/dynamislm/refusal/models.py b/src/dynamislm/refusal/models.py index e7c88d8..6e325eb 100644 --- a/src/dynamislm/refusal/models.py +++ b/src/dynamislm/refusal/models.py @@ -190,6 +190,25 @@ class RefusalReasonCode(StrEnum): UNKNOWN_INTERPOLATION = "UNKNOWN_INTERPOLATION" UNKNOWN_SMOOTHING = "UNKNOWN_SMOOTHING" UNKNOWN_RESAMPLING = "UNKNOWN_RESAMPLING" + RES70_UNRESOLVED_IDENTITY = "RES70_UNRESOLVED_IDENTITY" + RES70_COMPARABILITY_AUTHORITY_MISSING = "RES70_COMPARABILITY_AUTHORITY_MISSING" + RES70_BRIDGE_REQUIRED = "RES70_BRIDGE_REQUIRED" + RES70_BRIDGE_NOT_EXECUTED = "RES70_BRIDGE_NOT_EXECUTED" + RES70_BRIDGE_EXECUTION_FAILED = "RES70_BRIDGE_EXECUTION_FAILED" + RES70_BRIDGE_CONDITIONS_UNSATISFIED = "RES70_BRIDGE_CONDITIONS_UNSATISFIED" + RES70_INCOMPATIBLE_CONTEXT = "RES70_INCOMPATIBLE_CONTEXT" + RES70_NORMALIZATION_MISMATCH = "RES70_NORMALIZATION_MISMATCH" + RES70_METHOD_VERSION_MISMATCH = "RES70_METHOD_VERSION_MISMATCH" + RES70_THRESHOLD_IDENTITY_MISMATCH = "RES70_THRESHOLD_IDENTITY_MISMATCH" + RES70_STATISTICAL_AUTHORITY_INSUFFICIENT = "RES70_STATISTICAL_AUTHORITY_INSUFFICIENT" + RES70_WRONG_LEVEL_OF_ANALYSIS = "RES70_WRONG_LEVEL_OF_ANALYSIS" + RES70_PSEUDOREPLICATION_RISK = "RES70_PSEUDOREPLICATION_RISK" + RES70_UNSUPPORTED_CLAIM_ESCALATION = "RES70_UNSUPPORTED_CLAIM_ESCALATION" + RES70_UNSUPPORTED_CAUSAL_CLAIM = "RES70_UNSUPPORTED_CAUSAL_CLAIM" + RES70_INSUFFICIENT_EVIDENCE_APPLICABILITY = "RES70_INSUFFICIENT_EVIDENCE_APPLICABILITY" + RES70_APPLICABILITY_AXIS_UNASSESSED = "RES70_APPLICABILITY_AXIS_UNASSESSED" + RES70_TRANSITIVITY_NOT_ESTABLISHED = "RES70_TRANSITIVITY_NOT_ESTABLISHED" + RES70_REGISTRY_INTEGRITY_FAILURE = "RES70_REGISTRY_INTEGRITY_FAILURE" class RefusalStatus(StrEnum): diff --git a/tests/test_res70_models.py b/tests/test_res70_models.py new file mode 100644 index 0000000..0d5406f --- /dev/null +++ b/tests/test_res70_models.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +from dynamislm import ( + AnalysisCapability, + AnalysisCapabilityDisposition, + AnalysisClass, + AnalysisEstimandLevel, + AnalysisLevelIdentity, + AnalysisUnitOfAnalysis, + ApplicabilityAssessment, + ApplicabilityAxis, + ApplicabilityDecision, + BridgeAuthorityOrigin, + BridgeInvertibility, + BridgeMode, + BridgeRegistration, + ClaimIntent, + ClaimTarget, + ComparabilityDimension, + CrossSourceComparabilityRequest, + InstanceIdentifier, + MeasurementClaimLevel, + MetadataEntry, + ObservationAuthorityReference, + RegistryReference, + ScientificIdentifier, + SemanticIdentityKey, + canonical_hash, + canonical_json, + from_canonical_json, +) +from test_kernel import _derived_observation + + +def _reference(object_type: str, key: str, label: str) -> RegistryReference: + return RegistryReference(ScientificIdentifier("dynamislm", object_type, key, "1.0.0"), label) + + +def test_observation_authority_reference_binds_all_component_hashes() -> None: + observation = _derived_observation("res70-reference") + reference = ObservationAuthorityReference.from_observation(observation) + + assert reference.observation_hash == canonical_hash(observation) + assert reference.identity_hash == canonical_hash(observation.identity) + reference.validate_observation(observation) + + restored = from_canonical_json(canonical_json(reference), ObservationAuthorityReference) + assert restored == reference + + +def test_cross_source_request_is_pairwise_and_label_agnostic() -> None: + left = _derived_observation("res70-left") + right = _derived_observation("res70-right") + request = CrossSourceComparabilityRequest( + request_id=InstanceIdentifier("cross-source-comparability-request", "request-1"), + left_observation=ObservationAuthorityReference.from_observation(left), + right_observation=ObservationAuthorityReference.from_observation(right), + claim_intent=_reference("claim-intent", "longitudinal-change", "longitudinal change"), + requested_dimensions=(ComparabilityDimension.MEASURAND, ComparabilityDimension.UNIT), + ) + + assert request.left_observation.observation_id != request.right_observation.observation_id + assert request.request_hash == canonical_hash(request) + assert from_canonical_json(canonical_json(request), CrossSourceComparabilityRequest) == request + + +def test_semantic_bridge_keys_exclude_observation_instances() -> None: + observation = _derived_observation("res70-key") + key = SemanticIdentityKey.from_identity(observation.identity) + + assert all( + not any( + isinstance(item.value, str) and item.value.startswith(prefix) + for prefix in ("observation:", "artifact:", "acquisition:", "session:") + ) + for item in key.components + ) + assert key == SemanticIdentityKey.from_identity(observation.identity) + + +def test_production_bridge_contract_is_versioned_and_hashable() -> None: + observation = _derived_observation("res70-bridge") + source_key = SemanticIdentityKey.from_identity(observation.identity) + target_key = SemanticIdentityKey( + tuple(MetadataEntry(item.key, f"target:{item.value}") for item in source_key.components) + ) + bridge = BridgeRegistration( + bridge_reference=_reference("comparability-bridge", "example-v1", "Example bridge"), + source_semantic_key=source_key, + target_semantic_key=target_key, + claim_scope=(_reference("claim-intent", "longitudinal-change", "longitudinal change"),), + bridge_mode=BridgeMode.DECLARATIVE_EQUIVALENCE, + transformation_operation=None, + source_units=(), + target_units=(), + fixed_parameters=(), + domain_constraints=(MetadataEntry("domain", "synthetic-test"),), + applicability_conditions=(MetadataEntry("condition", "validated domain"),), + method_version=_reference("bridge-method", "example-v1", "Example bridge method"), + evidence_references=(_reference("evidence", "example", "Example evidence"),), + evidence_applicability=(), + uncertainty_model=None, + invertibility=BridgeInvertibility.EXACT, + lossiness_description=None, + provenance_rule=_reference("provenance-rule", "bridge-v1", "Bridge provenance"), + registry_version="res70-1.0.0", + authority_origin=BridgeAuthorityOrigin.SYNTHETIC_TEST, + ) + + assert bridge.canonical_bridge_hash == canonical_hash( + { + "bridge_reference": bridge.bridge_reference, + "source_semantic_key": bridge.source_semantic_key, + "target_semantic_key": bridge.target_semantic_key, + "claim_scope": bridge.claim_scope, + "bridge_mode": bridge.bridge_mode, + "transformation_operation": bridge.transformation_operation, + "source_units": bridge.source_units, + "target_units": bridge.target_units, + "fixed_parameters": bridge.fixed_parameters, + "domain_constraints": bridge.domain_constraints, + "applicability_conditions": bridge.applicability_conditions, + "method_version": bridge.method_version, + "evidence_references": bridge.evidence_references, + "evidence_applicability": bridge.evidence_applicability, + "uncertainty_model": bridge.uncertainty_model, + "invertibility": bridge.invertibility, + "lossiness_description": bridge.lossiness_description, + "provenance_rule": bridge.provenance_rule, + "registry_version": bridge.registry_version, + "authority_origin": bridge.authority_origin, + } + ) + assert from_canonical_json(canonical_json(bridge), BridgeRegistration) == bridge + + +def test_analysis_level_and_capability_preserve_estimand_identity() -> None: + level = AnalysisLevelIdentity( + unit_of_analysis=AnalysisUnitOfAnalysis.ATHLETE, + estimand_level=AnalysisEstimandLevel.WITHIN_ATHLETE, + subject_key="athlete_id", + clustering_keys=("athlete_id", "session_id"), + grouping_keys=(), + ) + capability = AnalysisCapability( + capability_reference=_reference("analysis-capability", "scalar-change", "Scalar change"), + analysis_class=AnalysisClass.SCALAR_ABSOLUTE_CHANGE, + registered_operation_reference=_reference( + "registered-operation", "absolute-change", "Absolute change" + ), + estimator_reference=_reference("estimator", "absolute-change", "Absolute change"), + disposition=AnalysisCapabilityDisposition.IMPLEMENTED, + required_support_shape=("exactly-two-scalar-entries",), + required_identity_dimensions=("MEASURAND", "UNIT"), + required_comparability_states=(), + required_bridge_execution=False, + required_level_of_analysis=(AnalysisEstimandLevel.WITHIN_ATHLETE,), + required_statistical_authority=(), + required_evidence_axes=(), + required_context=(), + output_claim_floor="NUMERICAL_CHANGE", + registry_version="res70-1.0.0", + ) + + assert level.level_key == "ATHLETE:WITHIN_ATHLETE" + assert capability.canonical_capability_hash.startswith("sha256:") + assert from_canonical_json(canonical_json(level), AnalysisLevelIdentity) == level + + +def test_evidence_applicability_is_a_vector_without_confidence_collapse() -> None: + claim = _reference("claim-intent", "observed", "Observed value") + applicability = tuple( + ApplicabilityAssessment( + axis=axis, + decision=ApplicabilityDecision.SUPPORTED, + required_for_claim=True, + evidence_references=(_reference("evidence", axis.value.lower(), axis.value),), + authority_references=(), + conditions=(), + rationale=f"synthetic {axis.value}", + ) + for axis in ApplicabilityAxis + ) + intent = ClaimIntent( + claim_reference=claim, + measurement_level=MeasurementClaimLevel.OBSERVED_VALUE, + relationship_level=None, + predictive_intent=None, + target=ClaimTarget.INDIVIDUAL, + analysis_reference=None, + evidence_applicability_reference=None, + decision_criterion_reference=None, + ) + + assert len(applicability) == 5 + assert not hasattr(intent, "confidence_score") + assert all(item.required_for_claim for item in applicability) From 6975e4060cd5f327cfec1a50cc9aedddc6d37591 Mon Sep 17 00:00:00 2001 From: Julio Rodriguez <144072916+Litju@users.noreply.github.com> Date: Fri, 18 Sep 2026 19:41:20 +0000 Subject: [PATCH 03/18] feat(res70): implement comparability and bridge authority --- src/dynamislm/__init__.py | 50 + src/dynamislm/comparability/__init__.py | 58 ++ .../comparability/res70_authority.py | 862 ++++++++++++++++++ src/dynamislm/comparability/res70_models.py | 65 ++ src/dynamislm/comparability/res70_registry.py | 250 +++++ .../comparability/res70_validation.py | 256 ++++++ tests/test_res70_bridges.py | 179 ++++ tests/test_res70_comparability.py | 145 +++ 8 files changed, 1865 insertions(+) create mode 100644 src/dynamislm/comparability/res70_authority.py create mode 100644 src/dynamislm/comparability/res70_registry.py create mode 100644 src/dynamislm/comparability/res70_validation.py create mode 100644 tests/test_res70_bridges.py create mode 100644 tests/test_res70_comparability.py diff --git a/src/dynamislm/__init__.py b/src/dynamislm/__init__.py index 0b12303..bc0ca2a 100644 --- a/src/dynamislm/__init__.py +++ b/src/dynamislm/__init__.py @@ -23,13 +23,24 @@ RelationshipClaimLevel, ) from dynamislm.comparability import ( + CANONICAL_BRIDGE_OPERATION_REGISTRY, + CANONICAL_BRIDGE_REGISTRY, + RES70_AFFINE_BRIDGE_OPERATION, + RES70_BRIDGE_PROVENANCE_RULE, + RES70_BRIDGE_REGISTRY, + RES70_COMPARABILITY_RULE_REGISTRY, + RES70_CROSS_SOURCE_COMPARABILITY_RULE, + RES70_REGISTRY_VERSION, + RES70_SOFTWARE_VERSION, BridgeApplicationRequest, BridgeAuthorityOrigin, BridgeExecutionResult, BridgeExecutionStatus, BridgeInvertibility, BridgeMode, + BridgeOperationRegistry, BridgeRegistration, + BridgeRegistry, ClaimContext, ComparabilityAuthority, ComparabilityAuthorityError, @@ -38,15 +49,29 @@ ComparabilityReasonCode, ComparabilityRequest, ComparabilityResult, + ComparabilityRuleRegistry, ComparabilityState, CrossSourceComparabilityDecision, CrossSourceComparabilityRequest, DimensionFinding, DimensionFindingStatus, ObservationAuthorityReference, + RegisteredBridgeOperation, RegisteredComparabilityRule, + RES70ComparabilityAuthorityError, + RES70ValidationError, SemanticIdentityKey, TransformationRequest, + assess_cross_source_comparability, + build_res70_refusal, + canonical_registry_hash, + execute_registered_bridge, + validate_bridge_execution, + validate_bridge_registration, + validate_bridge_registry, + validate_cross_source_decision, + validate_cross_source_request, + validate_pairwise_decisions, ) from dynamislm.evidence import ( ApplicabilityAssessment, @@ -531,12 +556,18 @@ "CommittedQualificationReceiptIdentity", "ComparabilityAuthority", "ComparabilityAuthorityError", + "RES70ComparabilityAuthorityError", "ComparabilityDecisionSource", "ComparabilityDimension", "ComparabilityReasonCode", "ComparabilityRequest", "ComparabilityResult", "ComparabilityState", + "BridgeOperationRegistry", + "BridgeRegistry", + "CANONICAL_BRIDGE_OPERATION_REGISTRY", + "CANONICAL_BRIDGE_REGISTRY", + "ComparabilityRuleRegistry", "BridgeApplicationRequest", "BridgeAuthorityOrigin", "BridgeExecutionResult", @@ -634,6 +665,15 @@ "RefusalStatus", "RelationshipClaimLevel", "RegisteredComparabilityRule", + "RegisteredBridgeOperation", + "RES70_AFFINE_BRIDGE_OPERATION", + "RES70_BRIDGE_PROVENANCE_RULE", + "RES70_BRIDGE_REGISTRY", + "RES70_COMPARABILITY_RULE_REGISTRY", + "RES70_CROSS_SOURCE_COMPARABILITY_RULE", + "RES70_REGISTRY_VERSION", + "RES70_SOFTWARE_VERSION", + "RES70ValidationError", "RegisteredDatasetFileIdentity", "RegistryReference", "ResultStatus", @@ -712,6 +752,9 @@ "build_source_manifest", "build_source_variable_registry", "canonical_hash", + "assess_cross_source_comparability", + "build_res70_refusal", + "canonical_registry_hash", "canonical_json", "canonical_jsonl_bytes", "canonical_replay_digest", @@ -719,6 +762,7 @@ "committed_qualification_receipt_identity", "content_addressed_object_path", "create_derived_observation", + "execute_registered_bridge", "create_longitudinal_record", "create_longitudinal_source_manifest", "create_multi_source_analysis_input", @@ -750,6 +794,12 @@ "store_verified_temporary_file", "tab_header", "validate_canonical_records", + "validate_bridge_execution", + "validate_bridge_registry", + "validate_bridge_registration", + "validate_cross_source_decision", + "validate_cross_source_request", + "validate_pairwise_decisions", "validate_claim_evidence_applicability", "validate_canonical_replay", "validate_complete_multi_source_provenance_graph", diff --git a/src/dynamislm/comparability/__init__.py b/src/dynamislm/comparability/__init__.py index 9746fce..ff5e8b6 100644 --- a/src/dynamislm/comparability/__init__.py +++ b/src/dynamislm/comparability/__init__.py @@ -13,6 +13,13 @@ ComparabilityState, TransformationRequest, ) +from dynamislm.comparability.res70_authority import ( + ComparabilityAuthorityError as RES70ComparabilityAuthorityError, +) +from dynamislm.comparability.res70_authority import ( + assess_cross_source_comparability, + execute_registered_bridge, +) from dynamislm.comparability.res70_models import ( BridgeApplicationRequest, BridgeAuthorityOrigin, @@ -30,15 +37,52 @@ ObservationAuthorityReference, SemanticIdentityKey, ) +from dynamislm.comparability.res70_registry import ( + CANONICAL_BRIDGE_OPERATION_REGISTRY, + CANONICAL_BRIDGE_REGISTRY, + RES70_AFFINE_BRIDGE_OPERATION, + RES70_BRIDGE_PROVENANCE_RULE, + RES70_BRIDGE_REGISTRY, + RES70_COMPARABILITY_RULE_REGISTRY, + RES70_CROSS_SOURCE_COMPARABILITY_RULE, + RES70_REGISTRY_VERSION, + RES70_SOFTWARE_VERSION, + BridgeOperationRegistry, + BridgeRegistry, + ComparabilityRuleRegistry, + RegisteredBridgeOperation, + canonical_registry_hash, +) +from dynamislm.comparability.res70_validation import ( + RES70ValidationError, + build_res70_refusal, + validate_bridge_execution, + validate_bridge_registration, + validate_bridge_registry, + validate_cross_source_decision, + validate_cross_source_request, + validate_pairwise_decisions, +) __all__ = [ + "CANONICAL_BRIDGE_OPERATION_REGISTRY", + "CANONICAL_BRIDGE_REGISTRY", + "RES70_AFFINE_BRIDGE_OPERATION", + "RES70_BRIDGE_PROVENANCE_RULE", + "RES70_BRIDGE_REGISTRY", + "RES70_COMPARABILITY_RULE_REGISTRY", + "RES70_CROSS_SOURCE_COMPARABILITY_RULE", + "RES70_REGISTRY_VERSION", + "RES70_SOFTWARE_VERSION", "BridgeApplicationRequest", "BridgeAuthorityOrigin", "BridgeExecutionResult", "BridgeExecutionStatus", "BridgeInvertibility", "BridgeMode", + "BridgeOperationRegistry", "BridgeRegistration", + "BridgeRegistry", "ClaimContext", "ComparabilityAuthority", "ComparabilityAuthorityError", @@ -47,13 +91,27 @@ "ComparabilityReasonCode", "ComparabilityRequest", "ComparabilityResult", + "ComparabilityRuleRegistry", "ComparabilityState", "CrossSourceComparabilityDecision", "CrossSourceComparabilityRequest", "DimensionFinding", "DimensionFindingStatus", "ObservationAuthorityReference", + "RES70ComparabilityAuthorityError", + "RES70ValidationError", + "RegisteredBridgeOperation", "RegisteredComparabilityRule", "SemanticIdentityKey", "TransformationRequest", + "assess_cross_source_comparability", + "build_res70_refusal", + "canonical_registry_hash", + "execute_registered_bridge", + "validate_bridge_execution", + "validate_bridge_registration", + "validate_bridge_registry", + "validate_cross_source_decision", + "validate_cross_source_request", + "validate_pairwise_decisions", ] diff --git a/src/dynamislm/comparability/res70_authority.py b/src/dynamislm/comparability/res70_authority.py new file mode 100644 index 0000000..17c65f6 --- /dev/null +++ b/src/dynamislm/comparability/res70_authority.py @@ -0,0 +1,862 @@ +"""Deterministic pairwise RES-70 comparability and bridge execution authority.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import replace + +from dynamislm.comparability import res70_registry as _res70_registry +from dynamislm.comparability.models import ( + ComparabilityDecisionSource, + ComparabilityReasonCode, + ComparabilityRequest, + ComparabilityResult, + ComparabilityState, + TransformationRequest, +) +from dynamislm.comparability.res70_models import ( + BridgeApplicationRequest, + BridgeAuthorityOrigin, + BridgeExecutionResult, + BridgeExecutionStatus, + BridgeMode, + BridgeRegistration, + ComparabilityDimension, + CrossSourceComparabilityDecision, + CrossSourceComparabilityRequest, + DimensionFinding, + DimensionFindingStatus, + SemanticIdentityKey, +) +from dynamislm.comparability.res70_registry import ( + RES70_AFFINE_BRIDGE_OPERATION, + RES70_COMPARABILITY_RULE_REGISTRY, + RES70_CROSS_SOURCE_COMPARABILITY_RULE, + RES70_SOFTWARE_VERSION, + BridgeOperationRegistry, + BridgeRegistry, + ComparabilityRuleRegistry, + canonical_registry_hash, +) +from dynamislm.comparability.res70_validation import ( + RES70ValidationError, + build_res70_refusal, + validate_bridge_execution, + validate_bridge_registry, + validate_cross_source_request, +) +from dynamislm.measurement.identity import ( + InstanceIdentifier, + MetadataEntry, + RegistryReference, +) +from dynamislm.measurement.observation import ScientificMeasurementObservation +from dynamislm.measurement.result import ScalarValue +from dynamislm.measurement.taxonomy import ScientificClassification, ValueOrigin +from dynamislm.provenance.models import ( + EvidenceReference, + LineageEdge, + LineageRelation, + ProcessingRun, + Provenance, +) +from dynamislm.refusal.models import RefusalClass, RefusalResult +from dynamislm.serialization import canonical_hash + + +class ComparabilityAuthorityError(ValueError): + """Raised when canonical RES-70 authority cannot be resolved safely.""" + + +def _canonical_value(value: object) -> str | None: + return None if value is None else canonical_hash(value) + + +def _metadata_text(value: object) -> str | None: + if value is None: + return None + if isinstance(value, str): + return value + return canonical_hash(value) + + +def _dimension_values( + observation: ScientificMeasurementObservation, + *, + claim_context_present: bool, +) -> dict[ComparabilityDimension, str | None]: + identity = observation.identity + semantic = identity.semantic + acquisition = identity.acquisition + processing = identity.processing + version = identity.version + context = observation.context + result = observation.result + parameters = {item.key: item.value for item in processing.method_parameters} + return { + ComparabilityDimension.CONSTRUCT: semantic.construct.stable_id, + ComparabilityDimension.TEST_FAMILY: semantic.test_family.stable_id, + ComparabilityDimension.MEASURAND: semantic.measurand.stable_id, + ComparabilityDimension.METRIC_DEFINITION: semantic.metric_definition.stable_id, + ComparabilityDimension.PROTOCOL: ( + semantic.protocol.stable_id if semantic.protocol is not None else None + ), + ComparabilityDimension.EVENT_DEFINITION: _canonical_value(processing.event_definitions), + ComparabilityDimension.PHASE_DEFINITION: _canonical_value(processing.phase_definitions), + ComparabilityDimension.UNIT: _canonical_value(result.unit or processing.unit), + ComparabilityDimension.NORMALIZATION: _canonical_value(processing.normalization), + ComparabilityDimension.ESTIMATOR: ( + processing.estimator.stable_id if processing.estimator is not None else None + ), + ComparabilityDimension.REGISTERED_PROCESSING_OPERATION: ( + processing.registered_operation.stable_id + if processing.registered_operation is not None + else None + ), + ComparabilityDimension.PROCESSING_PARAMETERS: _canonical_value( + processing.method_parameters + ), + ComparabilityDimension.FILTERING_SMOOTHING_RESAMPLING: _canonical_value( + processing.filtering + ), + ComparabilityDimension.SAMPLING_AND_TIMEBASE: _canonical_value(acquisition.sampling), + ComparabilityDimension.CALIBRATION_REFERENCE: ( + acquisition.calibration_reference.stable_id + if acquisition.calibration_reference is not None + else None + ), + ComparabilityDimension.DEVICE_MEASURING_SYSTEM: ( + acquisition.device.stable_id if acquisition.device is not None else None + ), + ComparabilityDimension.PROVIDER: _metadata_text(parameters.get("provider")), + ComparabilityDimension.SOFTWARE_ALGORITHM_VERSION: version.software_version, + ComparabilityDimension.HARDWARE_FIRMWARE_VERSION: ( + version.hardware_firmware.stable_id if version.hardware_firmware is not None else None + ), + ComparabilityDimension.SIGN_CONVENTION_AND_REFERENCE_FRAME: _canonical_value( + processing.sign_convention + ), + ComparabilityDimension.THRESHOLD_IDENTITY: _canonical_value( + tuple(item for item in processing.method_parameters if "threshold" in item.key.lower()) + ), + ComparabilityDimension.TRIAL_SELECTION_POLICY: ( + processing.trial_selection.stable_id if processing.trial_selection is not None else None + ), + ComparabilityDimension.AGGREGATION_POLICY: ( + processing.aggregation.stable_id if processing.aggregation is not None else None + ), + ComparabilityDimension.SESSION_SEGMENTATION: _metadata_text( + parameters.get("session_segmentation") + ), + ComparabilityDimension.ACQUISITION_CONTEXT: _canonical_value( + (context.environment, context.context_metadata) + ), + ComparabilityDimension.EXPOSURE_CONTEXT_MATCH_OR_TRAINING: ( + context.population_context if claim_context_present else None + ), + ComparabilityDimension.VALUE_ORIGIN: result.classification.value_origin.value, + ComparabilityDimension.UNCERTAINTY_ERROR_MODEL: _canonical_value(result.uncertainty), + ComparabilityDimension.POPULATION_APPLICABILITY: context.population_context, + ComparabilityDimension.EVIDENCE_APPLICABILITY: _canonical_value( + observation.provenance.evidence_references + ), + ComparabilityDimension.FOOTBALL_WORLD_CONTEXT: None, + } + + +_IRRECONCILABLE_DIMENSIONS = { + ComparabilityDimension.CONSTRUCT, + ComparabilityDimension.TEST_FAMILY, + ComparabilityDimension.MEASURAND, + ComparabilityDimension.METRIC_DEFINITION, + ComparabilityDimension.PROTOCOL, + ComparabilityDimension.VALUE_ORIGIN, +} +_TRANSFORMATION_DIMENSIONS = { + ComparabilityDimension.UNIT, +} +_BRIDGE_DIMENSIONS = ( + set(ComparabilityDimension) + - _IRRECONCILABLE_DIMENSIONS + - { + ComparabilityDimension.UNIT, + ComparabilityDimension.FOOTBALL_WORLD_CONTEXT, + } +) +_OPTIONAL_NOT_APPLICABLE_DIMENSIONS = { + ComparabilityDimension.NORMALIZATION, + ComparabilityDimension.REGISTERED_PROCESSING_OPERATION, + ComparabilityDimension.SAMPLING_AND_TIMEBASE, + ComparabilityDimension.CALIBRATION_REFERENCE, + ComparabilityDimension.DEVICE_MEASURING_SYSTEM, + ComparabilityDimension.PROVIDER, + ComparabilityDimension.HARDWARE_FIRMWARE_VERSION, + ComparabilityDimension.SIGN_CONVENTION_AND_REFERENCE_FRAME, + ComparabilityDimension.TRIAL_SELECTION_POLICY, + ComparabilityDimension.AGGREGATION_POLICY, + ComparabilityDimension.SESSION_SEGMENTATION, +} + + +def _reason_for_dimension(dimension: ComparabilityDimension) -> str: + mapping = { + ComparabilityDimension.METRIC_DEFINITION: ( + ComparabilityReasonCode.METRIC_DEFINITION_MISMATCH + ), + ComparabilityDimension.MEASURAND: ComparabilityReasonCode.MEASURAND_MISMATCH, + ComparabilityDimension.PROTOCOL: ComparabilityReasonCode.PROTOCOL_MISMATCH, + ComparabilityDimension.DEVICE_MEASURING_SYSTEM: ComparabilityReasonCode.DEVICE_MISMATCH, + ComparabilityDimension.UNIT: ComparabilityReasonCode.UNIT_OR_NORMALIZATION_MISMATCH, + ComparabilityDimension.NORMALIZATION: ComparabilityReasonCode.NORMALIZATION_MISMATCH, + ComparabilityDimension.ESTIMATOR: ComparabilityReasonCode.ESTIMATOR_MISMATCH, + ComparabilityDimension.THRESHOLD_IDENTITY: ComparabilityReasonCode.THRESHOLD_VALUE_MISMATCH, + ComparabilityDimension.EXPOSURE_CONTEXT_MATCH_OR_TRAINING: ( + ComparabilityReasonCode.EXPOSURE_CONTEXT_MISMATCH + ), + ComparabilityDimension.ACQUISITION_CONTEXT: ComparabilityReasonCode.ARRANGEMENT_MISMATCH, + ComparabilityDimension.POPULATION_APPLICABILITY: ComparabilityReasonCode.IDENTITY_MISMATCH, + ComparabilityDimension.VALUE_ORIGIN: ComparabilityReasonCode.VALUE_ORIGIN_MISMATCH, + } + return mapping.get(dimension, ComparabilityReasonCode.METHOD_MISMATCH).value + + +def _findings( + left: ScientificMeasurementObservation, + right: ScientificMeasurementObservation, + *, + claim_context_present: bool, +) -> tuple[DimensionFinding, ...]: + left_values = _dimension_values(left, claim_context_present=claim_context_present) + right_values = _dimension_values(right, claim_context_present=claim_context_present) + findings: list[DimensionFinding] = [] + for dimension in ComparabilityDimension: + left_value = left_values[dimension] + right_value = right_values[dimension] + if ( + dimension + in { + ComparabilityDimension.FOOTBALL_WORLD_CONTEXT, + ComparabilityDimension.EXPOSURE_CONTEXT_MATCH_OR_TRAINING, + } + and not claim_context_present + ): + status = DimensionFindingStatus.NOT_APPLICABLE + reason_codes: tuple[str, ...] = () + elif ( + left_value is None + and right_value is None + and dimension in _OPTIONAL_NOT_APPLICABLE_DIMENSIONS + ): + status = DimensionFindingStatus.NOT_APPLICABLE + reason_codes = () + elif left_value is None and right_value is None: + status = DimensionFindingStatus.UNKNOWN + reason_codes = ("MISSING_METADATA",) + elif left_value is None or right_value is None: + status = DimensionFindingStatus.UNKNOWN + reason_codes = ("MISSING_METADATA",) + elif left_value == right_value: + status = DimensionFindingStatus.MATCH + reason_codes = () + else: + status = DimensionFindingStatus.MISMATCH + reason_codes = (_reason_for_dimension(dimension),) + findings.append( + DimensionFinding( + dimension=dimension, + status=status, + left_value=left_value, + right_value=right_value, + reason_codes=reason_codes, + ) + ) + return tuple(findings) + + +def _leaf_result( + request: CrossSourceComparabilityRequest, + *, + state: ComparabilityState, + reasons: tuple[str, ...], + conditions: tuple[str, ...], + transformations: tuple[TransformationRequest, ...], + missing: tuple[str, ...], + evidence: tuple[RegistryReference, ...], +) -> ComparabilityResult: + leaf_request = ComparabilityRequest( + request_id=InstanceIdentifier( + "comparability-request", + f"res70:{request.request_hash.removeprefix('sha256:')[:40]}", + ), + left_observation_id=request.left_observation.observation_id, + right_observation_id=request.right_observation.observation_id, + claim=request.claim_intent.stable_id, + requested_transformations=transformations, + material_dimensions=tuple(item.value for item in ComparabilityDimension), + ) + return ComparabilityResult( + result_id=InstanceIdentifier( + "comparability-result", + f"res70:{request.request_hash.removeprefix('sha256:')[:40]}:{state.value.lower()}", + ), + request_id=leaf_request.request_id, + state=state, + reason_codes=reasons, + conditions=conditions, + transformations_required=transformations, + missing_information=missing, + rule_reference=RES70_CROSS_SOURCE_COMPARABILITY_RULE, + evidence_references=evidence, + decided_by=ComparabilityDecisionSource.DETERMINISTIC_RULE, + ) + + +def _bridge_for_request( + request: CrossSourceComparabilityRequest, + left: ScientificMeasurementObservation, + right: ScientificMeasurementObservation, + bridge_registry: BridgeRegistry, +) -> BridgeRegistration | None: + left_key = SemanticIdentityKey.from_identity(left.identity) + right_key = SemanticIdentityKey.from_identity(right.identity) + candidates = tuple( + entry + for entry in bridge_registry.entries + if entry.source_semantic_key == left_key + and entry.target_semantic_key == right_key + and any(item.stable_id == request.claim_intent.stable_id for item in entry.claim_scope) + ) + if len(candidates) > 1: + raise ComparabilityAuthorityError("multiple bridges match one pairwise request") + return candidates[0] if candidates else None + + +def assess_cross_source_comparability( + request: CrossSourceComparabilityRequest, + observations: Mapping[object, ScientificMeasurementObservation] + | Sequence[ScientificMeasurementObservation], + *, + bridge_registry: BridgeRegistry | None = None, + rule_registry: ComparabilityRuleRegistry = RES70_COMPARABILITY_RULE_REGISTRY, + bridge_execution: BridgeExecutionResult | None = None, + family_result: ComparabilityResult | None = None, +) -> CrossSourceComparabilityDecision: + """Adjudicate one pair without label matching or transitive closure.""" + + if not isinstance(request, CrossSourceComparabilityRequest): + raise ComparabilityAuthorityError("request must be a CrossSourceComparabilityRequest") + if bridge_registry is None: + bridge_registry = _res70_registry.CANONICAL_BRIDGE_REGISTRY + try: + validate_bridge_registry(bridge_registry, require_canonical=True) + left, right = validate_cross_source_request(request, observations) + except RES70ValidationError as exc: + raise ComparabilityAuthorityError(str(exc)) from exc + if not rule_registry.contains(RES70_CROSS_SOURCE_COMPARABILITY_RULE): + raise ComparabilityAuthorityError("RES70 comparability rule is not registered") + if family_result is not None and family_result.state not in ( + ComparabilityState.COMPARABLE, + ComparabilityState.COMPARABLE_WITH_CONDITIONS, + ): + state = family_result.state + reasons = tuple(family_result.reason_codes) + conditions = tuple(family_result.conditions) + transformations = tuple(family_result.transformations_required) + missing = tuple(family_result.missing_information) + evidence = tuple(family_result.evidence_references) + findings = _findings( + left, + right, + claim_context_present=request.claim_context is not None, + ) + leaf = family_result + return CrossSourceComparabilityDecision.create( + request_hash=request.request_hash, + state=state, + dimension_findings=findings, + conditions=conditions, + transformations_required=transformations, + bridge_application_reference=None, + rule_reference=RES70_CROSS_SOURCE_COMPARABILITY_RULE, + evidence_references=evidence, + registry_version=rule_registry.registry_version, + registry_hash=canonical_registry_hash( + bridge_registry=bridge_registry, + rule_registry=rule_registry, + ), + left_observation=request.left_observation, + right_observation=request.right_observation, + reason_codes=reasons, + missing_information=missing, + leaf_result=leaf, + ) + + effective_left = left + effective_right = right + bridge: BridgeRegistration | None = None + bridge_reference: RegistryReference | None = None + bridge_conditions: tuple[str, ...] = () + bridge_evidence: tuple[RegistryReference, ...] = () + bridge_applied = False + if bridge_execution is not None: + if bridge_execution.status is not BridgeExecutionStatus.EXECUTED: + raise ComparabilityAuthorityError( + "only executed bridge transformations may support values" + ) + try: + if bridge_execution.source_observation == request.left_observation: + validate_bridge_execution(bridge_execution, None, left) + if bridge_execution.transformed_observation is None: + raise ComparabilityAuthorityError( + "executed bridge has no transformed observation" + ) + effective_left = bridge_execution.transformed_observation + elif bridge_execution.source_observation == request.right_observation: + validate_bridge_execution(bridge_execution, None, right) + if bridge_execution.transformed_observation is None: + raise ComparabilityAuthorityError( + "executed bridge has no transformed observation" + ) + effective_right = bridge_execution.transformed_observation + else: + raise ComparabilityAuthorityError("bridge execution is not bound to this pair") + except RES70ValidationError as exc: + raise ComparabilityAuthorityError(str(exc)) from exc + bridge = bridge_registry.resolve(bridge_execution.bridge_reference) + if bridge is None: + raise ComparabilityAuthorityError("bridge execution references an unregistered bridge") + bridge_reference = bridge.bridge_reference + bridge_conditions = ("registered transformation executed",) + bridge_evidence = bridge.evidence_references + bridge_applied = True + + findings = _findings( + effective_left, + effective_right, + claim_context_present=request.claim_context is not None, + ) + unknown = tuple( + finding for finding in findings if finding.status is DimensionFindingStatus.UNKNOWN + ) + mismatches = tuple( + finding for finding in findings if finding.status is DimensionFindingStatus.MISMATCH + ) + reasons = tuple( + dict.fromkeys(reason for finding in mismatches for reason in finding.reason_codes) + ) + missing = tuple( + f"{finding.dimension.value.lower()} identity or applicability metadata" + for finding in unknown + ) + transformations = request.requested_transformations + state = ComparabilityState.COMPARABLE + conditions = bridge_conditions + evidence = bridge_evidence + + if unknown: + state = ComparabilityState.INSUFFICIENT_INFORMATION + reasons = tuple(dict.fromkeys(("RES70_UNRESOLVED_IDENTITY", *reasons))) + elif any( + finding.dimension in _IRRECONCILABLE_DIMENSIONS + and not (bridge_applied and finding.dimension is ComparabilityDimension.VALUE_ORIGIN) + for finding in mismatches + ): + state = ComparabilityState.NOT_COMPARABLE + elif any(finding.dimension in _TRANSFORMATION_DIMENSIONS for finding in mismatches): + if not transformations: + transformations = ( + TransformationRequest( + operation=RegistryReference( + RES70_AFFINE_BRIDGE_OPERATION.identifier, + RES70_AFFINE_BRIDGE_OPERATION.display_label, + ), + parameters=(), + ), + ) + if bridge_applied: + state = ComparabilityState.COMPARABLE_WITH_CONDITIONS + else: + state = ComparabilityState.REQUIRES_TRANSFORMATION + reasons = tuple(dict.fromkeys(("RES70_BRIDGE_NOT_EXECUTED", *reasons))) + elif mismatches: + if bridge is None: + bridge = _bridge_for_request(request, left, right, bridge_registry) + if bridge is None: + state = ComparabilityState.BRIDGE_VALIDATION_REQUIRED + reasons = tuple(dict.fromkeys(("RES70_BRIDGE_REQUIRED", *reasons))) + conditions = ("a registered claim-scoped bridge",) + elif bridge.authority_origin is not BridgeAuthorityOrigin.PRODUCTION: + state = ComparabilityState.INSUFFICIENT_INFORMATION + reasons = tuple(dict.fromkeys(("RES70_REGISTRY_INTEGRITY_FAILURE", *reasons))) + missing = ("canonical production bridge registry",) + elif bridge.bridge_mode is BridgeMode.DECLARATIVE_EQUIVALENCE: + state = ComparabilityState.COMPARABLE_WITH_CONDITIONS + bridge_reference = bridge.bridge_reference + conditions = tuple( + f"{item.key}={item.value}" for item in bridge.applicability_conditions + ) + evidence = bridge.evidence_references + for index, finding in enumerate(findings): + if finding.status is DimensionFindingStatus.MISMATCH: + findings = ( + *findings[:index], + replace(finding, status=DimensionFindingStatus.BRIDGED), + *findings[index + 1 :], + ) + elif bridge_applied: + state = ComparabilityState.COMPARABLE_WITH_CONDITIONS + bridge_reference = bridge.bridge_reference + evidence = bridge.evidence_references + else: + state = ComparabilityState.REQUIRES_TRANSFORMATION + bridge_reference = bridge.bridge_reference + conditions = ("registered numerical transformation must be executed",) + reasons = tuple(dict.fromkeys(("RES70_BRIDGE_NOT_EXECUTED", *reasons))) + if bridge.transformation_operation is not None and not transformations: + transformations = ( + TransformationRequest(bridge.transformation_operation, bridge.fixed_parameters), + ) + elif bridge_applied: + state = ComparabilityState.COMPARABLE_WITH_CONDITIONS + else: + state = ComparabilityState.COMPARABLE + + leaf = _leaf_result( + request, + state=state, + reasons=reasons, + conditions=conditions, + transformations=transformations, + missing=missing, + evidence=evidence, + ) + return CrossSourceComparabilityDecision.create( + request_hash=request.request_hash, + state=state, + dimension_findings=findings, + conditions=conditions, + transformations_required=transformations, + bridge_application_reference=bridge_reference, + rule_reference=RES70_CROSS_SOURCE_COMPARABILITY_RULE, + evidence_references=evidence, + registry_version=rule_registry.registry_version, + registry_hash=canonical_registry_hash( + bridge_registry=bridge_registry, + rule_registry=rule_registry, + ), + left_observation=request.left_observation, + right_observation=request.right_observation, + reason_codes=reasons, + missing_information=missing, + leaf_result=leaf, + ) + + +def _domain_allows( + value: float, + constraints: tuple[MetadataEntry, ...], +) -> bool: + values = {item.key: item.value for item in constraints} + minimum = values.get("min") + maximum = values.get("max") + if minimum is not None and (isinstance(minimum, bool) or not isinstance(minimum, int | float)): + raise ValueError("bridge min domain constraint must be numeric") + if maximum is not None and (isinstance(maximum, bool) or not isinstance(maximum, int | float)): + raise ValueError("bridge max domain constraint must be numeric") + return not ( + (minimum is not None and value < float(minimum)) + or (maximum is not None and value > float(maximum)) + ) + + +def _make_bridge_provenance( + source: ScientificMeasurementObservation, + processing_run: ProcessingRun, + bridge: BridgeRegistration, +) -> Provenance: + evidence = tuple( + dict.fromkeys( + ( + *source.provenance.evidence_references, + *(EvidenceReference(item) for item in bridge.evidence_references), + ) + ) + ) + edges = ( + *source.provenance.lineage_edges, + LineageEdge( + source.observation_id.qualified, + processing_run.processing_run_id.qualified, + LineageRelation.DERIVED_FROM, + ), + LineageEdge( + processing_run.processing_run_id.qualified, + processing_run.output_entity_id.qualified, + LineageRelation.PRODUCED, + ), + ) + return Provenance( + provenance_id=InstanceIdentifier("provenance", processing_run.output_entity_id.value), + source_artifacts=source.provenance.source_artifacts, + acquisitions=source.provenance.acquisitions, + processing_runs=(*source.provenance.processing_runs, processing_run), + lineage_edges=edges, + evidence_references=evidence, + metrological_traceability=source.provenance.metrological_traceability, + recorded_at=source.provenance.recorded_at, + ) + + +def execute_registered_bridge( + request: BridgeApplicationRequest, + source_observation: ScientificMeasurementObservation, + *, + bridge_registry: BridgeRegistry | None = None, + operation_registry: BridgeOperationRegistry | None = None, +) -> BridgeExecutionResult | RefusalResult: + """Resolve the canonical bridge and execute it without caller authority.""" + + observation_id = source_observation.observation_id + if bridge_registry is None: + bridge_registry = _res70_registry.CANONICAL_BRIDGE_REGISTRY + if operation_registry is None: + operation_registry = _res70_registry.CANONICAL_BRIDGE_OPERATION_REGISTRY + try: + request.source_observation.validate_observation(source_observation) + except ValueError as exc: + return build_res70_refusal( + "registered bridge execution", + "RES70_UNRESOLVED_IDENTITY", + refusal_class=RefusalClass.IDENTITY_UNRESOLVED, + missing_information=(str(exc),), + observation_ids=(observation_id,), + ) + try: + validate_bridge_registry(bridge_registry, require_canonical=True) + except RES70ValidationError as exc: + return build_res70_refusal( + "registered bridge execution", + "RES70_REGISTRY_INTEGRITY_FAILURE", + refusal_class=RefusalClass.COMPUTATION_NOT_REGISTERED, + missing_information=(str(exc),), + observation_ids=(observation_id,), + ) + if operation_registry is not _res70_registry.CANONICAL_BRIDGE_OPERATION_REGISTRY: + return build_res70_refusal( + "registered bridge execution", + "RES70_REGISTRY_INTEGRITY_FAILURE", + refusal_class=RefusalClass.COMPUTATION_NOT_REGISTERED, + missing_information=("canonical production bridge operation registry",), + observation_ids=(observation_id,), + ) + bridge = bridge_registry.resolve(request.bridge_reference) + if bridge is None: + return build_res70_refusal( + "registered bridge execution", + "RES70_BRIDGE_REQUIRED", + refusal_class=RefusalClass.COMPARABILITY_UNESTABLISHED, + missing_information=("canonical registered bridge declaration",), + observation_ids=(observation_id,), + ) + if bridge.authority_origin is not BridgeAuthorityOrigin.PRODUCTION: + return build_res70_refusal( + "registered bridge execution", + "RES70_REGISTRY_INTEGRITY_FAILURE", + refusal_class=RefusalClass.COMPUTATION_NOT_REGISTERED, + missing_information=("production bridge authority",), + observation_ids=(observation_id,), + ) + if not any(item.stable_id == request.claim_intent.stable_id for item in bridge.claim_scope): + return build_res70_refusal( + "registered bridge execution", + "RES70_BRIDGE_CONDITIONS_UNSATISFIED", + refusal_class=RefusalClass.COMPARABILITY_UNESTABLISHED, + missing_information=("bridge applicability for the requested claim",), + observation_ids=(observation_id,), + ) + if SemanticIdentityKey.from_identity(source_observation.identity) != bridge.source_semantic_key: + return build_res70_refusal( + "registered bridge execution", + "RES70_UNRESOLVED_IDENTITY", + refusal_class=RefusalClass.IDENTITY_UNRESOLVED, + missing_information=("exact bridge source semantic identity",), + observation_ids=(observation_id,), + ) + if request.requested_parameters and request.requested_parameters != bridge.fixed_parameters: + return build_res70_refusal( + "registered bridge execution", + "RES70_REGISTRY_INTEGRITY_FAILURE", + refusal_class=RefusalClass.COMPUTATION_NOT_REGISTERED, + missing_information=("caller parameters cannot replace registered bridge parameters",), + observation_ids=(observation_id,), + ) + if bridge.bridge_mode is BridgeMode.DECLARATIVE_EQUIVALENCE: + execution_content = { + "request_hash": request.request_hash, + "bridge_reference": bridge.bridge_reference, + "bridge_hash": bridge.canonical_bridge_hash, + "source_observation": request.source_observation, + "transformed_observation": None, + "processing_run": None, + "provenance": None, + "output_observation_hash": None, + "uncertainty_model": bridge.uncertainty_model, + "lossiness_description": bridge.lossiness_description, + "status": BridgeExecutionStatus.DECLARATIVE_APPLIED, + } + execution_hash = canonical_hash(execution_content) + result = BridgeExecutionResult( + execution_id=InstanceIdentifier( + "bridge-execution", execution_hash.removeprefix("sha256:") + ), + status=BridgeExecutionStatus.DECLARATIVE_APPLIED, + request_hash=request.request_hash, + bridge_reference=bridge.bridge_reference, + bridge_hash=bridge.canonical_bridge_hash, + source_observation=request.source_observation, + transformed_observation=None, + processing_run=None, + provenance=None, + output_observation_hash=None, + uncertainty_model=bridge.uncertainty_model, + lossiness_description=bridge.lossiness_description, + execution_hash=execution_hash, + ) + return result + if request.target_identity is None: + return build_res70_refusal( + "registered numerical bridge execution", + "RES70_BRIDGE_EXECUTION_FAILED", + refusal_class=RefusalClass.DATA_ADEQUACY_INSUFFICIENT, + missing_information=("exact target measurement identity",), + observation_ids=(observation_id,), + ) + if SemanticIdentityKey.from_identity(request.target_identity) != bridge.target_semantic_key: + return build_res70_refusal( + "registered numerical bridge execution", + "RES70_UNRESOLVED_IDENTITY", + refusal_class=RefusalClass.IDENTITY_UNRESOLVED, + missing_information=("exact bridge target semantic identity",), + observation_ids=(observation_id,), + ) + if bridge.transformation_operation is None: + return build_res70_refusal( + "registered numerical bridge execution", + "COMPUTATION_NOT_REGISTERED", + refusal_class=RefusalClass.COMPUTATION_NOT_REGISTERED, + missing_information=("registered deterministic bridge operation",), + observation_ids=(observation_id,), + ) + operation = operation_registry.resolve(bridge.transformation_operation) + if operation is None: + return build_res70_refusal( + "registered numerical bridge execution", + "COMPUTATION_NOT_REGISTERED", + refusal_class=RefusalClass.COMPUTATION_NOT_REGISTERED, + missing_information=("canonical bridge operation implementation",), + observation_ids=(observation_id,), + ) + if ( + not isinstance(source_observation.result.value, ScalarValue) + or isinstance(source_observation.result.value.value, bool) + or not isinstance(source_observation.result.value.value, int | float) + ): + return build_res70_refusal( + "registered numerical bridge execution", + "RES70_BRIDGE_EXECUTION_FAILED", + refusal_class=RefusalClass.DATA_ADEQUACY_INSUFFICIENT, + missing_information=("finite numeric scalar source result",), + observation_ids=(observation_id,), + ) + value = float(source_observation.result.value.value) + try: + if not _domain_allows(value, bridge.domain_constraints): + raise ValueError("source value is outside the registered bridge domain") + transformed_value = operation.execute(value, bridge.fixed_parameters) + if not _domain_allows(transformed_value, bridge.domain_constraints): + raise ValueError("transformed value is outside the registered bridge domain") + except (TypeError, ValueError) as exc: + return build_res70_refusal( + "registered numerical bridge execution", + "RES70_BRIDGE_EXECUTION_FAILED", + refusal_class=RefusalClass.DATA_ADEQUACY_INSUFFICIENT, + missing_information=(str(exc),), + observation_ids=(observation_id,), + ) + output_payload = { + "source_observation": request.source_observation, + "bridge_reference": bridge.bridge_reference, + "bridge_hash": bridge.canonical_bridge_hash, + "target_identity": request.target_identity, + "value": transformed_value, + "operation": bridge.transformation_operation, + "parameters": bridge.fixed_parameters, + "software_version": RES70_SOFTWARE_VERSION, + } + digest = canonical_hash(output_payload).removeprefix("sha256:") + output_observation_id = InstanceIdentifier("observation", f"bridge:{digest}") + output_result = replace( + source_observation.result, + result_id=InstanceIdentifier("result", f"bridge:{digest}"), + value=ScalarValue(transformed_value), + unit=request.target_identity.processing.unit or source_observation.result.unit, + classification=ScientificClassification( + ValueOrigin.DYNAMISLM_DERIVED, + source_observation.result.classification.scientific_roles, + ), + ) + processing_run = ProcessingRun( + processing_run_id=InstanceIdentifier("processing-run", f"bridge:{digest}"), + source_artifact_ids=tuple( + item.artifact_id for item in source_observation.provenance.source_artifacts + ), + method=bridge.transformation_operation, + parameters=bridge.fixed_parameters, + software_version=RES70_SOFTWARE_VERSION, + output_entity_id=output_observation_id, + ) + provenance = _make_bridge_provenance(source_observation, processing_run, bridge) + transformed = ScientificMeasurementObservation( + observation_id=output_observation_id, + context=source_observation.context, + identity=request.target_identity, + result=output_result, + provenance=provenance, + ) + execution_content = { + "request_hash": request.request_hash, + "bridge_reference": bridge.bridge_reference, + "bridge_hash": bridge.canonical_bridge_hash, + "source_observation": request.source_observation, + "transformed_observation": transformed, + "processing_run": processing_run, + "provenance": provenance, + "output_observation_hash": canonical_hash(transformed), + "uncertainty_model": bridge.uncertainty_model, + "lossiness_description": bridge.lossiness_description, + "status": BridgeExecutionStatus.EXECUTED, + } + execution_hash = canonical_hash(execution_content) + result = BridgeExecutionResult( + execution_id=InstanceIdentifier("bridge-execution", execution_hash.removeprefix("sha256:")), + status=BridgeExecutionStatus.EXECUTED, + request_hash=request.request_hash, + bridge_reference=bridge.bridge_reference, + bridge_hash=bridge.canonical_bridge_hash, + source_observation=request.source_observation, + transformed_observation=transformed, + processing_run=processing_run, + provenance=provenance, + output_observation_hash=canonical_hash(transformed), + uncertainty_model=bridge.uncertainty_model, + lossiness_description=bridge.lossiness_description, + execution_hash=execution_hash, + ) + return result + + +__all__ = [ + "ComparabilityAuthorityError", + "assess_cross_source_comparability", + "execute_registered_bridge", +] diff --git a/src/dynamislm/comparability/res70_models.py b/src/dynamislm/comparability/res70_models.py index c729e85..ff8dd3a 100644 --- a/src/dynamislm/comparability/res70_models.py +++ b/src/dynamislm/comparability/res70_models.py @@ -500,6 +500,69 @@ def pair_key(self) -> frozenset[str]: ) ) + @classmethod + def create( + cls, + *, + request_hash: str, + state: ComparabilityState, + dimension_findings: tuple[DimensionFinding, ...], + conditions: tuple[str, ...], + transformations_required: tuple[TransformationRequest, ...], + bridge_application_reference: RegistryReference | None, + rule_reference: RegistryReference | None, + evidence_references: tuple[RegistryReference, ...], + registry_version: str, + registry_hash: str, + left_observation: ObservationAuthorityReference, + right_observation: ObservationAuthorityReference, + reason_codes: tuple[str, ...] = (), + missing_information: tuple[str, ...] = (), + leaf_result: ComparabilityResult | None = None, + ) -> CrossSourceComparabilityDecision: + """Construct a decision while deriving its immutable ID and hash.""" + + content = { + "request_hash": request_hash, + "state": state, + "dimension_findings": dimension_findings, + "conditions": conditions, + "transformations_required": transformations_required, + "bridge_application_reference": bridge_application_reference, + "rule_reference": rule_reference, + "evidence_references": evidence_references, + "registry_version": registry_version, + "registry_hash": registry_hash, + "left_observation": left_observation, + "right_observation": right_observation, + "reason_codes": reason_codes, + "missing_information": missing_information, + "leaf_result": leaf_result, + } + decision_hash = canonical_hash(content) + return cls( + decision_id=InstanceIdentifier( + "cross-source-comparability-decision", + decision_hash.removeprefix(_SHA256_PREFIX), + ), + request_hash=request_hash, + state=state, + dimension_findings=dimension_findings, + conditions=conditions, + transformations_required=transformations_required, + bridge_application_reference=bridge_application_reference, + rule_reference=rule_reference, + evidence_references=evidence_references, + registry_version=registry_version, + registry_hash=registry_hash, + left_observation=left_observation, + right_observation=right_observation, + reason_codes=reason_codes, + missing_information=missing_information, + leaf_result=leaf_result, + decision_hash=decision_hash, + ) + @register_serializable_type @dataclass(frozen=True, slots=True) @@ -627,6 +690,7 @@ class BridgeApplicationRequest: bridge_reference: RegistryReference claim_intent: RegistryReference requested_parameters: tuple[MetadataEntry, ...] = () + target_identity: MeasurementIdentity | None = None def __post_init__(self) -> None: _require_instance(self.request_id, InstanceIdentifier, "request_id") @@ -638,6 +702,7 @@ def __post_init__(self) -> None: _require_instance(self.bridge_reference, RegistryReference, "bridge_reference") _require_instance(self.claim_intent, RegistryReference, "claim_intent") _require_tuple_items(self.requested_parameters, MetadataEntry, "requested_parameters") + _require_optional_instance(self.target_identity, MeasurementIdentity, "target_identity") @property def request_hash(self) -> str: diff --git a/src/dynamislm/comparability/res70_registry.py b/src/dynamislm/comparability/res70_registry.py new file mode 100644 index 0000000..53154b6 --- /dev/null +++ b/src/dynamislm/comparability/res70_registry.py @@ -0,0 +1,250 @@ +"""Canonical RES-70 comparability and bridge registries.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from dynamislm.comparability.res70_models import BridgeRegistration +from dynamislm.measurement.identity import ( + MetadataEntry, + RegistryReference, + ScientificIdentifier, + _require_instance, + _require_text, + _require_tuple_items, + require_tuple, +) +from dynamislm.serialization import canonical_hash, register_serializable_type + +RES70_REGISTRY_VERSION = "1.0.0" +RES70_SOFTWARE_VERSION = "dynamislm-res70-1.0.0" + + +def _reference(object_type: str, key: str, label: str) -> RegistryReference: + return RegistryReference( + ScientificIdentifier("dynamislm", object_type, key, RES70_REGISTRY_VERSION), + label, + ) + + +RES70_CROSS_SOURCE_COMPARABILITY_RULE = _reference( + "comparability-rule", + "cross-source-pairwise-v1", + "RES-70 pairwise cross-source comparability", +) +RES70_AFFINE_BRIDGE_OPERATION = _reference( + "registered-operation", + "res70-registered-affine-transformation-v1", + "RES-70 registered affine transformation", +) +RES70_BRIDGE_PROVENANCE_RULE = _reference( + "provenance-rule", + "res70-bridge-execution-v1", + "RES-70 bridge execution provenance", +) + + +BridgeTransform = Callable[[float, tuple[MetadataEntry, ...]], float] + + +@dataclass(frozen=True, slots=True) +class RegisteredBridgeOperation: + """Deterministic operation implementation owned by the canonical registry.""" + + reference: RegistryReference + execute: BridgeTransform + note: str + + def __post_init__(self) -> None: + _require_instance(self.reference, RegistryReference, "reference") + if self.reference.identifier.object_type != "registered-operation": + raise ValueError("bridge operation reference must identify a registered operation") + _require_text(self.note, "note") + + +@dataclass(frozen=True, slots=True) +class BridgeOperationRegistry: + """Non-callable wire-free dispatch table for canonical operations.""" + + entries: tuple[RegisteredBridgeOperation, ...] + + def __post_init__(self) -> None: + require_tuple(self.entries, "entries") + if any(not isinstance(item, RegisteredBridgeOperation) for item in self.entries): + raise ValueError("bridge operation registry entries must be typed") + refs = tuple(item.reference.stable_id for item in self.entries) + if len(set(refs)) != len(refs): + raise ValueError("bridge operation registry cannot contain duplicate references") + + def resolve(self, reference: RegistryReference) -> RegisteredBridgeOperation | None: + _require_instance(reference, RegistryReference, "reference") + matches = tuple( + item for item in self.entries if item.reference.stable_id == reference.stable_id + ) + if len(matches) > 1: + raise ValueError("RES70_REGISTRY_INTEGRITY_FAILURE: duplicate bridge operation") + return matches[0] if matches else None + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class BridgeRegistry: + """Immutable bridge declarations with a canonical content hash.""" + + entries: tuple[BridgeRegistration, ...] = () + registry_version: str = RES70_REGISTRY_VERSION + authority_origin: str = "PRODUCTION" + registry_hash: str | None = None + + def __post_init__(self) -> None: + _require_tuple_items(self.entries, BridgeRegistration, "entries") + _require_text(self.registry_version, "registry_version") + _require_text(self.authority_origin, "authority_origin") + refs = tuple(item.bridge_reference.stable_id for item in self.entries) + if len(set(refs)) != len(refs): + raise ValueError("RES70_REGISTRY_INTEGRITY_FAILURE: duplicate bridge reference") + if self.authority_origin == "PRODUCTION" and any( + item.authority_origin.value != "PRODUCTION" for item in self.entries + ): + raise ValueError("production bridge registry cannot contain synthetic bridges") + if any(item.registry_version != self.registry_version for item in self.entries): + raise ValueError("bridge and bridge-registry versions must match") + expected = canonical_hash( + { + "entries": self.entries, + "registry_version": self.registry_version, + "authority_origin": self.authority_origin, + } + ) + if self.registry_hash is None: + object.__setattr__(self, "registry_hash", expected) + elif self.registry_hash != expected: + raise ValueError("registry_hash does not match immutable bridge registry") + + def resolve(self, reference: RegistryReference) -> BridgeRegistration | None: + _require_instance(reference, RegistryReference, "reference") + matches = tuple( + item for item in self.entries if item.bridge_reference.stable_id == reference.stable_id + ) + if len(matches) > 1: + raise ValueError("RES70_REGISTRY_INTEGRITY_FAILURE: duplicate bridge authority") + return matches[0] if matches else None + + def with_synthetic_entry(self, entry: BridgeRegistration) -> BridgeRegistry: + if entry.authority_origin.value != "SYNTHETIC_TEST": + raise ValueError("with_synthetic_entry accepts only synthetic bridge declarations") + return BridgeRegistry( + entries=(*self.entries, entry), + registry_version=self.registry_version, + authority_origin="SYNTHETIC_TEST", + ) + + @property + def canonical_hash(self) -> str: + assert self.registry_hash is not None + return self.registry_hash + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class ComparabilityRuleRegistry: + """Immutable semantic rule references used by the RES-70 wrapper.""" + + entries: tuple[RegistryReference, ...] + registry_version: str = RES70_REGISTRY_VERSION + registry_hash: str | None = None + + def __post_init__(self) -> None: + _require_tuple_items(self.entries, RegistryReference, "entries") + _require_text(self.registry_version, "registry_version") + refs = tuple(item.stable_id for item in self.entries) + if len(set(refs)) != len(refs): + raise ValueError("comparability rule registry cannot contain duplicate references") + expected = canonical_hash( + {"entries": self.entries, "registry_version": self.registry_version} + ) + if self.registry_hash is None: + object.__setattr__(self, "registry_hash", expected) + elif self.registry_hash != expected: + raise ValueError("registry_hash does not match immutable rule registry") + + def contains(self, reference: RegistryReference) -> bool: + return any(item.stable_id == reference.stable_id for item in self.entries) + + @property + def canonical_hash(self) -> str: + assert self.registry_hash is not None + return self.registry_hash + + +def _affine_transform(value: float, parameters: tuple[MetadataEntry, ...]) -> float: + values = {item.key: item.value for item in parameters} + scale = values.get("scale") + offset = values.get("offset", 0.0) + if isinstance(scale, bool) or not isinstance(scale, int | float): + raise ValueError("registered affine bridge requires a numeric fixed scale") + if isinstance(offset, bool) or not isinstance(offset, int | float): + raise ValueError("registered affine bridge requires a numeric fixed offset") + result = float(value) * float(scale) + float(offset) + if result != result or result in (float("inf"), float("-inf")): + raise ValueError("registered bridge produced a non-finite result") + return result + + +CANONICAL_BRIDGE_OPERATION_REGISTRY = BridgeOperationRegistry( + entries=( + RegisteredBridgeOperation( + reference=RES70_AFFINE_BRIDGE_OPERATION, + execute=_affine_transform, + note="fixed affine operation; scale and offset come only from a registered bridge", + ), + ) +) + +CANONICAL_BRIDGE_REGISTRY = BridgeRegistry() +RES70_BRIDGE_REGISTRY = CANONICAL_BRIDGE_REGISTRY +RES70_COMPARABILITY_RULE_REGISTRY = ComparabilityRuleRegistry( + entries=(RES70_CROSS_SOURCE_COMPARABILITY_RULE,) +) + + +def canonical_registry_hash( + *, + bridge_registry: BridgeRegistry = CANONICAL_BRIDGE_REGISTRY, + rule_registry: ComparabilityRuleRegistry = RES70_COMPARABILITY_RULE_REGISTRY, +) -> str: + return canonical_hash( + { + "bridge_registry": bridge_registry, + "rule_registry": rule_registry, + } + ) + + +def is_canonical_bridge_registry(registry: BridgeRegistry) -> bool: + return registry is CANONICAL_BRIDGE_REGISTRY + + +def is_canonical_operation_registry(registry: BridgeOperationRegistry) -> bool: + return registry is CANONICAL_BRIDGE_OPERATION_REGISTRY + + +__all__ = [ + "CANONICAL_BRIDGE_OPERATION_REGISTRY", + "CANONICAL_BRIDGE_REGISTRY", + "RES70_AFFINE_BRIDGE_OPERATION", + "RES70_BRIDGE_PROVENANCE_RULE", + "RES70_BRIDGE_REGISTRY", + "RES70_COMPARABILITY_RULE_REGISTRY", + "RES70_CROSS_SOURCE_COMPARABILITY_RULE", + "RES70_REGISTRY_VERSION", + "RES70_SOFTWARE_VERSION", + "BridgeOperationRegistry", + "BridgeRegistry", + "ComparabilityRuleRegistry", + "RegisteredBridgeOperation", + "canonical_registry_hash", + "is_canonical_bridge_registry", + "is_canonical_operation_registry", +] diff --git a/src/dynamislm/comparability/res70_validation.py b/src/dynamislm/comparability/res70_validation.py new file mode 100644 index 0000000..431b27e --- /dev/null +++ b/src/dynamislm/comparability/res70_validation.py @@ -0,0 +1,256 @@ +"""Fail-closed validation for RES-70 comparability and bridge decisions.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence + +from dynamislm.comparability.res70_models import ( + BridgeExecutionResult, + BridgeRegistration, + CrossSourceComparabilityDecision, + CrossSourceComparabilityRequest, + ObservationAuthorityReference, +) +from dynamislm.comparability.res70_registry import ( + RES70_CROSS_SOURCE_COMPARABILITY_RULE, + BridgeOperationRegistry, + BridgeRegistry, + ComparabilityRuleRegistry, + canonical_registry_hash, + is_canonical_bridge_registry, + is_canonical_operation_registry, +) +from dynamislm.measurement.identity import InstanceIdentifier, RegistryReference +from dynamislm.measurement.observation import ScientificMeasurementObservation +from dynamislm.refusal.models import RefusalClass, RefusalResult, RefusalStatus +from dynamislm.serialization import canonical_hash + + +class RES70ValidationError(ValueError): + """Raised when a supplied RES-70 authority record is forged or inconsistent.""" + + +def build_res70_refusal( + blocked_claim: str, + reason_code: str, + *, + refusal_class: RefusalClass, + missing_information: tuple[str, ...] = (), + safe_descriptions: tuple[str, ...] = ( + "the exact source observations remain independently describable under their own identity", + ), + evidence_references: tuple[RegistryReference, ...] = (), + observation_ids: tuple[InstanceIdentifier, ...] = (), +) -> RefusalResult: + """Build the repository-native refusal envelope with RES-70 provenance.""" + + payload = { + "blocked_claim": blocked_claim, + "reason_codes": (reason_code,), + "missing_information": missing_information, + "safe_descriptions": safe_descriptions, + "evidence_references": evidence_references, + "observation_ids": observation_ids, + } + refusal_id = InstanceIdentifier( + "refusal", + f"res70:{canonical_hash(payload).removeprefix('sha256:')}", + ) + return RefusalResult( + refusal_id=refusal_id, + status=RefusalStatus.PARTIALLY_REFUSED if observation_ids else RefusalStatus.REFUSED, + refusal_class=refusal_class, + blocked_claim=blocked_claim, + reason_codes=(reason_code,), + missing_information=missing_information, + what_can_still_be_safely_described=safe_descriptions, + evidence_references=evidence_references, + observation_ids=observation_ids, + ) + + +def _observation_lookup( + observations: Mapping[object, ScientificMeasurementObservation] + | Sequence[ScientificMeasurementObservation], +) -> dict[str, ScientificMeasurementObservation]: + if isinstance(observations, Mapping): + values = tuple(observations.values()) + else: + values = tuple(observations) + result: dict[str, ScientificMeasurementObservation] = {} + for observation in values: + if not isinstance(observation, ScientificMeasurementObservation): + raise RES70ValidationError("observations must contain typed scientific observations") + key = observation.observation_id.qualified + if key in result and result[key] != observation: + raise RES70ValidationError( + "observation resolver contains conflicting observation content" + ) + result[key] = observation + return result + + +def resolve_observation_reference( + reference: ObservationAuthorityReference, + observations: Mapping[object, ScientificMeasurementObservation] + | Sequence[ScientificMeasurementObservation], +) -> ScientificMeasurementObservation: + """Resolve and hash-check one exact observation reference.""" + + lookup = _observation_lookup(observations) + observation = lookup.get(reference.observation_id.qualified) + if observation is None: + raise RES70ValidationError( + f"observation {reference.observation_id.qualified!r} is absent from the resolver" + ) + try: + reference.validate_observation(observation) + except ValueError as exc: + raise RES70ValidationError(str(exc)) from exc + return observation + + +def validate_cross_source_request( + request: CrossSourceComparabilityRequest, + observations: Mapping[object, ScientificMeasurementObservation] + | Sequence[ScientificMeasurementObservation], +) -> tuple[ScientificMeasurementObservation, ScientificMeasurementObservation]: + if not isinstance(request, CrossSourceComparabilityRequest): + raise RES70ValidationError("request must be a CrossSourceComparabilityRequest") + left = resolve_observation_reference(request.left_observation, observations) + right = resolve_observation_reference(request.right_observation, observations) + if left.observation_id == right.observation_id: + raise RES70ValidationError("cross-source request must remain pairwise") + return left, right + + +def validate_bridge_registration( + registration: BridgeRegistration, + *, + operation_registry: BridgeOperationRegistry | None = None, +) -> None: + if not isinstance(registration, BridgeRegistration): + raise RES70ValidationError("registration must be a BridgeRegistration") + if operation_registry is not None: + if not is_canonical_operation_registry(operation_registry): + raise RES70ValidationError( + "caller-supplied bridge operation registries cannot authorize a bridge" + ) + if ( + registration.transformation_operation is not None + and operation_registry.resolve(registration.transformation_operation) is None + ): + raise RES70ValidationError("bridge transformation operation is not registered") + + +def validate_bridge_registry( + registry: BridgeRegistry, + *, + require_canonical: bool = True, +) -> None: + if not isinstance(registry, BridgeRegistry): + raise RES70ValidationError("registry must be a BridgeRegistry") + if require_canonical and not is_canonical_bridge_registry(registry): + raise RES70ValidationError( + "caller-supplied bridge registries cannot authorize production bridges" + ) + for entry in registry.entries: + validate_bridge_registration(entry) + expected = canonical_hash( + { + "entries": registry.entries, + "registry_version": registry.registry_version, + "authority_origin": registry.authority_origin, + } + ) + if registry.canonical_hash != expected: + raise RES70ValidationError("bridge registry hash does not match its entries") + + +def validate_cross_source_decision( + decision: CrossSourceComparabilityDecision, + request: CrossSourceComparabilityRequest, + *, + bridge_registry: BridgeRegistry, + rule_registry: ComparabilityRuleRegistry, +) -> None: + if not isinstance(decision, CrossSourceComparabilityDecision): + raise RES70ValidationError("decision must be a CrossSourceComparabilityDecision") + if not isinstance(request, CrossSourceComparabilityRequest): + raise RES70ValidationError("request must be a CrossSourceComparabilityRequest") + if decision.request_hash != request.request_hash: + raise RES70ValidationError("decision request hash does not match request") + if decision.left_observation != request.left_observation: + raise RES70ValidationError("decision left observation reference was changed") + if decision.right_observation != request.right_observation: + raise RES70ValidationError("decision right observation reference was changed") + if decision.registry_version != rule_registry.registry_version: + raise RES70ValidationError("decision registry version does not match rule registry") + if decision.registry_hash != canonical_registry_hash( + bridge_registry=bridge_registry, + rule_registry=rule_registry, + ): + raise RES70ValidationError("decision registry hash does not match canonical registries") + if decision.rule_reference != RES70_CROSS_SOURCE_COMPARABILITY_RULE: + raise RES70ValidationError( + "decision must identify the registered RES-70 comparability rule" + ) + if len({item.dimension for item in decision.dimension_findings}) != len( + decision.dimension_findings + ): + raise RES70ValidationError("decision cannot contain duplicate dimension findings") + if decision.state.value == "COMPARABLE" and decision.bridge_application_reference is not None: + raise RES70ValidationError("direct comparability cannot carry bridge application") + if decision.state.value == "COMPARABLE_WITH_CONDITIONS" and ( + decision.bridge_application_reference is None and not decision.conditions + ): + raise RES70ValidationError("conditional comparability requires conditions or a bridge") + if decision.canonical_decision_hash != decision._content_hash(): + raise RES70ValidationError("decision hash does not match immutable decision content") + + +def validate_bridge_execution( + result: BridgeExecutionResult, + request_hash: str | None, + source_observation: ScientificMeasurementObservation, +) -> None: + if not isinstance(result, BridgeExecutionResult): + raise RES70ValidationError("result must be a BridgeExecutionResult") + if request_hash is not None and result.request_hash != request_hash: + raise RES70ValidationError("bridge execution request hash does not match request") + expected_source = ObservationAuthorityReference.from_observation(source_observation) + if result.source_observation != expected_source: + raise RES70ValidationError("bridge execution source reference does not match observation") + if result.transformed_observation is not None: + if result.processing_run is None or result.provenance is None: + raise RES70ValidationError("transformed bridge output must carry processing provenance") + if result.processing_run.output_entity_id != result.transformed_observation.observation_id: + raise RES70ValidationError( + "bridge processing output does not match transformed observation" + ) + if result.output_observation_hash != canonical_hash(result.transformed_observation): + raise RES70ValidationError("bridge output hash does not match transformed observation") + + +def validate_pairwise_decisions( + decisions: tuple[CrossSourceComparabilityDecision, ...], +) -> None: + """Validate pair uniqueness without offering any transitive closure operation.""" + + pairs = tuple(decision.pair_key for decision in decisions) + if len(set(pairs)) != len(pairs): + raise RES70ValidationError("pairwise decision collection contains duplicate pairs") + # Deliberately no graph traversal or reachable/comparable inference exists here. + + +__all__ = [ + "RES70ValidationError", + "build_res70_refusal", + "resolve_observation_reference", + "validate_bridge_execution", + "validate_bridge_registration", + "validate_bridge_registry", + "validate_cross_source_decision", + "validate_cross_source_request", + "validate_pairwise_decisions", +] diff --git a/tests/test_res70_bridges.py b/tests/test_res70_bridges.py new file mode 100644 index 0000000..5363026 --- /dev/null +++ b/tests/test_res70_bridges.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +from dataclasses import replace + +import dynamislm.comparability.res70_registry as res70_registry +from dynamislm import ( + BridgeApplicationRequest, + BridgeAuthorityOrigin, + BridgeInvertibility, + BridgeMode, + BridgeRegistration, + CrossSourceComparabilityRequest, + InstanceIdentifier, + MetadataEntry, + ObservationAuthorityReference, + RegistryReference, + ScientificIdentifier, + SemanticIdentityKey, + UnitReference, + assess_cross_source_comparability, + execute_registered_bridge, +) +from dynamislm.comparability import ( + RES70_AFFINE_BRIDGE_OPERATION, + RES70_BRIDGE_PROVENANCE_RULE, + BridgeExecutionResult, + BridgeExecutionStatus, + BridgeRegistry, +) +from dynamislm.measurement.observation import ScientificMeasurementObservation +from dynamislm.measurement.taxonomy import ValueOrigin +from test_kernel import _derived_observation + + +def _reference(object_type: str, key: str, label: str) -> RegistryReference: + return RegistryReference(ScientificIdentifier("dynamislm", object_type, key, "1.0.0"), label) + + +def _unit() -> UnitReference: + return UnitReference(ScientificIdentifier("dynamislm", "unit", "meter", "1.0.0"), "m") + + +def _bridge_fixture() -> tuple[ + ScientificMeasurementObservation, + ScientificMeasurementObservation, + BridgeRegistration, + BridgeRegistry, + RegistryReference, +]: + unit = _unit() + source = _derived_observation("res70-bridge-source", value=1.0) + source_identity = replace( + source.identity, + processing=replace(source.identity.processing, unit=unit), + ) + source = replace(source, identity=source_identity, result=replace(source.result, unit=unit)) + target_identity = replace( + source_identity, + identity_id=ScientificIdentifier("dynamislm", "measurement", "target-identity", "1.0.0"), + acquisition=replace( + source_identity.acquisition, + device=_reference("device", "target-device", "Target device"), + ), + ) + target = _derived_observation("res70-bridge-target", value=3.0) + target = replace(target, identity=target_identity, result=replace(target.result, unit=unit)) + claim = _reference("claim-intent", "longitudinal-change", "Longitudinal change") + bridge = BridgeRegistration( + bridge_reference=_reference("comparability-bridge", "device-v1", "Device bridge"), + source_semantic_key=SemanticIdentityKey.from_identity(source_identity), + target_semantic_key=SemanticIdentityKey.from_identity(target_identity), + claim_scope=(claim,), + bridge_mode=BridgeMode.NUMERICAL_TRANSFORMATION, + transformation_operation=RES70_AFFINE_BRIDGE_OPERATION, + source_units=(unit,), + target_units=(unit,), + fixed_parameters=(MetadataEntry("scale", 2.0), MetadataEntry("offset", 1.0)), + domain_constraints=(), + applicability_conditions=(MetadataEntry("domain", "validated"),), + method_version=_reference("bridge-method", "device-v1", "Device bridge method"), + evidence_references=(_reference("evidence", "device-study", "Device study"),), + evidence_applicability=(), + uncertainty_model=None, + invertibility=BridgeInvertibility.EXACT, + lossiness_description=None, + provenance_rule=RES70_BRIDGE_PROVENANCE_RULE, + registry_version="1.0.0", + authority_origin=BridgeAuthorityOrigin.PRODUCTION, + ) + registry = BridgeRegistry(entries=(bridge,)) + return source, target, bridge, registry, claim + + +def test_registered_numeric_bridge_creates_new_value_and_provenance() -> None: + source, target, bridge, registry, claim = _bridge_fixture() + original_registry = res70_registry.CANONICAL_BRIDGE_REGISTRY + res70_registry.CANONICAL_BRIDGE_REGISTRY = registry + try: + result = execute_registered_bridge( + BridgeApplicationRequest( + request_id=InstanceIdentifier("bridge-request", "res70-positive"), + source_observation=ObservationAuthorityReference.from_observation(source), + bridge_reference=bridge.bridge_reference, + claim_intent=claim, + target_identity=target.identity, + ), + source, + ) + finally: + res70_registry.CANONICAL_BRIDGE_REGISTRY = original_registry + + assert isinstance(result, BridgeExecutionResult) + assert result.status is BridgeExecutionStatus.EXECUTED + assert result.transformed_observation is not None + assert result.transformed_observation.result.value.value == 3.0 # type: ignore[union-attr] + assert ( + result.transformed_observation.result.classification.value_origin + is ValueOrigin.DYNAMISLM_DERIVED + ) + assert result.processing_run is not None + assert result.provenance is not None + assert result.output_observation_hash is not None + assert result.transformed_observation.observation_id != source.observation_id + + +def test_executed_bridge_supports_conditional_pairwise_comparability() -> None: + source, target, bridge, registry, claim = _bridge_fixture() + original_registry = res70_registry.CANONICAL_BRIDGE_REGISTRY + res70_registry.CANONICAL_BRIDGE_REGISTRY = registry + try: + execution = execute_registered_bridge( + BridgeApplicationRequest( + request_id=InstanceIdentifier("bridge-request", "res70-comparison"), + source_observation=ObservationAuthorityReference.from_observation(source), + bridge_reference=bridge.bridge_reference, + claim_intent=claim, + target_identity=target.identity, + ), + source, + ) + request = CrossSourceComparabilityRequest( + request_id=InstanceIdentifier( + "cross-source-comparability-request", "res70-bridge-comparison" + ), + left_observation=ObservationAuthorityReference.from_observation(source), + right_observation=ObservationAuthorityReference.from_observation(target), + claim_intent=claim, + ) + decision = assess_cross_source_comparability( + request, + (source, target), + bridge_execution=execution, # type: ignore[arg-type] + ) + finally: + res70_registry.CANONICAL_BRIDGE_REGISTRY = original_registry + + assert isinstance(execution, BridgeExecutionResult) + assert decision.state.value == "COMPARABLE_WITH_CONDITIONS" + assert decision.bridge_application_reference == bridge.bridge_reference + + +def test_declared_bridge_without_execution_never_supports_transformed_values() -> None: + source, target, bridge, registry, claim = _bridge_fixture() + original_registry = res70_registry.CANONICAL_BRIDGE_REGISTRY + res70_registry.CANONICAL_BRIDGE_REGISTRY = registry + try: + request = CrossSourceComparabilityRequest( + request_id=InstanceIdentifier("cross-source-comparability-request", "res70-unexecuted"), + left_observation=ObservationAuthorityReference.from_observation(source), + right_observation=ObservationAuthorityReference.from_observation(target), + claim_intent=claim, + ) + decision = assess_cross_source_comparability(request, (source, target)) + finally: + res70_registry.CANONICAL_BRIDGE_REGISTRY = original_registry + + assert decision.state.value == "REQUIRES_TRANSFORMATION" + assert "RES70_BRIDGE_NOT_EXECUTED" in decision.reason_codes + assert decision.transformations_required diff --git a/tests/test_res70_comparability.py b/tests/test_res70_comparability.py new file mode 100644 index 0000000..d7f0ede --- /dev/null +++ b/tests/test_res70_comparability.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +from dataclasses import replace + +import pytest + +import dynamislm.comparability.res70_registry as res70_registry +from dynamislm import ( + BridgeAuthorityOrigin, + ComparabilityDimension, + CrossSourceComparabilityRequest, + InstanceIdentifier, + ObservationAuthorityReference, + RegistryReference, + ScientificIdentifier, + UnitReference, + assess_cross_source_comparability, + validate_cross_source_decision, +) +from dynamislm.comparability import ( + ComparabilityState, + RES70ComparabilityAuthorityError, + validate_pairwise_decisions, +) +from dynamislm.measurement.observation import ScientificMeasurementObservation +from test_kernel import _derived_observation + + +def _reference(object_type: str, key: str, label: str) -> RegistryReference: + return RegistryReference(ScientificIdentifier("dynamislm", object_type, key, "1.0.0"), label) + + +def _claim() -> RegistryReference: + return _reference("claim-intent", "longitudinal-change", "Longitudinal change") + + +def _unit() -> UnitReference: + return UnitReference(ScientificIdentifier("dynamislm", "unit", "meter", "1.0.0"), "m") + + +def _observation_pair() -> tuple[ + ScientificMeasurementObservation, ScientificMeasurementObservation +]: + unit = _unit() + left = _derived_observation("res70-compare-left", value=1.0) + identity = replace(left.identity, processing=replace(left.identity.processing, unit=unit)) + left = replace(left, identity=identity, result=replace(left.result, unit=unit)) + right = _derived_observation("res70-compare-right", value=2.0) + right = replace(right, identity=identity, result=replace(right.result, unit=unit)) + return left, right + + +def _request( + left: ScientificMeasurementObservation, + right: ScientificMeasurementObservation, +) -> CrossSourceComparabilityRequest: + return CrossSourceComparabilityRequest( + request_id=InstanceIdentifier("cross-source-comparability-request", "res70-test"), + left_observation=ObservationAuthorityReference.from_observation(left), + right_observation=ObservationAuthorityReference.from_observation(right), + claim_intent=_claim(), + ) + + +def test_identical_complete_measurements_are_directly_comparable() -> None: + left, right = _observation_pair() + request = _request(left, right) + + decision = assess_cross_source_comparability(request, (left, right)) + + assert decision.state is ComparabilityState.COMPARABLE + assert decision.bridge_application_reference is None + assert all( + item.status in (item.status.MATCH, item.status.NOT_APPLICABLE) + for item in decision.dimension_findings + ) + validate_cross_source_decision( + decision, + request, + bridge_registry=res70_registry.CANONICAL_BRIDGE_REGISTRY, + rule_registry=res70_registry.RES70_COMPARABILITY_RULE_REGISTRY, + ) + + +def test_same_label_different_measurand_is_not_comparable() -> None: + left, right = _observation_pair() + changed_semantic = replace( + right.identity.semantic, + measurand=_reference("measurand", "different", "Same display label"), + ) + right = replace(right, identity=replace(right.identity, semantic=changed_semantic)) + request = _request(left, right) + + decision = assess_cross_source_comparability(request, (left, right)) + + assert decision.state is ComparabilityState.NOT_COMPARABLE + assert "MEASURAND_MISMATCH" in decision.reason_codes + assert any( + item.dimension is ComparabilityDimension.MEASURAND and item.status.value == "MISMATCH" + for item in decision.dimension_findings + ) + + +def test_tampered_observation_hash_cannot_mint_comparability() -> None: + left, right = _observation_pair() + request = replace( + _request(left, right), + left_observation=replace( + ObservationAuthorityReference.from_observation(left), + result_hash="sha256:" + "0" * 64, + ), + ) + + with pytest.raises(RES70ComparabilityAuthorityError, match="exact observation"): + assess_cross_source_comparability(request, (left, right)) + + +def test_pairwise_validation_rejects_duplicate_pairs_without_transitive_closure() -> None: + left, right = _observation_pair() + request = _request(left, right) + decision = assess_cross_source_comparability(request, (left, right)) + + validate_pairwise_decisions((decision,)) + with pytest.raises(ValueError, match="duplicate pairs"): + validate_pairwise_decisions((decision, decision)) + + +def test_caller_supplied_synthetic_registry_cannot_authorize_comparability() -> None: + left, right = _observation_pair() + bridge_key = _reference("comparability-bridge", "synthetic", "Synthetic bridge") + synthetic_registry = res70_registry.BridgeRegistry( + authority_origin=BridgeAuthorityOrigin.SYNTHETIC_TEST.value + ) + request = _request(left, right) + + with pytest.raises(RES70ComparabilityAuthorityError, match="caller-supplied"): + assess_cross_source_comparability( + request, + (left, right), + bridge_registry=synthetic_registry, + ) + + assert bridge_key.stable_id not in { + item.bridge_reference.stable_id for item in synthetic_registry.entries + } From f9f30dd69b9d149c0e3ad8f1d1aef7fe30b47254 Mon Sep 17 00:00:00 2001 From: Julio Rodriguez <144072916+Litju@users.noreply.github.com> Date: Fri, 18 Sep 2026 19:49:29 +0000 Subject: [PATCH 04/18] feat(res70): implement analysis capability validation --- src/dynamislm/__init__.py | 32 +++ src/dynamislm/analysis/__init__.py | 35 +++ src/dynamislm/analysis/authority.py | 260 +++++++++++++++++ src/dynamislm/analysis/models.py | 109 +++++++- src/dynamislm/analysis/registry.py | 284 +++++++++++++++++++ src/dynamislm/analysis/validation.py | 354 ++++++++++++++++++++++++ tests/test_res70_analysis_capability.py | 118 ++++++++ 7 files changed, 1189 insertions(+), 3 deletions(-) create mode 100644 src/dynamislm/analysis/authority.py create mode 100644 src/dynamislm/analysis/registry.py create mode 100644 src/dynamislm/analysis/validation.py create mode 100644 tests/test_res70_analysis_capability.py diff --git a/src/dynamislm/__init__.py b/src/dynamislm/__init__.py index bc0ca2a..6c61fd7 100644 --- a/src/dynamislm/__init__.py +++ b/src/dynamislm/__init__.py @@ -1,17 +1,33 @@ """DynamisLM's generic scientific-contract kernel.""" from dynamislm.analysis import ( + CANONICAL_ANALYSIS_CAPABILITY_REGISTRY, + RES70_ANALYSIS_REGISTRY_VERSION, + RES70_CAPABILITIES, + RES70_CAPABILITY_REGISTRY, AnalysisAuthorization, AnalysisAuthorizationRequest, AnalysisAuthorizationStatus, AnalysisCapability, AnalysisCapabilityDisposition, + AnalysisCapabilityRegistry, AnalysisClass, AnalysisEstimandLevel, AnalysisLevelIdentity, AnalysisUnit, AnalysisUnitOfAnalysis, + AnalysisValidationError, EstimandLevel, + authorize_analysis, + is_canonical_analysis_registry, + validate_analysis_authorization, + validate_analysis_capability_registry, + validate_comparability_authority, + validate_evidence_applicability, + validate_exact_support, + validate_level_of_analysis, + validate_observation_hashes, + validate_support_shape, ) from dynamislm.claims import ( ClaimAuthorityResult, @@ -505,15 +521,18 @@ "AnalysisAuthorizationRequest", "AnalysisAuthorizationStatus", "AnalysisCapability", + "AnalysisCapabilityRegistry", "AnalysisCapabilityDisposition", "AnalysisClass", "AnalysisEstimandLevel", "AnalysisLevelIdentity", "AnalysisUnit", "AnalysisUnitOfAnalysis", + "AnalysisValidationError", "ApplicabilityAssessment", "ApplicabilityAxis", "BUILD_LONGITUDINAL_SOURCE_MANIFEST", + "CANONICAL_ANALYSIS_CAPABILITY_REGISTRY", "CANONICAL_POPULATION_DIMENSIONS", "CANONICAL_POPULATION_QUALIFICATION_METHOD", "CANONICAL_SOURCE_QUALIFICATION_METHOD", @@ -528,6 +547,9 @@ "RES62_REGISTRY_VERSION", "RES62_SOFTWARE_VERSION", "RES63_RUNTIME_AUTHORITY_ID", + "RES70_ANALYSIS_REGISTRY_VERSION", + "RES70_CAPABILITIES", + "RES70_CAPABILITY_REGISTRY", "SERIALIZATION_VERSION", "AcquisitionError", "AcquisitionIdentity", @@ -737,6 +759,7 @@ "acquire_url", "assert_data_path_contained", "assert_external_data_root", + "authorize_analysis", "build_canonical_source", "build_claim_evidence_applicability", "build_complete_multi_source_provenance_graph", @@ -777,6 +800,7 @@ "graph_reaches", "inspect_tabular_schema", "iter_tab_rows", + "is_canonical_analysis_registry", "load_committed_dataset_registry", "load_dataset_registry", "migrate_scientific_observation_to_longitudinal_entry", @@ -800,6 +824,14 @@ "validate_cross_source_decision", "validate_cross_source_request", "validate_pairwise_decisions", + "validate_analysis_authorization", + "validate_analysis_capability_registry", + "validate_comparability_authority", + "validate_evidence_applicability", + "validate_exact_support", + "validate_level_of_analysis", + "validate_observation_hashes", + "validate_support_shape", "validate_claim_evidence_applicability", "validate_canonical_replay", "validate_complete_multi_source_provenance_graph", diff --git a/src/dynamislm/analysis/__init__.py b/src/dynamislm/analysis/__init__.py index 22bedd0..eb0e9bf 100644 --- a/src/dynamislm/analysis/__init__.py +++ b/src/dynamislm/analysis/__init__.py @@ -1,5 +1,6 @@ """RES-70 deterministic analysis-capability contracts.""" +from dynamislm.analysis.authority import authorize_analysis, validate_analysis_authorization from dynamislm.analysis.models import ( AnalysisAuthorization, AnalysisAuthorizationRequest, @@ -13,17 +14,51 @@ AnalysisUnitOfAnalysis, EstimandLevel, ) +from dynamislm.analysis.registry import ( + CANONICAL_ANALYSIS_CAPABILITY_REGISTRY, + RES70_ANALYSIS_REGISTRY_VERSION, + RES70_CAPABILITIES, + RES70_CAPABILITY_REGISTRY, + AnalysisCapabilityRegistry, + is_canonical_analysis_registry, +) +from dynamislm.analysis.validation import ( + AnalysisValidationError, + validate_analysis_capability_registry, + validate_comparability_authority, + validate_evidence_applicability, + validate_exact_support, + validate_level_of_analysis, + validate_observation_hashes, + validate_support_shape, +) __all__ = [ + "CANONICAL_ANALYSIS_CAPABILITY_REGISTRY", + "RES70_ANALYSIS_REGISTRY_VERSION", + "RES70_CAPABILITIES", + "RES70_CAPABILITY_REGISTRY", "AnalysisAuthorization", "AnalysisAuthorizationRequest", "AnalysisAuthorizationStatus", "AnalysisCapability", "AnalysisCapabilityDisposition", + "AnalysisCapabilityRegistry", "AnalysisClass", "AnalysisEstimandLevel", "AnalysisLevelIdentity", "AnalysisUnit", "AnalysisUnitOfAnalysis", + "AnalysisValidationError", "EstimandLevel", + "authorize_analysis", + "is_canonical_analysis_registry", + "validate_analysis_authorization", + "validate_analysis_capability_registry", + "validate_comparability_authority", + "validate_evidence_applicability", + "validate_exact_support", + "validate_level_of_analysis", + "validate_observation_hashes", + "validate_support_shape", ] diff --git a/src/dynamislm/analysis/authority.py b/src/dynamislm/analysis/authority.py new file mode 100644 index 0000000..0f0c972 --- /dev/null +++ b/src/dynamislm/analysis/authority.py @@ -0,0 +1,260 @@ +"""Deterministic RES-70 analysis-capability authorization.""" + +from __future__ import annotations + +from dynamislm.analysis.models import ( + AnalysisAuthorization, + AnalysisAuthorizationRequest, + AnalysisAuthorizationStatus, + AnalysisCapability, +) +from dynamislm.analysis.registry import ( + CANONICAL_ANALYSIS_CAPABILITY_REGISTRY, + AnalysisCapabilityRegistry, +) +from dynamislm.analysis.validation import ( + AnalysisValidationError, + validate_analysis_capability_registry, + validate_comparability_authority, + validate_evidence_applicability, + validate_exact_support, + validate_level_of_analysis, + validate_observation_hashes, + validate_support_shape, +) +from dynamislm.comparability.res70_validation import build_res70_refusal +from dynamislm.longitudinal.statistics.models import StatisticalOperationDisposition +from dynamislm.longitudinal.statistics.registry import ( + RES69_OPERATION_REGISTRY, + RES69_SCALE_REGISTRY, + RES69_SCALE_SEMANTICS_AUTHORITY, +) +from dynamislm.longitudinal.statistics.validation import ( + resolve_scale_semantics, + validate_method_comparison_authority, + validate_reliability_authority, +) +from dynamislm.refusal.models import RefusalClass, RefusalResult +from dynamislm.serialization import canonical_hash + + +def _refusal_class(code: str) -> RefusalClass: + if code in { + "RES70_COMPARABILITY_AUTHORITY_MISSING", + "RES70_BRIDGE_NOT_EXECUTED", + "RES70_BRIDGE_REQUIRED", + }: + return RefusalClass.COMPARABILITY_UNESTABLISHED + if code in { + "RES70_INSUFFICIENT_EVIDENCE_APPLICABILITY", + "RES70_APPLICABILITY_AXIS_UNASSESSED", + }: + return RefusalClass.EVIDENCE_SCOPE_UNSUPPORTED + if code in { + "RES70_WRONG_LEVEL_OF_ANALYSIS", + "RES70_PSEUDOREPLICATION_RISK", + }: + return RefusalClass.ANALYSIS_DESIGN_MISMATCH + if code in {"RES70_REGISTRY_INTEGRITY_FAILURE", "COMPUTATION_NOT_REGISTERED"}: + return RefusalClass.COMPUTATION_NOT_REGISTERED + return RefusalClass.DATA_ADEQUACY_INSUFFICIENT + + +def _refuse( + request: AnalysisAuthorizationRequest, + code: str, + message: str, + *, + missing_information: tuple[str, ...] = (), +) -> RefusalResult: + support = request.support + observation_ids = support.source_observation_ids if support is not None else () + return build_res70_refusal( + f"analysis:{request.analysis_class.value}", + code, + refusal_class=_refusal_class(code), + missing_information=missing_information or (message,), + safe_descriptions=( + "the exact source observations remain independently describable under their own " + "identity", + ), + observation_ids=observation_ids, + ) + + +def _operation_check(capability: AnalysisCapability) -> None: + operation = capability.registered_operation_reference + if operation is None: + raise AnalysisValidationError( + "analysis capability has no registered RES-69 operation", + "COMPUTATION_NOT_REGISTERED", + ("registered deterministic operation",), + ) + registered = RES69_OPERATION_REGISTRY.get(operation) + if ( + registered is None + or registered.disposition is not StatisticalOperationDisposition.IMPLEMENTED + ): + raise AnalysisValidationError( + "requested analysis operation is deferred, represented-only, rejected, or absent", + "COMPUTATION_NOT_REGISTERED", + (operation.stable_id,), + ) + + +def _statistical_authority_check( + request: AnalysisAuthorizationRequest, + capability: AnalysisCapability, + support: object, +) -> tuple[str, ...]: + from dynamislm.longitudinal.statistics.models import StatisticalSupport + + if not isinstance(support, StatisticalSupport): + raise AnalysisValidationError( + "exact StatisticalSupport is required", + "RES70_STATISTICAL_AUTHORITY_INSUFFICIENT", + ) + hashes: list[str] = [] + if request.reliability_authority is not None: + hashes.append(canonical_hash(request.reliability_authority)) + if request.reliability_assessment is not None: + hashes.append(canonical_hash(request.reliability_assessment)) + if request.method_comparison_authority is not None: + hashes.append(canonical_hash(request.method_comparison_authority)) + if request.scale_semantics is not None: + hashes.append(canonical_hash(request.scale_semantics)) + required = {item.stable_id for item in capability.required_statistical_authority} + if any("reliability-design" in item for item in required): + if request.reliability_authority is None: + raise AnalysisValidationError( + "source-bound reliability design authority is required", + "RES70_STATISTICAL_AUTHORITY_INSUFFICIENT", + ("source-bound ReliabilityDesignAuthority",), + ) + try: + validate_reliability_authority(request.reliability_authority, support) + except (TypeError, ValueError) as exc: + raise AnalysisValidationError( + str(exc), + "RES70_STATISTICAL_AUTHORITY_INSUFFICIENT", + ) from exc + if any("method-comparison-design" in item for item in required): + if request.method_comparison_authority is None: + raise AnalysisValidationError( + "source-bound method-comparison authority is required", + "RES70_STATISTICAL_AUTHORITY_INSUFFICIENT", + ("source-bound MethodComparisonDesignAuthority",), + ) + try: + validate_method_comparison_authority(request.method_comparison_authority, support) + except (TypeError, ValueError) as exc: + raise AnalysisValidationError( + str(exc), + "RES70_STATISTICAL_AUTHORITY_INSUFFICIENT", + ) from exc + if any( + item.stable_id == RES69_SCALE_SEMANTICS_AUTHORITY.stable_id + for item in capability.required_statistical_authority + ): + try: + resolve_scale_semantics(support, registry=RES69_SCALE_REGISTRY) + except (TypeError, ValueError) as exc: + raise AnalysisValidationError( + str(exc), + "RES70_STATISTICAL_AUTHORITY_INSUFFICIENT", + ("canonical production RES-69 scale authority",), + ) from exc + return tuple(dict.fromkeys(hashes)) + + +def authorize_analysis( + request: AnalysisAuthorizationRequest, + *, + registry: AnalysisCapabilityRegistry = CANONICAL_ANALYSIS_CAPABILITY_REGISTRY, +) -> AnalysisAuthorization | RefusalResult: + """Authorize only registered admissible analysis requests.""" + + try: + validate_analysis_capability_registry(registry) + capability = registry.resolve(request.analysis_class) + if capability is None: + raise AnalysisValidationError( + "analysis class is not present in the canonical capability registry", + "COMPUTATION_NOT_REGISTERED", + (request.analysis_class.value,), + ) + _operation_check(capability) + support = validate_exact_support(request) + identity_hashes = validate_observation_hashes(request, support) + validate_level_of_analysis(request, capability, support) + validate_support_shape(request, capability, support) + comparability_hashes = validate_comparability_authority(request, capability, support) + validate_evidence_applicability(request, capability) + statistical_authority_hashes = _statistical_authority_check( + request, + capability, + support, + ) + except AnalysisValidationError as exc: + return _refuse( + request, + exc.code, + str(exc), + missing_information=exc.missing_information, + ) + + operation = capability.registered_operation_reference + estimator = capability.estimator_reference + assert operation is not None + return AnalysisAuthorization.create( + status=AnalysisAuthorizationStatus.AUTHORIZED, + request_id=request.request_id, + analysis_class=request.analysis_class, + capability_reference=capability.capability_reference, + capability_hash=capability.canonical_capability_hash, + operation_reference=operation, + estimator_reference=estimator, + support_hashes=(support.canonical_support_hash,), + identity_hashes=identity_hashes, + comparability_hashes=comparability_hashes, + statistical_authority_hashes=statistical_authority_hashes, + resolved_level=request.requested_level, + registry_version=registry.registry_version, + software_version="dynamislm-res70-1.0.0", + reason_codes=(), + missing_information=(), + safe_descriptions=( + "the registered analysis capability is authorized at the requested level of analysis", + ), + ) + + +def validate_analysis_authorization( + authorization: AnalysisAuthorization, + request: AnalysisAuthorizationRequest, + *, + registry: AnalysisCapabilityRegistry = CANONICAL_ANALYSIS_CAPABILITY_REGISTRY, +) -> None: + if not isinstance(authorization, AnalysisAuthorization): + raise ValueError("authorization must be an AnalysisAuthorization") + if authorization.status is not AnalysisAuthorizationStatus.AUTHORIZED: + raise ValueError("only authorized analysis records may pass validation") + validate_analysis_capability_registry(registry) + capability = registry.resolve(request.analysis_class) + if capability is None: + raise ValueError("analysis capability is absent from canonical registry") + if authorization.request_id != request.request_id: + raise ValueError("authorization request ID does not match request") + if authorization.capability_hash != capability.canonical_capability_hash: + raise ValueError("authorization capability hash does not match registry") + expected_support_hashes = ( + (request.support.canonical_support_hash,) if request.support is not None else () + ) + if authorization.support_hashes != expected_support_hashes: + raise ValueError("authorization support hash does not match request") + + +__all__ = [ + "authorize_analysis", + "validate_analysis_authorization", +] diff --git a/src/dynamislm/analysis/models.py b/src/dynamislm/analysis/models.py index 393ff9b..0b0959f 100644 --- a/src/dynamislm/analysis/models.py +++ b/src/dynamislm/analysis/models.py @@ -8,7 +8,13 @@ from dynamislm.comparability.models import ComparabilityState from dynamislm.comparability.res70_models import CrossSourceComparabilityDecision from dynamislm.evidence.res70 import ClaimEvidenceApplicability -from dynamislm.longitudinal.statistics.models import StatisticalSupport +from dynamislm.longitudinal.statistics.models import ( + MeasurementScaleSemantics, + MethodComparisonDesignAuthority, + ReliabilityAssumptionAssessment, + ReliabilityDesignAuthority, + StatisticalSupport, +) from dynamislm.measurement.identity import ( InstanceIdentifier, MetadataEntry, @@ -252,6 +258,10 @@ class AnalysisAuthorizationRequest: evidence_applicability: ClaimEvidenceApplicability | None context_references: tuple[RegistryReference, ...] requested_parameters: tuple[MetadataEntry, ...] = () + reliability_authority: ReliabilityDesignAuthority | None = None + reliability_assessment: ReliabilityAssumptionAssessment | None = None + method_comparison_authority: MethodComparisonDesignAuthority | None = None + scale_semantics: MeasurementScaleSemantics | None = None def __post_init__(self) -> None: _require_instance(self.request_id, InstanceIdentifier, "request_id") @@ -260,8 +270,9 @@ def __post_init__(self) -> None: _require_enum(self.analysis_class, AnalysisClass, "analysis_class") _require_optional_instance(self.support, StatisticalSupport, "support") _require_optional_instance(self.support_reference, RegistryReference, "support_reference") - if self.support is None and self.support_reference is None: - raise ValueError("analysis authorization requires exact support or support_reference") + # An empty support slot is a valid request shape for a fail-closed + # refusal: the authority, not dataclass construction, reports the + # missing prerequisite to the caller/LM. _require_tuple_items(self.observations, ScientificMeasurementObservation, "observations") _require_string_tuple(self.identity_hashes, "identity_hashes") for item in self.identity_hashes: @@ -279,6 +290,26 @@ def __post_init__(self) -> None: ) _require_tuple_items(self.context_references, RegistryReference, "context_references") _require_tuple_items(self.requested_parameters, MetadataEntry, "requested_parameters") + _require_optional_instance( + self.reliability_authority, + ReliabilityDesignAuthority, + "reliability_authority", + ) + _require_optional_instance( + self.reliability_assessment, + ReliabilityAssumptionAssessment, + "reliability_assessment", + ) + _require_optional_instance( + self.method_comparison_authority, + MethodComparisonDesignAuthority, + "method_comparison_authority", + ) + _require_optional_instance( + self.scale_semantics, + MeasurementScaleSemantics, + "scale_semantics", + ) @property def request_hash(self) -> str: @@ -307,6 +338,7 @@ class AnalysisAuthorization: support_hashes: tuple[str, ...] identity_hashes: tuple[str, ...] comparability_hashes: tuple[str, ...] + statistical_authority_hashes: tuple[str, ...] resolved_level: AnalysisLevelIdentity registry_version: str software_version: str @@ -337,6 +369,7 @@ def __post_init__(self) -> None: ("support_hashes", self.support_hashes), ("identity_hashes", self.identity_hashes), ("comparability_hashes", self.comparability_hashes), + ("statistical_authority_hashes", self.statistical_authority_hashes), ): require_tuple(values, field_name) for item in values: @@ -367,6 +400,7 @@ def __post_init__(self) -> None: "support_hashes": self.support_hashes, "identity_hashes": self.identity_hashes, "comparability_hashes": self.comparability_hashes, + "statistical_authority_hashes": self.statistical_authority_hashes, "resolved_level": self.resolved_level, "registry_version": self.registry_version, "software_version": self.software_version, @@ -386,6 +420,75 @@ def __post_init__(self) -> None: if self.authorization_id != expected_id: raise ValueError("authorization_id does not match immutable authorization content") + @classmethod + def create( + cls, + *, + status: AnalysisAuthorizationStatus, + request_id: InstanceIdentifier, + analysis_class: AnalysisClass, + capability_reference: RegistryReference, + capability_hash: str, + operation_reference: RegistryReference | None, + estimator_reference: RegistryReference | None, + support_hashes: tuple[str, ...], + identity_hashes: tuple[str, ...], + comparability_hashes: tuple[str, ...], + statistical_authority_hashes: tuple[str, ...], + resolved_level: AnalysisLevelIdentity, + registry_version: str, + software_version: str, + reason_codes: tuple[str, ...], + missing_information: tuple[str, ...], + safe_descriptions: tuple[str, ...], + refusal_result: RefusalResult | None = None, + ) -> AnalysisAuthorization: + content = { + "status": status, + "request_id": request_id, + "analysis_class": analysis_class, + "capability_reference": capability_reference, + "capability_hash": capability_hash, + "operation_reference": operation_reference, + "estimator_reference": estimator_reference, + "support_hashes": support_hashes, + "identity_hashes": identity_hashes, + "comparability_hashes": comparability_hashes, + "statistical_authority_hashes": statistical_authority_hashes, + "resolved_level": resolved_level, + "registry_version": registry_version, + "software_version": software_version, + "reason_codes": reason_codes, + "missing_information": missing_information, + "safe_descriptions": safe_descriptions, + "refusal_result": refusal_result, + } + authorization_hash = canonical_hash(content) + return cls( + authorization_id=InstanceIdentifier( + "analysis-authorization", authorization_hash.removeprefix(_SHA256_PREFIX) + ), + status=status, + request_id=request_id, + analysis_class=analysis_class, + capability_reference=capability_reference, + capability_hash=capability_hash, + operation_reference=operation_reference, + estimator_reference=estimator_reference, + support_hashes=support_hashes, + identity_hashes=identity_hashes, + comparability_hashes=comparability_hashes, + statistical_authority_hashes=statistical_authority_hashes, + resolved_level=resolved_level, + registry_version=registry_version, + software_version=software_version, + reason_codes=reason_codes, + missing_information=missing_information, + safe_descriptions=safe_descriptions, + refusal_result=refusal_result, + authorization_hash=authorization_hash, + ) + @property def canonical_authorization_hash(self) -> str: assert self.authorization_hash is not None diff --git a/src/dynamislm/analysis/registry.py b/src/dynamislm/analysis/registry.py new file mode 100644 index 0000000..74023ea --- /dev/null +++ b/src/dynamislm/analysis/registry.py @@ -0,0 +1,284 @@ +"""Canonical RES-70 analysis-capability matrix above RES-69.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from dynamislm.analysis.models import ( + AnalysisCapability, + AnalysisCapabilityDisposition, + AnalysisClass, + AnalysisEstimandLevel, +) +from dynamislm.comparability.models import ComparabilityState +from dynamislm.longitudinal.statistics.registry import ( + MIXED_EFFECTS, + REPEATED_MEASURES_CORRELATION, + RES69_ABSOLUTE_CHANGE_ESTIMATOR, + RES69_ABSOLUTE_CHANGE_OPERATION, + RES69_LOG_RATIO_ESTIMATOR, + RES69_LOG_RATIO_OPERATION, + RES69_METHOD_COMPARISON_DESIGN_OPERATION, + RES69_METHOD_COMPARISON_OPERATION, + RES69_REFERENCE_WINDOW_DEVIATION_OPERATION, + RES69_REFERENCE_Z_ESTIMATOR, + RES69_RELATIVE_CHANGE_ESTIMATOR, + RES69_RELATIVE_CHANGE_OPERATION, + RES69_RELIABILITY_ASSUMPTION_ASSESSMENT_OPERATION, + RES69_RELIABILITY_DESIGN_OPERATION, + RES69_SCALE_SEMANTICS_AUTHORITY, + RES69_TWO_REPLICATE_RANDOM_ERROR_OPERATION, +) +from dynamislm.measurement.identity import ( + RegistryReference, + ScientificIdentifier, + _require_instance, + _require_text, + _require_tuple_items, + require_tuple, +) +from dynamislm.serialization import canonical_hash, register_serializable_type + +RES70_ANALYSIS_REGISTRY_VERSION = "1.0.0" + + +def _reference(key: str, label: str) -> RegistryReference: + return RegistryReference( + ScientificIdentifier( + "dynamislm", + "analysis-capability", + key, + RES70_ANALYSIS_REGISTRY_VERSION, + ), + label, + ) + + +def _capability( + key: str, + analysis_class: AnalysisClass, + *, + operation: RegistryReference | None, + estimator: RegistryReference | None, + disposition: AnalysisCapabilityDisposition, + support_shape: tuple[str, ...], + level: tuple[AnalysisEstimandLevel, ...], + authority: tuple[RegistryReference, ...] = (), + evidence_axes: tuple[str, ...] = (), + context: tuple[str, ...] = (), + claim_floor: str | None, +) -> AnalysisCapability: + return AnalysisCapability( + capability_reference=_reference(key, key.replace("_", " ").title()), + analysis_class=analysis_class, + registered_operation_reference=operation, + estimator_reference=estimator, + disposition=disposition, + required_support_shape=support_shape, + required_identity_dimensions=("CONSTRUCT", "MEASURAND", "METRIC_DEFINITION", "UNIT"), + required_comparability_states=( + ComparabilityState.COMPARABLE, + ComparabilityState.COMPARABLE_WITH_CONDITIONS, + ), + required_bridge_execution=False, + required_level_of_analysis=level, + required_statistical_authority=authority, + required_evidence_axes=evidence_axes, + required_context=context, + output_claim_floor=claim_floor, + registry_version=RES70_ANALYSIS_REGISTRY_VERSION, + ) + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class AnalysisCapabilityRegistry: + entries: tuple[AnalysisCapability, ...] + registry_version: str = RES70_ANALYSIS_REGISTRY_VERSION + registry_hash: str | None = None + + def __post_init__(self) -> None: + require_tuple(self.entries, "entries") + _require_tuple_items(self.entries, AnalysisCapability, "entries") + _require_text(self.registry_version, "registry_version") + refs = tuple(item.capability_reference.stable_id for item in self.entries) + classes = tuple(item.analysis_class.value for item in self.entries) + if len(set(refs)) != len(refs) or len(set(classes)) != len(classes): + raise ValueError("RES70 capability registry cannot contain duplicate entries") + if any(item.registry_version != self.registry_version for item in self.entries): + raise ValueError("capability and registry versions must match") + expected = canonical_hash( + {"entries": self.entries, "registry_version": self.registry_version} + ) + if self.registry_hash is None: + object.__setattr__(self, "registry_hash", expected) + elif self.registry_hash != expected: + raise ValueError("capability registry hash does not match immutable entries") + + def resolve(self, analysis_class: AnalysisClass) -> AnalysisCapability | None: + if not isinstance(analysis_class, AnalysisClass): + raise ValueError("analysis_class must be an AnalysisClass") + matches = tuple(item for item in self.entries if item.analysis_class is analysis_class) + if len(matches) > 1: + raise ValueError("RES70_REGISTRY_INTEGRITY_FAILURE: duplicate analysis capability") + return matches[0] if matches else None + + def resolve_reference(self, reference: RegistryReference) -> AnalysisCapability | None: + _require_instance(reference, RegistryReference, "reference") + matches = tuple( + item + for item in self.entries + if item.capability_reference.stable_id == reference.stable_id + ) + if len(matches) > 1: + raise ValueError("RES70_REGISTRY_INTEGRITY_FAILURE: duplicate capability reference") + return matches[0] if matches else None + + @property + def canonical_hash(self) -> str: + assert self.registry_hash is not None + return self.registry_hash + + +RES70_CAPABILITIES = ( + _capability( + "scalar_absolute_change", + AnalysisClass.SCALAR_ABSOLUTE_CHANGE, + operation=RES69_ABSOLUTE_CHANGE_OPERATION, + estimator=RES69_ABSOLUTE_CHANGE_ESTIMATOR, + disposition=AnalysisCapabilityDisposition.IMPLEMENTED, + support_shape=("exactly-two-scalar-entries",), + level=(AnalysisEstimandLevel.WITHIN_ATHLETE,), + claim_floor="NUMERICAL_CHANGE", + ), + _capability( + "scalar_relative_change", + AnalysisClass.SCALAR_RELATIVE_CHANGE, + operation=RES69_RELATIVE_CHANGE_OPERATION, + estimator=RES69_RELATIVE_CHANGE_ESTIMATOR, + disposition=AnalysisCapabilityDisposition.IMPLEMENTED, + support_shape=("exactly-two-scalar-entries", "nonzero-denominator"), + level=(AnalysisEstimandLevel.WITHIN_ATHLETE,), + authority=(RES69_SCALE_SEMANTICS_AUTHORITY,), + claim_floor="NUMERICAL_CHANGE", + ), + _capability( + "scalar_log_change", + AnalysisClass.SCALAR_LOG_CHANGE, + operation=RES69_LOG_RATIO_OPERATION, + estimator=RES69_LOG_RATIO_ESTIMATOR, + disposition=AnalysisCapabilityDisposition.IMPLEMENTED, + support_shape=("exactly-two-scalar-entries", "strictly-positive-values"), + level=(AnalysisEstimandLevel.WITHIN_ATHLETE,), + authority=(RES69_SCALE_SEMANTICS_AUTHORITY,), + claim_floor="NUMERICAL_CHANGE", + ), + _capability( + "baseline_reference_window_deviation", + AnalysisClass.BASELINE_REFERENCE_WINDOW_DEVIATION, + operation=RES69_REFERENCE_WINDOW_DEVIATION_OPERATION, + estimator=RES69_REFERENCE_Z_ESTIMATOR, + disposition=AnalysisCapabilityDisposition.IMPLEMENTED, + support_shape=( + "one-current-entry", + "at-least-two-reference-entries", + "positive-reference-sd", + ), + level=(AnalysisEstimandLevel.WITHIN_ATHLETE,), + claim_floor="NUMERICAL_CHANGE", + ), + _capability( + "reliability_random_error_comparison", + AnalysisClass.RELIABILITY_RANDOM_ERROR_COMPARISON, + operation=RES69_TWO_REPLICATE_RANDOM_ERROR_OPERATION, + estimator=None, + disposition=AnalysisCapabilityDisposition.IMPLEMENTED, + support_shape=("two-replicate-pairs",), + level=(AnalysisEstimandLevel.WITHIN_ATHLETE,), + authority=( + RES69_RELIABILITY_DESIGN_OPERATION, + RES69_RELIABILITY_ASSUMPTION_ASSESSMENT_OPERATION, + ), + evidence_axes=("METHOD_VALIDITY", "STATISTICAL_ADEQUACY"), + claim_floor="CHANGE_RELATIVE_TO_MEASUREMENT_ERROR", + ), + _capability( + "method_agreement_summary", + AnalysisClass.METHOD_AGREEMENT_SUMMARY, + operation=RES69_METHOD_COMPARISON_OPERATION, + estimator=None, + disposition=AnalysisCapabilityDisposition.IMPLEMENTED, + support_shape=("one-pair-per-independent-subject",), + level=(AnalysisEstimandLevel.WITHIN_ATHLETE,), + authority=(RES69_METHOD_COMPARISON_DESIGN_OPERATION,), + evidence_axes=("METHOD_VALIDITY", "STATISTICAL_ADEQUACY"), + claim_floor="ASSOCIATION", + ), + _capability( + "within_athlete_association", + AnalysisClass.WITHIN_ATHLETE_ASSOCIATION, + operation=REPEATED_MEASURES_CORRELATION, + estimator=None, + disposition=AnalysisCapabilityDisposition.DEFERRED, + support_shape=("repeated-observations",), + level=(AnalysisEstimandLevel.WITHIN_ATHLETE,), + claim_floor="ASSOCIATION", + ), + _capability( + "between_athlete_association", + AnalysisClass.BETWEEN_ATHLETE_ASSOCIATION, + operation=None, + estimator=None, + disposition=AnalysisCapabilityDisposition.REJECTED, + support_shape=("one-independent-unit-per-athlete",), + level=(AnalysisEstimandLevel.BETWEEN_ATHLETE,), + claim_floor="ASSOCIATION", + ), + _capability( + "repeated_measures_analysis", + AnalysisClass.REPEATED_MEASURES_ANALYSIS, + operation=REPEATED_MEASURES_CORRELATION, + estimator=None, + disposition=AnalysisCapabilityDisposition.DEFERRED, + support_shape=("repeated-observations", "explicit-clustering"), + level=(AnalysisEstimandLevel.WITHIN_ATHLETE, AnalysisEstimandLevel.JOINT_MULTILEVEL), + claim_floor="ASSOCIATION", + ), + _capability( + "mixed_effects_analysis", + AnalysisClass.MIXED_EFFECTS_ANALYSIS, + operation=MIXED_EFFECTS, + estimator=None, + disposition=AnalysisCapabilityDisposition.DEFERRED, + support_shape=("repeated-observations", "explicit-random-effects"), + level=(AnalysisEstimandLevel.JOINT_MULTILEVEL,), + claim_floor="ASSOCIATION", + ), + _capability( + "cross_test_association", + AnalysisClass.CROSS_TEST_ASSOCIATION, + operation=None, + estimator=None, + disposition=AnalysisCapabilityDisposition.REJECTED, + support_shape=("distinct-test-identities", "temporal-or-lag-policy"), + level=(AnalysisEstimandLevel.WITHIN_ATHLETE, AnalysisEstimandLevel.BETWEEN_ATHLETE), + claim_floor="ASSOCIATION", + ), +) + +RES70_CAPABILITY_REGISTRY = AnalysisCapabilityRegistry(entries=RES70_CAPABILITIES) +CANONICAL_ANALYSIS_CAPABILITY_REGISTRY = RES70_CAPABILITY_REGISTRY + + +def is_canonical_analysis_registry(registry: AnalysisCapabilityRegistry) -> bool: + return registry is CANONICAL_ANALYSIS_CAPABILITY_REGISTRY + + +__all__ = [ + "CANONICAL_ANALYSIS_CAPABILITY_REGISTRY", + "RES70_ANALYSIS_REGISTRY_VERSION", + "RES70_CAPABILITIES", + "RES70_CAPABILITY_REGISTRY", + "AnalysisCapabilityRegistry", + "is_canonical_analysis_registry", +] diff --git a/src/dynamislm/analysis/validation.py b/src/dynamislm/analysis/validation.py new file mode 100644 index 0000000..c37ef88 --- /dev/null +++ b/src/dynamislm/analysis/validation.py @@ -0,0 +1,354 @@ +"""Deterministic prerequisite and hash validation for RES-70 analyses.""" + +from __future__ import annotations + +from collections import Counter + +from dynamislm.analysis.models import ( + AnalysisAuthorizationRequest, + AnalysisCapability, + AnalysisEstimandLevel, +) +from dynamislm.analysis.registry import ( + RES70_ANALYSIS_REGISTRY_VERSION, + AnalysisCapabilityRegistry, + is_canonical_analysis_registry, +) +from dynamislm.comparability.models import ComparabilityState +from dynamislm.comparability.res70_models import SemanticIdentityKey +from dynamislm.comparability.res70_validation import validate_pairwise_decisions +from dynamislm.evidence.res70 import ApplicabilityAxis +from dynamislm.longitudinal.statistics.models import StatisticalSupport +from dynamislm.longitudinal.statistics.support import ( + StatisticalConstraintError, + validate_statistical_support, +) +from dynamislm.measurement.result import ScalarValue +from dynamislm.serialization import canonical_hash + + +class AnalysisValidationError(ValueError): + """Raised for a deterministic RES-70 prerequisite failure.""" + + def __init__(self, message: str, code: str, missing_information: tuple[str, ...] = ()) -> None: + super().__init__(message) + self.code = code + self.missing_information = missing_information + + +def validate_analysis_capability_registry(registry: AnalysisCapabilityRegistry) -> None: + if not isinstance(registry, AnalysisCapabilityRegistry): + raise AnalysisValidationError( + "analysis capability registry must be typed", + "RES70_REGISTRY_INTEGRITY_FAILURE", + ) + if not is_canonical_analysis_registry(registry): + raise AnalysisValidationError( + "caller-supplied analysis capability registries cannot authorize analysis", + "RES70_REGISTRY_INTEGRITY_FAILURE", + ) + if registry.registry_version != RES70_ANALYSIS_REGISTRY_VERSION: + raise AnalysisValidationError( + "analysis capability registry version is not canonical", + "RES70_REGISTRY_INTEGRITY_FAILURE", + ) + expected = canonical_hash( + {"entries": registry.entries, "registry_version": registry.registry_version} + ) + if registry.canonical_hash != expected: + raise AnalysisValidationError( + "analysis capability registry hash does not match entries", + "RES70_REGISTRY_INTEGRITY_FAILURE", + ) + + +def validate_exact_support(request: AnalysisAuthorizationRequest) -> StatisticalSupport: + support = request.support + if support is None: + raise AnalysisValidationError( + "an exact StatisticalSupport object is required for RES-70 authorization", + "RES70_STATISTICAL_AUTHORITY_INSUFFICIENT", + ("exact StatisticalSupport snapshot",), + ) + try: + validate_statistical_support(support) + except StatisticalConstraintError as exc: + raise AnalysisValidationError( + str(exc), + "RES70_STATISTICAL_AUTHORITY_INSUFFICIENT", + getattr(exc, "missing_information", ()), + ) from exc + if request.support_reference is not None and request.support_reference.stable_id != ( + f"dynamislm:statistical-support:{support.canonical_support_id.value}@1.0.0" + ): + raise AnalysisValidationError( + "support reference does not identify the exact supplied support", + "RES70_REGISTRY_INTEGRITY_FAILURE", + ) + return support + + +def validate_observation_hashes( + request: AnalysisAuthorizationRequest, + support: StatisticalSupport, +) -> tuple[str, ...]: + expected = tuple( + canonical_hash(entry.observation.identity) for entry in support.included_entries + ) + if request.identity_hashes and request.identity_hashes != expected: + raise AnalysisValidationError( + "analysis request identity hashes do not match exact support", + "RES70_REGISTRY_INTEGRITY_FAILURE", + ) + if request.observations: + expected_ids = tuple(entry.source_observation_id for entry in support.included_entries) + actual_ids = tuple(item.observation_id for item in request.observations) + if actual_ids != expected_ids: + raise AnalysisValidationError( + "analysis observations do not match exact support ordering", + "RES70_REGISTRY_INTEGRITY_FAILURE", + ) + for observation in request.observations: + if canonical_hash(observation.identity) not in expected: + raise AnalysisValidationError( + "analysis observation identity is not part of exact support", + "RES70_REGISTRY_INTEGRITY_FAILURE", + ) + return expected + + +def _scalar_entries(support: StatisticalSupport) -> tuple[object, ...]: + return tuple( + entry + for entry in support.included_entries + if isinstance(entry.observation.result.value, ScalarValue) + and not isinstance(entry.observation.result.value.value, bool) + and isinstance(entry.observation.result.value.value, int | float) + ) + + +def _pair_key(left: object, right: object) -> frozenset[str]: + return frozenset( + ( + left.source_observation_id.qualified, # type: ignore[attr-defined] + right.source_observation_id.qualified, # type: ignore[attr-defined] + ) + ) + + +def validate_level_of_analysis( + request: AnalysisAuthorizationRequest, + capability: AnalysisCapability, + support: StatisticalSupport, +) -> None: + level = request.requested_level + if capability.required_level_of_analysis and level.estimand_level not in ( + capability.required_level_of_analysis + ): + raise AnalysisValidationError( + "requested estimand level does not match the registered capability", + "RES70_WRONG_LEVEL_OF_ANALYSIS", + ("registered level-of-analysis identity",), + ) + athlete_ids = tuple(entry.observation.context.athlete_id.qualified for entry in support.entries) + if level.estimand_level is AnalysisEstimandLevel.WITHIN_ATHLETE: + if len(set(athlete_ids)) != 1: + raise AnalysisValidationError( + "within-athlete analysis cannot pool multiple athletes", + "RES70_WRONG_LEVEL_OF_ANALYSIS", + ("one athlete or registered multilevel estimator",), + ) + if level.subject_key not in ("athlete_id", "athlete"): + raise AnalysisValidationError( + "within-athlete analysis must identify athlete as subject", + "RES70_WRONG_LEVEL_OF_ANALYSIS", + ) + elif level.estimand_level is AnalysisEstimandLevel.BETWEEN_ATHLETE: + counts = Counter(athlete_ids) + if any(count > 1 for count in counts.values()) and not level.clustering_keys: + raise AnalysisValidationError( + "repeated rows cannot be treated as independent between-athlete subjects", + "RES70_PSEUDOREPLICATION_RISK", + ("one independent unit per athlete or a registered clustering model",), + ) + if level.subject_key not in ("athlete_id", "athlete"): + raise AnalysisValidationError( + "between-athlete analysis must identify athlete as subject", + "RES70_WRONG_LEVEL_OF_ANALYSIS", + ) + elif level.estimand_level is AnalysisEstimandLevel.JOINT_MULTILEVEL: + if not level.clustering_keys: + raise AnalysisValidationError( + "joint multilevel analysis requires explicit clustering keys", + "RES70_WRONG_LEVEL_OF_ANALYSIS", + ) + + +def validate_support_shape( + request: AnalysisAuthorizationRequest, + capability: AnalysisCapability, + support: StatisticalSupport, +) -> None: + entries = support.entries + scalar_count = len(_scalar_entries(support)) + for shape in capability.required_support_shape: + if shape == "exactly-two-scalar-entries" and (len(entries) != 2 or scalar_count != 2): + raise AnalysisValidationError( + "registered scalar capability requires exactly two numeric scalar entries", + "RES70_STATISTICAL_AUTHORITY_INSUFFICIENT", + ("two valid scalar entries",), + ) + if shape == "nonzero-denominator" and ( + scalar_count != 2 or float(entries[0].observation.result.value.value) == 0.0 # type: ignore[union-attr] + ): + raise AnalysisValidationError( + "relative change requires a nonzero baseline denominator", + "RES70_STATISTICAL_AUTHORITY_INSUFFICIENT", + ("nonzero baseline scalar",), + ) + if shape == "strictly-positive-values" and ( + scalar_count != len(entries) + or any(float(entry.observation.result.value.value) <= 0 for entry in entries) # type: ignore[union-attr] + ): + raise AnalysisValidationError( + "log change requires strictly positive scalar values", + "RES70_STATISTICAL_AUTHORITY_INSUFFICIENT", + ("strictly positive ratio-scale values",), + ) + if shape == "one-current-entry" and support.current_entry_id is None: + raise AnalysisValidationError( + "reference-window deviation requires one explicit current entry", + "RES70_STATISTICAL_AUTHORITY_INSUFFICIENT", + ("current_entry_id",), + ) + if shape == "at-least-two-reference-entries" and len(support.reference_entry_ids) < 2: + raise AnalysisValidationError( + "reference-window deviation requires at least two reference entries", + "RES70_STATISTICAL_AUTHORITY_INSUFFICIENT", + ("two prior reference entries",), + ) + if shape == "repeated-observations" and len(entries) < 2: + raise AnalysisValidationError( + "repeated analysis requires at least two observations", + "RES70_STATISTICAL_AUTHORITY_INSUFFICIENT", + ("repeated observations",), + ) + if shape == "explicit-clustering" and not request.requested_level.clustering_keys: + raise AnalysisValidationError( + "repeated analysis requires explicit clustering keys", + "RES70_WRONG_LEVEL_OF_ANALYSIS", + ) + if shape == "one-independent-unit-per-athlete": + counts = Counter(entry.observation.context.athlete_id.qualified for entry in entries) + if any(count != 1 for count in counts.values()): + raise AnalysisValidationError( + "between-athlete support contains repeated rows per athlete", + "RES70_PSEUDOREPLICATION_RISK", + ) + if shape == "distinct-test-identities": + keys = { + SemanticIdentityKey.from_identity(entry.observation.identity) for entry in entries + } + if len(keys) < 2: + raise AnalysisValidationError( + "cross-test association requires distinct registered test identities", + "RES70_STATISTICAL_AUTHORITY_INSUFFICIENT", + ) + + +def validate_comparability_authority( + request: AnalysisAuthorizationRequest, + capability: AnalysisCapability, + support: StatisticalSupport, +) -> tuple[str, ...]: + decisions = request.comparability_decisions + if decisions: + validate_pairwise_decisions(decisions) + entries = support.entries + required_pairs = tuple( + (left, right) + for index, left in enumerate(entries) + for right in entries[index + 1 :] + if ( + SemanticIdentityKey.from_identity(left.observation.identity) + != SemanticIdentityKey.from_identity(right.observation.identity) + or left.observation.result.unit != right.observation.result.unit + ) + ) + by_pair = {decision.pair_key: decision for decision in decisions} + hashes: list[str] = [] + for left, right in required_pairs: + pair = _pair_key(left, right) + decision = by_pair.get(pair) + if decision is None: + raise AnalysisValidationError( + "material cross-source differences require a direct RES-70 pair decision", + "RES70_COMPARABILITY_AUTHORITY_MISSING", + ("direct pairwise comparability decision",), + ) + if decision.state is ComparabilityState.REQUIRES_TRANSFORMATION: + raise AnalysisValidationError( + "comparison requires a bridge transformation that has not executed", + "RES70_BRIDGE_NOT_EXECUTED", + ("executed registered bridge output",), + ) + if decision.state not in ( + ComparabilityState.COMPARABLE, + ComparabilityState.COMPARABLE_WITH_CONDITIONS, + ): + raise AnalysisValidationError( + "cross-source comparability is not affirmative", + "RES70_COMPARABILITY_AUTHORITY_MISSING", + ) + hashes.append(decision.canonical_decision_hash) + if capability.required_bridge_execution and decision.bridge_application_reference is None: + raise AnalysisValidationError( + "registered capability requires executed bridge authority", + "RES70_BRIDGE_NOT_EXECUTED", + ) + return tuple(hashes) + + +def validate_evidence_applicability( + request: AnalysisAuthorizationRequest, + capability: AnalysisCapability, +) -> None: + if not capability.required_evidence_axes: + return + if request.evidence_applicability is None: + raise AnalysisValidationError( + "registered analysis requires an evidence-applicability vector", + "RES70_INSUFFICIENT_EVIDENCE_APPLICABILITY", + ("ClaimEvidenceApplicability",), + ) + for axis_name in capability.required_evidence_axes: + try: + axis = ApplicabilityAxis(axis_name) + assessment = request.evidence_applicability.assessment(axis) + except (KeyError, ValueError) as exc: + raise AnalysisValidationError( + "required evidence-applicability axis is absent", + "RES70_APPLICABILITY_AXIS_UNASSESSED", + (axis_name,), + ) from exc + if not assessment.required_for_claim or assessment.decision.value in { + "UNSUPPORTED", + "UNASSESSED", + }: + raise AnalysisValidationError( + "required evidence-applicability axis is not affirmative", + "RES70_INSUFFICIENT_EVIDENCE_APPLICABILITY", + (axis_name,), + ) + + +__all__ = [ + "AnalysisValidationError", + "validate_analysis_capability_registry", + "validate_comparability_authority", + "validate_evidence_applicability", + "validate_exact_support", + "validate_level_of_analysis", + "validate_observation_hashes", + "validate_support_shape", +] diff --git a/tests/test_res70_analysis_capability.py b/tests/test_res70_analysis_capability.py new file mode 100644 index 0000000..eb7af75 --- /dev/null +++ b/tests/test_res70_analysis_capability.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from dynamislm import ( + AnalysisAuthorization, + AnalysisAuthorizationRequest, + AnalysisClass, + AnalysisEstimandLevel, + AnalysisLevelIdentity, + AnalysisUnitOfAnalysis, + InstanceIdentifier, + authorize_analysis, +) +from dynamislm.refusal import RefusalResult +from test_longitudinal_statistics import _entries, _support + + +def _request( + analysis_class: AnalysisClass, + *, + level: AnalysisEstimandLevel, +) -> AnalysisAuthorizationRequest: + entries = _entries((10.0, 12.0), prefix=f"res70-{analysis_class.value.lower()}") + support = _support(entries) + return AnalysisAuthorizationRequest( + request_id=InstanceIdentifier( + "analysis-authorization-request", f"{analysis_class.value.lower()}-request" + ), + analysis_class=analysis_class, + support=support, + support_reference=None, + observations=tuple(entry.observation for entry in entries), + identity_hashes=(), + comparability_decisions=(), + requested_level=AnalysisLevelIdentity( + unit_of_analysis=AnalysisUnitOfAnalysis.ATHLETE, + estimand_level=level, + subject_key="athlete_id", + clustering_keys=("athlete_id", "session_id"), + grouping_keys=(), + ), + evidence_applicability=None, + context_references=(), + ) + + +def test_scalar_absolute_change_consumes_res69_and_authorizes_exact_support() -> None: + request = _request( + AnalysisClass.SCALAR_ABSOLUTE_CHANGE, + level=AnalysisEstimandLevel.WITHIN_ATHLETE, + ) + + result = authorize_analysis(request) + + assert isinstance(result, AnalysisAuthorization) + assert result.operation_reference is not None + assert result.support_hashes == (request.support.canonical_support_hash,) # type: ignore[union-attr] + assert result.resolved_level.estimand_level is AnalysisEstimandLevel.WITHIN_ATHLETE + + +def test_missing_prerequisite_returns_structured_refusal() -> None: + request = _request( + AnalysisClass.SCALAR_ABSOLUTE_CHANGE, + level=AnalysisEstimandLevel.WITHIN_ATHLETE, + ) + request = AnalysisAuthorizationRequest( + request_id=request.request_id, + analysis_class=request.analysis_class, + support=None, + support_reference=None, + observations=(), + identity_hashes=(), + comparability_decisions=(), + requested_level=request.requested_level, + evidence_applicability=None, + context_references=(), + ) + + result = authorize_analysis(request) + + assert isinstance(result, RefusalResult) + assert result.refusal_class.value == "DATA_ADEQUACY_INSUFFICIENT" + assert "RES70_STATISTICAL_AUTHORITY_INSUFFICIENT" in result.reason_codes + + +def test_deferred_repeated_measures_capability_is_not_executable() -> None: + request = _request( + AnalysisClass.WITHIN_ATHLETE_ASSOCIATION, + level=AnalysisEstimandLevel.WITHIN_ATHLETE, + ) + + result = authorize_analysis(request) + + assert isinstance(result, RefusalResult) + assert "COMPUTATION_NOT_REGISTERED" in result.reason_codes + + +def test_relative_change_refuses_without_canonical_res69_scale_authority() -> None: + request = _request( + AnalysisClass.SCALAR_RELATIVE_CHANGE, + level=AnalysisEstimandLevel.WITHIN_ATHLETE, + ) + + result = authorize_analysis(request) + + assert isinstance(result, RefusalResult) + assert "RES70_STATISTICAL_AUTHORITY_INSUFFICIENT" in result.reason_codes + + +def test_between_level_cannot_be_relabelled_for_within_scalar_change() -> None: + request = _request( + AnalysisClass.SCALAR_ABSOLUTE_CHANGE, + level=AnalysisEstimandLevel.BETWEEN_ATHLETE, + ) + + result = authorize_analysis(request) + + assert isinstance(result, RefusalResult) + assert "RES70_WRONG_LEVEL_OF_ANALYSIS" in result.reason_codes From fd16a89c2786572f6bad31d37770c5e43622da49 Mon Sep 17 00:00:00 2001 From: Julio Rodriguez <144072916+Litju@users.noreply.github.com> Date: Fri, 18 Sep 2026 19:59:56 +0000 Subject: [PATCH 05/18] feat(res70): implement claim authority adjudication --- src/dynamislm/__init__.py | 22 + src/dynamislm/claims/__init__.py | 24 + src/dynamislm/claims/authority.py | 567 ++++++++++++++++++++++++ src/dynamislm/claims/models.py | 75 ++++ src/dynamislm/claims/registry.py | 303 +++++++++++++ src/dynamislm/claims/validation.py | 68 +++ tests/test_res70_claim_authority.py | 100 +++++ tests/test_res70_evidence_and_levels.py | 94 ++++ 8 files changed, 1253 insertions(+) create mode 100644 src/dynamislm/claims/authority.py create mode 100644 src/dynamislm/claims/registry.py create mode 100644 src/dynamislm/claims/validation.py create mode 100644 tests/test_res70_claim_authority.py create mode 100644 tests/test_res70_evidence_and_levels.py diff --git a/src/dynamislm/__init__.py b/src/dynamislm/__init__.py index 6c61fd7..15f2940 100644 --- a/src/dynamislm/__init__.py +++ b/src/dynamislm/__init__.py @@ -30,13 +30,24 @@ validate_support_shape, ) from dynamislm.claims import ( + CANONICAL_CLAIM_POLICY_REGISTRY, + RES70_CLAIM_POLICIES, + RES70_CLAIM_POLICY_REGISTRY, + RES70_CLAIM_REGISTRY_VERSION, ClaimAuthorityResult, ClaimAuthorityStatus, + ClaimAxis, ClaimIntent, + ClaimLevel, + ClaimPolicy, + ClaimPolicyRegistry, ClaimTarget, MeasurementClaimLevel, PredictionStatus, RelationshipClaimLevel, + authorize_claim, + is_canonical_claim_registry, + validate_claim_authority, ) from dynamislm.comparability import ( CANONICAL_BRIDGE_OPERATION_REGISTRY, @@ -599,9 +610,14 @@ "BridgeRegistration", "ClaimAuthorityResult", "ClaimAuthorityStatus", + "CANONICAL_CLAIM_POLICY_REGISTRY", + "ClaimAxis", "ClaimContext", "ClaimEvidenceApplicability", "ClaimIntent", + "ClaimLevel", + "ClaimPolicy", + "ClaimPolicyRegistry", "ClaimTarget", "CrossSourceComparabilityDecision", "CrossSourceComparabilityRequest", @@ -686,6 +702,9 @@ "RefusalResult", "RefusalStatus", "RelationshipClaimLevel", + "RES70_CLAIM_POLICIES", + "RES70_CLAIM_POLICY_REGISTRY", + "RES70_CLAIM_REGISTRY_VERSION", "RegisteredComparabilityRule", "RegisteredBridgeOperation", "RES70_AFFINE_BRIDGE_OPERATION", @@ -776,6 +795,7 @@ "build_source_variable_registry", "canonical_hash", "assess_cross_source_comparability", + "authorize_claim", "build_res70_refusal", "canonical_registry_hash", "canonical_json", @@ -801,6 +821,7 @@ "inspect_tabular_schema", "iter_tab_rows", "is_canonical_analysis_registry", + "is_canonical_claim_registry", "load_committed_dataset_registry", "load_dataset_registry", "migrate_scientific_observation_to_longitudinal_entry", @@ -818,6 +839,7 @@ "store_verified_temporary_file", "tab_header", "validate_canonical_records", + "validate_claim_authority", "validate_bridge_execution", "validate_bridge_registry", "validate_bridge_registration", diff --git a/src/dynamislm/claims/__init__.py b/src/dynamislm/claims/__init__.py index ffcc2a9..d223144 100644 --- a/src/dynamislm/claims/__init__.py +++ b/src/dynamislm/claims/__init__.py @@ -1,5 +1,6 @@ """RES-70 two-axis claim-authority contracts.""" +from dynamislm.claims.authority import authorize_claim from dynamislm.claims.models import ( ClaimAuthorityResult, ClaimAuthorityStatus, @@ -9,13 +10,36 @@ PredictionStatus, RelationshipClaimLevel, ) +from dynamislm.claims.registry import ( + CANONICAL_CLAIM_POLICY_REGISTRY, + RES70_CLAIM_POLICIES, + RES70_CLAIM_POLICY_REGISTRY, + RES70_CLAIM_REGISTRY_VERSION, + ClaimAxis, + ClaimLevel, + ClaimPolicy, + ClaimPolicyRegistry, + is_canonical_claim_registry, +) +from dynamislm.claims.validation import validate_claim_authority __all__ = [ + "CANONICAL_CLAIM_POLICY_REGISTRY", + "RES70_CLAIM_POLICIES", + "RES70_CLAIM_POLICY_REGISTRY", + "RES70_CLAIM_REGISTRY_VERSION", "ClaimAuthorityResult", "ClaimAuthorityStatus", + "ClaimAxis", "ClaimIntent", + "ClaimLevel", + "ClaimPolicy", + "ClaimPolicyRegistry", "ClaimTarget", "MeasurementClaimLevel", "PredictionStatus", "RelationshipClaimLevel", + "authorize_claim", + "is_canonical_claim_registry", + "validate_claim_authority", ] diff --git a/src/dynamislm/claims/authority.py b/src/dynamislm/claims/authority.py new file mode 100644 index 0000000..e7eee4b --- /dev/null +++ b/src/dynamislm/claims/authority.py @@ -0,0 +1,567 @@ +"""Deterministic two-axis RES-70 claim-authority adjudication.""" + +from __future__ import annotations + +from typing import cast + +from dynamislm.analysis.models import AnalysisAuthorizationStatus +from dynamislm.claims.models import ( + ClaimAuthorityResult, + ClaimAuthorityStatus, + ClaimIntent, + ClaimTarget, + MeasurementClaimLevel, + PredictionStatus, + RelationshipClaimLevel, +) +from dynamislm.claims.registry import ( + CANONICAL_CLAIM_POLICY_REGISTRY, + ClaimAxis, + ClaimPolicy, + ClaimPolicyRegistry, + is_canonical_claim_registry, +) +from dynamislm.comparability.res70_validation import build_res70_refusal +from dynamislm.evidence.models import ApplicabilityDecision +from dynamislm.evidence.res70 import validate_claim_evidence_applicability +from dynamislm.measurement.identity import InstanceIdentifier +from dynamislm.measurement.result import ResultStatus +from dynamislm.refusal.models import RefusalClass, RefusalResult +from dynamislm.serialization import canonical_hash + +_MEASUREMENT_LEVELS = ( + MeasurementClaimLevel.OBSERVED_VALUE, + MeasurementClaimLevel.NUMERICAL_CHANGE, + MeasurementClaimLevel.COMPARABLE_CHANGE, + MeasurementClaimLevel.CHANGE_RELATIVE_TO_MEASUREMENT_ERROR, + MeasurementClaimLevel.PRACTICAL_OR_DECISION_MEANINGFULNESS, +) +_RELATIONSHIP_LEVELS = ( + RelationshipClaimLevel.OBSERVATION, + RelationshipClaimLevel.DESCRIPTIVE_CHANGE, + RelationshipClaimLevel.ASSOCIATION, + RelationshipClaimLevel.TEMPORAL_ASSOCIATION, + RelationshipClaimLevel.MECHANISTIC_HYPOTHESIS, + RelationshipClaimLevel.CAUSAL_EVIDENCE, +) + + +def _observation_ids(intent: ClaimIntent) -> tuple[InstanceIdentifier, ...]: + if intent.observations: + return tuple(item.observation_id for item in intent.observations) + if intent.statistical_result is not None and intent.statistical_result.support is not None: + return intent.statistical_result.support.source_observation_ids + return () + + +def _support_hashes(intent: ClaimIntent) -> tuple[str, ...]: + if intent.statistical_result is not None and intent.statistical_result.support is not None: + return (intent.statistical_result.support.canonical_support_hash,) + if intent.analysis_authorization is not None: + return intent.analysis_authorization.support_hashes + return () + + +def _analysis_hashes(intent: ClaimIntent) -> tuple[str, ...]: + if intent.analysis_authorization is None: + return () + return (intent.analysis_authorization.canonical_authorization_hash,) + + +def _comparability_hashes(intent: ClaimIntent) -> tuple[str, ...]: + return tuple(item.canonical_decision_hash for item in intent.comparability_decisions) + + +def _bridge_hashes(intent: ClaimIntent) -> tuple[str, ...]: + return tuple( + canonical_hash(item.bridge_application_reference) + for item in intent.comparability_decisions + if item.bridge_application_reference is not None + ) + + +def _safe_observation(intent: ClaimIntent) -> bool: + if intent.observations: + return all(item.result.status is ResultStatus.VALID for item in intent.observations) + return intent.statistical_result is not None and intent.statistical_result.support is not None + + +def _analysis_matches( + intent: ClaimIntent, + level: object, +) -> tuple[bool, str | None, tuple[str, ...]]: + auth = intent.analysis_authorization + if auth is None or auth.status is not AnalysisAuthorizationStatus.AUTHORIZED: + if level is MeasurementClaimLevel.CHANGE_RELATIVE_TO_MEASUREMENT_ERROR: + return ( + False, + "a registered source-bound measurement-error authority", + ("RES70_STATISTICAL_AUTHORITY_INSUFFICIENT",), + ) + return ( + False, + "a registered authorized deterministic analysis", + ("COMPUTATION_NOT_REGISTERED",), + ) + if ( + intent.analysis_reference is not None + and auth.capability_reference != intent.analysis_reference + ): + return ( + False, + "analysis reference bound to the claim intent", + ("RES70_REGISTRY_INTEGRITY_FAILURE",), + ) + if isinstance(level, MeasurementClaimLevel): + allowed = { + MeasurementClaimLevel.NUMERICAL_CHANGE: { + "SCALAR_ABSOLUTE_CHANGE", + "SCALAR_RELATIVE_CHANGE", + "SCALAR_LOG_CHANGE", + "BASELINE_REFERENCE_WINDOW_DEVIATION", + }, + MeasurementClaimLevel.COMPARABLE_CHANGE: { + "SCALAR_ABSOLUTE_CHANGE", + "SCALAR_RELATIVE_CHANGE", + "SCALAR_LOG_CHANGE", + "BASELINE_REFERENCE_WINDOW_DEVIATION", + }, + MeasurementClaimLevel.CHANGE_RELATIVE_TO_MEASUREMENT_ERROR: { + "RELIABILITY_RANDOM_ERROR_COMPARISON", + }, + MeasurementClaimLevel.PRACTICAL_OR_DECISION_MEANINGFULNESS: { + "RELIABILITY_RANDOM_ERROR_COMPARISON", + }, + } + if level is MeasurementClaimLevel.OBSERVED_VALUE: + return True, None, () + if auth.analysis_class.value not in allowed.get(level, set()): + return ( + False, + ("analysis operation semantically supports the requested measurement level"), + ("RES70_UNSUPPORTED_CLAIM_ESCALATION",), + ) + if ( + level is MeasurementClaimLevel.CHANGE_RELATIVE_TO_MEASUREMENT_ERROR + and not auth.statistical_authority_hashes + ): + return ( + False, + "registered source-bound measurement-error authority", + ("RES70_STATISTICAL_AUTHORITY_INSUFFICIENT",), + ) + else: + if level is RelationshipClaimLevel.OBSERVATION: + return True, None, () + if level is RelationshipClaimLevel.DESCRIPTIVE_CHANGE: + if auth.analysis_class.value not in { + "SCALAR_ABSOLUTE_CHANGE", + "SCALAR_RELATIVE_CHANGE", + "SCALAR_LOG_CHANGE", + "BASELINE_REFERENCE_WINDOW_DEVIATION", + }: + return ( + False, + "registered descriptive change analysis", + ("RES70_UNSUPPORTED_CLAIM_ESCALATION",), + ) + elif level is RelationshipClaimLevel.ASSOCIATION: + if "ASSOCIATION" not in auth.analysis_class.value: + return ( + False, + "registered association analysis", + ("RES70_UNSUPPORTED_CLAIM_ESCALATION",), + ) + elif level is RelationshipClaimLevel.TEMPORAL_ASSOCIATION: + return ( + False, + "registered temporal association estimator", + ("COMPUTATION_NOT_REGISTERED",), + ) + elif level is RelationshipClaimLevel.MECHANISTIC_HYPOTHESIS: + if "ASSOCIATION" not in auth.analysis_class.value: + return ( + False, + "association result supporting a bounded mechanism hypothesis", + ("RES70_UNSUPPORTED_CLAIM_ESCALATION",), + ) + elif level is RelationshipClaimLevel.CAUSAL_EVIDENCE: + return ( + False, + "registered causal estimand and identification strategy", + ("RES70_UNSUPPORTED_CAUSAL_CLAIM",), + ) + return True, None, () + + +def _evidence_supports( + intent: ClaimIntent, + policy: ClaimPolicy, +) -> tuple[bool, str | None, tuple[str, ...]]: + if not policy.required_evidence_axes: + return True, None, () + bundle = intent.evidence_applicability + if bundle is None: + return ( + False, + "claim-relative evidence-applicability vector", + ("RES70_APPLICABILITY_AXIS_UNASSESSED",), + ) + try: + validate_claim_evidence_applicability(bundle) + except ValueError: + return ( + False, + "untampered claim-relative evidence-applicability vector", + ("RES70_REGISTRY_INTEGRITY_FAILURE",), + ) + blocked: list[str] = [] + for axis in policy.required_evidence_axes: + try: + assessment = bundle.assessment(axis) + except KeyError: + blocked.append(axis.value) + continue + if assessment.decision in ( + ApplicabilityDecision.UNSUPPORTED, + ApplicabilityDecision.UNASSESSED, + ): + blocked.append(axis.value) + if ( + policy.requires_decision_criterion + and assessment.decision is not ApplicabilityDecision.SUPPORTED + ): + blocked.append(axis.value) + if blocked: + return ( + False, + "affirmative applicability for every required evidence axis", + ("RES70_INSUFFICIENT_EVIDENCE_APPLICABILITY",), + ) + return True, None, () + + +def _comparability_supports(intent: ClaimIntent) -> tuple[bool, str | None, tuple[str, ...]]: + if not intent.comparability_decisions: + return ( + False, + "affirmative direct pairwise comparability decisions", + ("RES70_COMPARABILITY_AUTHORITY_MISSING",), + ) + for decision in intent.comparability_decisions: + if decision.state.value in {"REQUIRES_TRANSFORMATION", "BRIDGE_VALIDATION_REQUIRED"}: + return ( + False, + "executed registered bridge or affirmative comparability", + ("RES70_BRIDGE_NOT_EXECUTED",), + ) + if decision.state.value not in {"COMPARABLE", "COMPARABLE_WITH_CONDITIONS"}: + return ( + False, + "affirmative pairwise comparability", + ("RES70_COMPARABILITY_AUTHORITY_MISSING",), + ) + return True, None, () + + +def _target_level_supports(intent: ClaimIntent) -> tuple[bool, str | None, tuple[str, ...]]: + auth = intent.analysis_authorization + if auth is None: + return True, None, () + level = auth.resolved_level.estimand_level.value + if intent.target is ClaimTarget.ATHLETE_WITHIN and level != "WITHIN_ATHLETE": + return False, "within-athlete analysis level", ("RES70_WRONG_LEVEL_OF_ANALYSIS",) + if intent.target is ClaimTarget.ATHLETE_BETWEEN and level != "BETWEEN_ATHLETE": + return False, "between-athlete analysis level", ("RES70_WRONG_LEVEL_OF_ANALYSIS",) + if intent.target in (ClaimTarget.GROUP, ClaimTarget.POPULATION) and level not in { + "BETWEEN_ATHLETE", + "GROUP", + "JOINT_MULTILEVEL", + }: + return False, "group/population estimand level", ("RES70_WRONG_LEVEL_OF_ANALYSIS",) + return True, None, () + + +def _policy_result( + intent: ClaimIntent, + policy: ClaimPolicy, +) -> tuple[bool, str | None, tuple[str, ...], tuple[str, ...]]: + if not _safe_observation(intent): + return ( + False, + "exact valid observations or a result-bound support", + ("RES70_UNRESOLVED_IDENTITY",), + ("exact typed observations",), + ) + if policy.causal_operation_required: + return ( + False, + "registered causal operation", + ("RES70_UNSUPPORTED_CAUSAL_CLAIM",), + ("causal estimand and identification strategy",), + ) + if policy.requires_decision_criterion and intent.decision_criterion_reference is None: + return ( + False, + "registered claim-specific decision/utility criterion", + ("RES70_UNSUPPORTED_CLAIM_ESCALATION",), + ("decision_criterion_reference",), + ) + if policy.requires_measurement_error_authority: + if ( + intent.analysis_authorization is None + or not intent.analysis_authorization.statistical_authority_hashes + ): + return ( + False, + "source-bound measurement-error authority", + ("RES70_STATISTICAL_AUTHORITY_INSUFFICIENT",), + ("source-bound measurement-error authority",), + ) + if policy.requires_comparability: + ok, missing, reasons = _comparability_supports(intent) + if not ok: + return False, missing, reasons, (missing or "pairwise comparability",) + if policy.requires_analysis: + ok, missing, reasons = _analysis_matches(intent, policy.level) + if not ok: + return False, missing, reasons, (missing or "registered analysis",) + if policy.requires_statistical_result and intent.statistical_result is None: + return ( + False, + "exact deterministic statistical result", + ("RES70_STATISTICAL_AUTHORITY_INSUFFICIENT",), + ("StatisticalResult",), + ) + ok, missing, reasons = _evidence_supports(intent, policy) + if not ok: + return False, missing, reasons, (missing or "evidence applicability",) + ok, missing, reasons = _target_level_supports(intent) + if not ok: + return False, missing, reasons, (missing or "level-of-analysis identity",) + if policy.causal_operation_required: + return ( + False, + "registered causal operation", + ("RES70_UNSUPPORTED_CAUSAL_CLAIM",), + ("causal estimand and identification strategy",), + ) + return True, None, (), () + + +def _evaluate_axis( + intent: ClaimIntent, + levels: tuple[MeasurementClaimLevel, ...] | tuple[RelationshipClaimLevel, ...], + axis: ClaimAxis, + registry: ClaimPolicyRegistry, +) -> tuple[ + tuple[object, ...], + tuple[str, ...], + tuple[str, ...], + tuple[str, ...], + str | None, +]: + requested = ( + intent.measurement_level + if axis is ClaimAxis.MEASUREMENT_CHANGE + else intent.relationship_level + ) + if requested is None: + return (), (), (), (), None + allowed: list[MeasurementClaimLevel | RelationshipClaimLevel] = [] + blocked: list[str] = [] + reasons: list[str] = [] + missing: list[str] = [] + first: str | None = None + lower_failure: tuple[str | None, tuple[str, ...], tuple[str, ...]] | None = None + for level in levels: + policy = registry.resolve(axis, level) + if policy is None: + blocked.append(level.value) + reasons.append("RES70_REGISTRY_INTEGRITY_FAILURE") + first = first or "canonical claim policy" + break + ok, missing_item, reason_codes, missing_items = _policy_result(intent, policy) + if ok: + allowed.append(level) + if level is requested: + break + else: + if level is not requested: + blocked.append(level.value) + lower_failure = (missing_item, reason_codes, missing_items) + continue + blocked.append(level.value) + if lower_failure is not None and not reason_codes: + missing_item, reason_codes, missing_items = lower_failure + reasons.extend(reason_codes) + missing.extend(missing_items) + first = first or missing_item + break + return ( + tuple(allowed), + tuple(blocked), + tuple(dict.fromkeys(reasons)), + tuple(dict.fromkeys(missing)), + first, + ) + + +def authorize_claim( + intent: ClaimIntent, + *, + registry: ClaimPolicyRegistry = CANONICAL_CLAIM_POLICY_REGISTRY, +) -> ClaimAuthorityResult: + """Authorize claims independently on measurement/change and relationship axes.""" + + if not is_canonical_claim_registry(registry): + registry_reasons = ("RES70_REGISTRY_INTEGRITY_FAILURE",) + blocked = tuple( + item.value + for item in (intent.measurement_level, intent.relationship_level) + if item is not None + ) + registry_refusal = build_res70_refusal( + "claim authority", + registry_reasons[0], + refusal_class=RefusalClass.COMPUTATION_NOT_REGISTERED, + missing_information=("canonical claim policy registry",), + observation_ids=_observation_ids(intent), + ) + return ClaimAuthorityResult.create( + status=ClaimAuthorityStatus.REFUSED, + claim_intent_reference=intent.claim_reference, + claim_intent_hash=intent.intent_hash, + allowed_measurement_levels=(), + allowed_relationship_levels=(), + prediction_status=PredictionStatus.REFUSED + if intent.predictive_intent is not None + else PredictionStatus.NOT_REQUESTED, + blocked_claims=blocked, + first_blocking_prerequisite="canonical claim policy registry", + reason_codes=registry_reasons, + missing_information=("canonical claim policy registry",), + safe_descriptions=("exact observations remain independently describable",), + support_hashes=_support_hashes(intent), + analysis_hashes=_analysis_hashes(intent), + comparability_hashes=_comparability_hashes(intent), + bridge_hashes=_bridge_hashes(intent), + evidence_applicability_hash=( + intent.evidence_applicability.canonical_applicability_hash + if intent.evidence_applicability is not None + else None + ), + registry_version=registry.registry_version, + software_version="dynamislm-res70-1.0.0", + refusal_result=registry_refusal, + ) + + ( + allowed_measurement, + blocked_measurement, + measurement_reasons, + measurement_missing, + measurement_first, + ) = _evaluate_axis(intent, _MEASUREMENT_LEVELS, ClaimAxis.MEASUREMENT_CHANGE, registry) + ( + allowed_relationship, + blocked_relationship, + relationship_reasons, + relationship_missing, + relationship_first, + ) = _evaluate_axis(intent, _RELATIONSHIP_LEVELS, ClaimAxis.RELATIONSHIP_CAUSAL, registry) + allowed_measurement = cast(tuple[MeasurementClaimLevel, ...], allowed_measurement) + allowed_relationship = cast(tuple[RelationshipClaimLevel, ...], allowed_relationship) + reasons: tuple[str, ...] = tuple(dict.fromkeys((*measurement_reasons, *relationship_reasons))) + missing: tuple[str, ...] = tuple(dict.fromkeys((*measurement_missing, *relationship_missing))) + first = measurement_first or relationship_first + prediction_status = PredictionStatus.NOT_REQUESTED + if intent.predictive_intent is not None: + prediction_status = PredictionStatus.REFUSED + reasons = tuple(dict.fromkeys((*reasons, "COMPUTATION_NOT_REGISTERED"))) + missing = (*missing, "registered predictive-validity contract") + first = first or "registered predictive-validity contract" + requested_axes = ( + int(intent.measurement_level is not None) + + int(intent.relationship_level is not None) + + int(intent.predictive_intent is not None) + ) + authorized_axes = int( + intent.measurement_level is not None and intent.measurement_level in allowed_measurement + ) + int( + intent.relationship_level is not None and intent.relationship_level in allowed_relationship + ) + if ( + intent.predictive_intent is not None + and prediction_status is not PredictionStatus.AUTHORIZED + ): + authorized_axes = max(0, authorized_axes - 1) + blocked_claims: tuple[str, ...] = (*blocked_measurement, *blocked_relationship) + if intent.predictive_intent is not None: + blocked_claims = (*blocked_claims, intent.predictive_intent.stable_id) + refusal: RefusalResult | None + if not blocked_claims and authorized_axes == requested_axes: + status = ClaimAuthorityStatus.AUTHORIZED + refusal = None + elif allowed_measurement or allowed_relationship: + status = ClaimAuthorityStatus.PARTIALLY_AUTHORIZED + refusal = build_res70_refusal( + "claim authority escalation", + reasons[0] if reasons else "RES70_UNSUPPORTED_CLAIM_ESCALATION", + refusal_class=( + RefusalClass.CAUSAL_IDENTIFICATION_UNSUPPORTED + if "RES70_UNSUPPORTED_CAUSAL_CLAIM" in reasons + else RefusalClass.UNCERTAINTY_LIMITS_CLAIM + ), + missing_information=missing, + observation_ids=_observation_ids(intent), + ) + else: + status = ClaimAuthorityStatus.REFUSED + refusal = build_res70_refusal( + "claim authority", + reasons[0] if reasons else "RES70_UNSUPPORTED_CLAIM_ESCALATION", + refusal_class=( + RefusalClass.CAUSAL_IDENTIFICATION_UNSUPPORTED + if "RES70_UNSUPPORTED_CAUSAL_CLAIM" in reasons + else RefusalClass.UNCERTAINTY_LIMITS_CLAIM + ), + missing_information=missing, + observation_ids=_observation_ids(intent), + ) + safe_descriptions: tuple[str, ...] = ( + "exact valid observations remain safely describable under their recorded identity", + ) + if status is ClaimAuthorityStatus.PARTIALLY_AUTHORIZED: + safe_descriptions = ( + *safe_descriptions, + "authorized lower claim levels remain available without implying the blocked level", + ) + return ClaimAuthorityResult.create( + status=status, + claim_intent_reference=intent.claim_reference, + claim_intent_hash=intent.intent_hash, + allowed_measurement_levels=allowed_measurement, + allowed_relationship_levels=allowed_relationship, + prediction_status=prediction_status, + blocked_claims=blocked_claims, + first_blocking_prerequisite=first, + reason_codes=reasons, + missing_information=missing, + safe_descriptions=safe_descriptions, + support_hashes=_support_hashes(intent), + analysis_hashes=_analysis_hashes(intent), + comparability_hashes=_comparability_hashes(intent), + bridge_hashes=_bridge_hashes(intent), + evidence_applicability_hash=( + intent.evidence_applicability.canonical_applicability_hash + if intent.evidence_applicability is not None + else None + ), + registry_version=registry.registry_version, + software_version="dynamislm-res70-1.0.0", + refusal_result=refusal, + ) + + +__all__ = ["authorize_claim"] diff --git a/src/dynamislm/claims/models.py b/src/dynamislm/claims/models.py index 92b83e0..ab44c09 100644 --- a/src/dynamislm/claims/models.py +++ b/src/dynamislm/claims/models.py @@ -172,6 +172,7 @@ class ClaimAuthorityResult: decision_id: InstanceIdentifier status: ClaimAuthorityStatus claim_intent_reference: RegistryReference + claim_intent_hash: str allowed_measurement_levels: tuple[MeasurementClaimLevel, ...] allowed_relationship_levels: tuple[RelationshipClaimLevel, ...] prediction_status: PredictionStatus @@ -200,6 +201,7 @@ def __post_init__(self) -> None: RegistryReference, "claim_intent_reference", ) + _require_hash(self.claim_intent_hash, "claim_intent_hash") _require_tuple_items( self.allowed_measurement_levels, MeasurementClaimLevel, @@ -239,6 +241,7 @@ def __post_init__(self) -> None: { "status": self.status, "claim_intent_reference": self.claim_intent_reference, + "claim_intent_hash": self.claim_intent_hash, "allowed_measurement_levels": self.allowed_measurement_levels, "allowed_relationship_levels": self.allowed_relationship_levels, "prediction_status": self.prediction_status, @@ -267,6 +270,78 @@ def __post_init__(self) -> None: if self.decision_id != expected_id: raise ValueError("decision_id does not match immutable claim decision content") + @classmethod + def create( + cls, + *, + status: ClaimAuthorityStatus, + claim_intent_reference: RegistryReference, + claim_intent_hash: str, + allowed_measurement_levels: tuple[MeasurementClaimLevel, ...], + allowed_relationship_levels: tuple[RelationshipClaimLevel, ...], + prediction_status: PredictionStatus, + blocked_claims: tuple[str, ...], + first_blocking_prerequisite: str | None, + reason_codes: tuple[str, ...], + missing_information: tuple[str, ...], + safe_descriptions: tuple[str, ...], + support_hashes: tuple[str, ...], + analysis_hashes: tuple[str, ...], + comparability_hashes: tuple[str, ...], + bridge_hashes: tuple[str, ...], + evidence_applicability_hash: str | None, + registry_version: str, + software_version: str, + refusal_result: RefusalResult | None = None, + ) -> ClaimAuthorityResult: + content = { + "status": status, + "claim_intent_reference": claim_intent_reference, + "claim_intent_hash": claim_intent_hash, + "allowed_measurement_levels": allowed_measurement_levels, + "allowed_relationship_levels": allowed_relationship_levels, + "prediction_status": prediction_status, + "blocked_claims": blocked_claims, + "first_blocking_prerequisite": first_blocking_prerequisite, + "reason_codes": reason_codes, + "missing_information": missing_information, + "safe_descriptions": safe_descriptions, + "support_hashes": support_hashes, + "analysis_hashes": analysis_hashes, + "comparability_hashes": comparability_hashes, + "bridge_hashes": bridge_hashes, + "evidence_applicability_hash": evidence_applicability_hash, + "registry_version": registry_version, + "software_version": software_version, + "refusal_result": refusal_result, + } + decision_hash = canonical_hash(content) + return cls( + decision_id=InstanceIdentifier( + "claim-authority-decision", decision_hash.removeprefix(_SHA256_PREFIX) + ), + status=status, + claim_intent_reference=claim_intent_reference, + claim_intent_hash=claim_intent_hash, + allowed_measurement_levels=allowed_measurement_levels, + allowed_relationship_levels=allowed_relationship_levels, + prediction_status=prediction_status, + blocked_claims=blocked_claims, + first_blocking_prerequisite=first_blocking_prerequisite, + reason_codes=reason_codes, + missing_information=missing_information, + safe_descriptions=safe_descriptions, + support_hashes=support_hashes, + analysis_hashes=analysis_hashes, + comparability_hashes=comparability_hashes, + bridge_hashes=bridge_hashes, + evidence_applicability_hash=evidence_applicability_hash, + registry_version=registry_version, + software_version=software_version, + refusal_result=refusal_result, + decision_hash=decision_hash, + ) + @property def canonical_decision_hash(self) -> str: assert self.decision_hash is not None diff --git a/src/dynamislm/claims/registry.py b/src/dynamislm/claims/registry.py new file mode 100644 index 0000000..b3d8989 --- /dev/null +++ b/src/dynamislm/claims/registry.py @@ -0,0 +1,303 @@ +"""Canonical RES-70 claim prerequisite policies.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum + +from dynamislm.claims.models import MeasurementClaimLevel, RelationshipClaimLevel +from dynamislm.evidence.res70 import ApplicabilityAxis +from dynamislm.measurement.identity import ( + RegistryReference, + ScientificIdentifier, + _require_enum, + _require_instance, + _require_text, + _require_tuple_items, + require_tuple, +) +from dynamislm.serialization import canonical_hash, register_serializable_type + +RES70_CLAIM_REGISTRY_VERSION = "1.0.0" + + +class ClaimAxis(StrEnum): + MEASUREMENT_CHANGE = "MEASUREMENT_CHANGE" + RELATIONSHIP_CAUSAL = "RELATIONSHIP_CAUSAL" + + +ClaimLevel = MeasurementClaimLevel | RelationshipClaimLevel + + +def _reference(key: str, label: str) -> RegistryReference: + return RegistryReference( + ScientificIdentifier("dynamislm", "claim-policy", key, RES70_CLAIM_REGISTRY_VERSION), + label, + ) + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class ClaimPolicy: + policy_reference: RegistryReference + axis: ClaimAxis + level: ClaimLevel + ordinal: int + required_lower_level: ClaimLevel | None + requires_analysis: bool + requires_statistical_result: bool + required_evidence_axes: tuple[ApplicabilityAxis, ...] + requires_comparability: bool + requires_measurement_error_authority: bool + requires_decision_criterion: bool + causal_operation_required: bool + + def __post_init__(self) -> None: + _require_instance(self.policy_reference, RegistryReference, "policy_reference") + if self.policy_reference.identifier.object_type != "claim-policy": + raise ValueError("policy_reference must identify a claim policy") + _require_enum(self.axis, ClaimAxis, "axis") + if not isinstance(self.level, MeasurementClaimLevel | RelationshipClaimLevel): + raise ValueError("level must be a registered claim level") + if ( + isinstance(self.level, MeasurementClaimLevel) + and self.axis is not ClaimAxis.MEASUREMENT_CHANGE + ): + raise ValueError("measurement level must use the measurement-change axis") + if ( + isinstance(self.level, RelationshipClaimLevel) + and self.axis is not ClaimAxis.RELATIONSHIP_CAUSAL + ): + raise ValueError("relationship level must use the relationship-causal axis") + if isinstance(self.required_lower_level, MeasurementClaimLevel | RelationshipClaimLevel): + if type(self.required_lower_level) is not type(self.level): + raise ValueError("lower-level prerequisite must use the same claim axis") + if isinstance(self.required_lower_level, type(None)) is False and not isinstance( + self.required_lower_level, + MeasurementClaimLevel | RelationshipClaimLevel, + ): + raise ValueError("required_lower_level must be a claim level or None") + if isinstance(self.ordinal, bool) or self.ordinal < 0: + raise ValueError("claim policy ordinal must be a non-negative integer") + for field_name, value in ( + ("requires_analysis", self.requires_analysis), + ("requires_statistical_result", self.requires_statistical_result), + ("requires_comparability", self.requires_comparability), + ("requires_measurement_error_authority", self.requires_measurement_error_authority), + ("requires_decision_criterion", self.requires_decision_criterion), + ("causal_operation_required", self.causal_operation_required), + ): + if not isinstance(value, bool): + raise ValueError(f"{field_name} must be a boolean") + require_tuple(self.required_evidence_axes, "required_evidence_axes") + if any(not isinstance(item, ApplicabilityAxis) for item in self.required_evidence_axes): + raise ValueError("required_evidence_axes must contain ApplicabilityAxis values") + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class ClaimPolicyRegistry: + entries: tuple[ClaimPolicy, ...] + registry_version: str = RES70_CLAIM_REGISTRY_VERSION + registry_hash: str | None = None + + def __post_init__(self) -> None: + require_tuple(self.entries, "entries") + _require_tuple_items(self.entries, ClaimPolicy, "entries") + _require_text(self.registry_version, "registry_version") + keys = tuple((item.axis, item.level) for item in self.entries) + if len(set(keys)) != len(keys): + raise ValueError("claim policy registry cannot contain duplicate axis/level entries") + if any( + item.policy_reference.identifier.version != self.registry_version + for item in self.entries + ): + raise ValueError("claim policy and registry versions must match") + expected = canonical_hash( + {"entries": self.entries, "registry_version": self.registry_version} + ) + if self.registry_hash is None: + object.__setattr__(self, "registry_hash", expected) + elif self.registry_hash != expected: + raise ValueError("claim policy registry hash does not match entries") + + def resolve(self, axis: ClaimAxis, level: ClaimLevel) -> ClaimPolicy | None: + matches = tuple(item for item in self.entries if item.axis is axis and item.level is level) + if len(matches) > 1: + raise ValueError("RES70_REGISTRY_INTEGRITY_FAILURE: duplicate claim policy") + return matches[0] if matches else None + + @property + def canonical_hash(self) -> str: + assert self.registry_hash is not None + return self.registry_hash + + +def _measurement_policy( + level: MeasurementClaimLevel, + ordinal: int, + *, + lower: MeasurementClaimLevel | None = None, + analysis: bool = False, + result: bool = False, + evidence: tuple[ApplicabilityAxis, ...] = (), + comparable: bool = False, + error: bool = False, + criterion: bool = False, +) -> ClaimPolicy: + return ClaimPolicy( + policy_reference=_reference(f"measurement-{level.value.lower()}", level.value), + axis=ClaimAxis.MEASUREMENT_CHANGE, + level=level, + ordinal=ordinal, + required_lower_level=lower, + requires_analysis=analysis, + requires_statistical_result=result, + required_evidence_axes=evidence, + requires_comparability=comparable, + requires_measurement_error_authority=error, + requires_decision_criterion=criterion, + causal_operation_required=False, + ) + + +def _relationship_policy( + level: RelationshipClaimLevel, + ordinal: int, + *, + lower: RelationshipClaimLevel | None = None, + analysis: bool = False, + result: bool = False, + evidence: tuple[ApplicabilityAxis, ...] = (), + causal: bool = False, +) -> ClaimPolicy: + return ClaimPolicy( + policy_reference=_reference(f"relationship-{level.value.lower()}", level.value), + axis=ClaimAxis.RELATIONSHIP_CAUSAL, + level=level, + ordinal=ordinal, + required_lower_level=lower, + requires_analysis=analysis, + requires_statistical_result=result, + required_evidence_axes=evidence, + requires_comparability=False, + requires_measurement_error_authority=False, + requires_decision_criterion=False, + causal_operation_required=causal, + ) + + +RES70_CLAIM_POLICIES = ( + _measurement_policy(MeasurementClaimLevel.OBSERVED_VALUE, 0), + _measurement_policy( + MeasurementClaimLevel.NUMERICAL_CHANGE, + 1, + lower=MeasurementClaimLevel.OBSERVED_VALUE, + analysis=True, + result=True, + ), + _measurement_policy( + MeasurementClaimLevel.COMPARABLE_CHANGE, + 2, + lower=MeasurementClaimLevel.NUMERICAL_CHANGE, + analysis=True, + result=True, + comparable=True, + ), + _measurement_policy( + MeasurementClaimLevel.CHANGE_RELATIVE_TO_MEASUREMENT_ERROR, + 3, + lower=MeasurementClaimLevel.COMPARABLE_CHANGE, + analysis=True, + result=True, + evidence=(ApplicabilityAxis.METHOD_VALIDITY, ApplicabilityAxis.STATISTICAL_ADEQUACY), + comparable=True, + error=True, + ), + _measurement_policy( + MeasurementClaimLevel.PRACTICAL_OR_DECISION_MEANINGFULNESS, + 4, + lower=MeasurementClaimLevel.CHANGE_RELATIVE_TO_MEASUREMENT_ERROR, + analysis=True, + result=True, + evidence=( + ApplicabilityAxis.METHOD_VALIDITY, + ApplicabilityAxis.POPULATION_RELEVANCE, + ApplicabilityAxis.CONTEXTUAL_RELEVANCE, + ApplicabilityAxis.STATISTICAL_ADEQUACY, + ), + comparable=True, + error=True, + criterion=True, + ), + _relationship_policy(RelationshipClaimLevel.OBSERVATION, 0), + _relationship_policy( + RelationshipClaimLevel.DESCRIPTIVE_CHANGE, + 1, + lower=RelationshipClaimLevel.OBSERVATION, + analysis=True, + result=True, + ), + _relationship_policy( + RelationshipClaimLevel.ASSOCIATION, + 2, + lower=RelationshipClaimLevel.DESCRIPTIVE_CHANGE, + analysis=True, + result=True, + evidence=(ApplicabilityAxis.STATISTICAL_ADEQUACY,), + ), + _relationship_policy( + RelationshipClaimLevel.TEMPORAL_ASSOCIATION, + 3, + lower=RelationshipClaimLevel.ASSOCIATION, + analysis=True, + result=True, + evidence=(ApplicabilityAxis.STATISTICAL_ADEQUACY,), + ), + _relationship_policy( + RelationshipClaimLevel.MECHANISTIC_HYPOTHESIS, + 4, + lower=RelationshipClaimLevel.TEMPORAL_ASSOCIATION, + analysis=True, + result=True, + evidence=( + ApplicabilityAxis.METHOD_VALIDITY, + ApplicabilityAxis.POPULATION_RELEVANCE, + ApplicabilityAxis.CONTEXTUAL_RELEVANCE, + ), + ), + _relationship_policy( + RelationshipClaimLevel.CAUSAL_EVIDENCE, + 5, + lower=RelationshipClaimLevel.MECHANISTIC_HYPOTHESIS, + analysis=True, + result=True, + evidence=( + ApplicabilityAxis.METHOD_VALIDITY, + ApplicabilityAxis.POPULATION_RELEVANCE, + ApplicabilityAxis.CONTEXTUAL_RELEVANCE, + ApplicabilityAxis.STATISTICAL_ADEQUACY, + ), + causal=True, + ), +) + +RES70_CLAIM_POLICY_REGISTRY = ClaimPolicyRegistry(entries=RES70_CLAIM_POLICIES) +CANONICAL_CLAIM_POLICY_REGISTRY = RES70_CLAIM_POLICY_REGISTRY + + +def is_canonical_claim_registry(registry: ClaimPolicyRegistry) -> bool: + return registry is CANONICAL_CLAIM_POLICY_REGISTRY + + +__all__ = [ + "CANONICAL_CLAIM_POLICY_REGISTRY", + "RES70_CLAIM_POLICIES", + "RES70_CLAIM_POLICY_REGISTRY", + "RES70_CLAIM_REGISTRY_VERSION", + "ClaimAxis", + "ClaimLevel", + "ClaimPolicy", + "ClaimPolicyRegistry", + "is_canonical_claim_registry", +] diff --git a/src/dynamislm/claims/validation.py b/src/dynamislm/claims/validation.py new file mode 100644 index 0000000..cc2926e --- /dev/null +++ b/src/dynamislm/claims/validation.py @@ -0,0 +1,68 @@ +"""Hash and boundary validation for RES-70 claim decisions.""" + +from __future__ import annotations + +from dynamislm.claims.authority import authorize_claim +from dynamislm.claims.models import ClaimAuthorityResult, ClaimIntent +from dynamislm.claims.registry import ( + CANONICAL_CLAIM_POLICY_REGISTRY, + ClaimPolicyRegistry, + is_canonical_claim_registry, +) +from dynamislm.evidence.res70 import validate_claim_evidence_applicability +from dynamislm.serialization import canonical_hash + + +def validate_claim_authority( + result: ClaimAuthorityResult, + intent: ClaimIntent, + *, + registry: ClaimPolicyRegistry = CANONICAL_CLAIM_POLICY_REGISTRY, +) -> None: + if not isinstance(result, ClaimAuthorityResult): + raise ValueError("result must be a ClaimAuthorityResult") + if not isinstance(intent, ClaimIntent): + raise ValueError("intent must be a ClaimIntent") + if not is_canonical_claim_registry(registry): + raise ValueError("caller-supplied claim registries cannot authorize claims") + if result.claim_intent_reference != intent.claim_reference: + raise ValueError("claim result reference does not match claim intent") + if result.claim_intent_hash != intent.intent_hash: + raise ValueError("claim result intent hash does not match claim intent") + if intent.evidence_applicability is not None: + validate_claim_evidence_applicability(intent.evidence_applicability) + if result.evidence_applicability_hash != ( + intent.evidence_applicability.canonical_applicability_hash + ): + raise ValueError("claim result evidence hash does not match applicability bundle") + expected_hash = canonical_hash( + { + "status": result.status, + "claim_intent_reference": result.claim_intent_reference, + "claim_intent_hash": result.claim_intent_hash, + "allowed_measurement_levels": result.allowed_measurement_levels, + "allowed_relationship_levels": result.allowed_relationship_levels, + "prediction_status": result.prediction_status, + "blocked_claims": result.blocked_claims, + "first_blocking_prerequisite": result.first_blocking_prerequisite, + "reason_codes": result.reason_codes, + "missing_information": result.missing_information, + "safe_descriptions": result.safe_descriptions, + "support_hashes": result.support_hashes, + "analysis_hashes": result.analysis_hashes, + "comparability_hashes": result.comparability_hashes, + "bridge_hashes": result.bridge_hashes, + "evidence_applicability_hash": result.evidence_applicability_hash, + "registry_version": result.registry_version, + "software_version": result.software_version, + "refusal_result": result.refusal_result, + } + ) + if result.canonical_decision_hash != expected_hash: + raise ValueError("claim decision hash does not match immutable content") + expected = authorize_claim(intent, registry=registry) + if expected != result: + raise ValueError("claim decision does not recompute from canonical authority") + + +__all__ = ["validate_claim_authority"] diff --git a/tests/test_res70_claim_authority.py b/tests/test_res70_claim_authority.py new file mode 100644 index 0000000..5a143ac --- /dev/null +++ b/tests/test_res70_claim_authority.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from dynamislm import ( + ClaimAuthorityStatus, + ClaimIntent, + ClaimTarget, + MeasurementClaimLevel, + RegistryReference, + RelationshipClaimLevel, + ScientificIdentifier, + authorize_claim, +) +from dynamislm.claims.models import PredictionStatus +from dynamislm.refusal import RefusalResult +from test_kernel import _derived_observation + + +def _reference(object_type: str, key: str, label: str) -> RegistryReference: + return RegistryReference(ScientificIdentifier("dynamislm", object_type, key, "1.0.0"), label) + + +def _intent( + *, + measurement_level: MeasurementClaimLevel | None = None, + relationship_level: RelationshipClaimLevel | None = None, + target: ClaimTarget = ClaimTarget.INDIVIDUAL, +) -> ClaimIntent: + return ClaimIntent( + claim_reference=_reference("claim-intent", "res70-test", "RES-70 test claim"), + measurement_level=measurement_level, + relationship_level=relationship_level, + predictive_intent=None, + target=target, + analysis_reference=None, + evidence_applicability_reference=None, + decision_criterion_reference=None, + observations=(_derived_observation("res70-claim-observation"),), + ) + + +def test_observed_value_is_authorized_without_promoting_other_levels() -> None: + result = authorize_claim(_intent(measurement_level=MeasurementClaimLevel.OBSERVED_VALUE)) + + assert result.status is ClaimAuthorityStatus.AUTHORIZED + assert result.allowed_measurement_levels == (MeasurementClaimLevel.OBSERVED_VALUE,) + assert result.allowed_relationship_levels == () + assert result.prediction_status is PredictionStatus.NOT_REQUESTED + assert result.blocked_claims == () + + +def test_numerical_change_is_not_implicitly_comparable_change() -> None: + numerical = authorize_claim(_intent(measurement_level=MeasurementClaimLevel.NUMERICAL_CHANGE)) + comparable = authorize_claim(_intent(measurement_level=MeasurementClaimLevel.COMPARABLE_CHANGE)) + + assert isinstance(numerical.refusal_result, RefusalResult) + assert isinstance(comparable.refusal_result, RefusalResult) + assert "RES70_COMPARABILITY_AUTHORITY_MISSING" in comparable.reason_codes + assert MeasurementClaimLevel.COMPARABLE_CHANGE not in numerical.allowed_measurement_levels + + +def test_error_relative_change_and_practical_meaning_are_separate_gates() -> None: + error_relative = authorize_claim( + _intent(measurement_level=MeasurementClaimLevel.CHANGE_RELATIVE_TO_MEASUREMENT_ERROR) + ) + practical = authorize_claim( + _intent(measurement_level=MeasurementClaimLevel.PRACTICAL_OR_DECISION_MEANINGFULNESS) + ) + + assert "RES70_STATISTICAL_AUTHORITY_INSUFFICIENT" in error_relative.reason_codes + assert "RES70_UNSUPPORTED_CLAIM_ESCALATION" in practical.reason_codes + assert practical.status is ClaimAuthorityStatus.PARTIALLY_AUTHORIZED + assert MeasurementClaimLevel.OBSERVED_VALUE in practical.allowed_measurement_levels + + +def test_association_is_not_causal_and_causal_claim_is_refused() -> None: + association = authorize_claim(_intent(relationship_level=RelationshipClaimLevel.ASSOCIATION)) + causal = authorize_claim(_intent(relationship_level=RelationshipClaimLevel.CAUSAL_EVIDENCE)) + + assert "COMPUTATION_NOT_REGISTERED" in association.reason_codes + assert "RES70_UNSUPPORTED_CAUSAL_CLAIM" in causal.reason_codes + assert RelationshipClaimLevel.CAUSAL_EVIDENCE not in causal.allowed_relationship_levels + + +def test_prediction_remains_orthogonal_and_unregistered() -> None: + intent = ClaimIntent( + claim_reference=_reference("claim-intent", "prediction", "Prediction"), + measurement_level=MeasurementClaimLevel.OBSERVED_VALUE, + relationship_level=None, + predictive_intent=_reference("prediction-intent", "future", "Future prediction"), + target=ClaimTarget.INDIVIDUAL, + analysis_reference=None, + evidence_applicability_reference=None, + decision_criterion_reference=None, + observations=(_derived_observation("res70-prediction"),), + ) + + result = authorize_claim(intent) + + assert result.prediction_status is PredictionStatus.REFUSED + assert "COMPUTATION_NOT_REGISTERED" in result.reason_codes diff --git a/tests/test_res70_evidence_and_levels.py b/tests/test_res70_evidence_and_levels.py new file mode 100644 index 0000000..99fd589 --- /dev/null +++ b/tests/test_res70_evidence_and_levels.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from dynamislm import ( + ApplicabilityAssessment, + ApplicabilityAxis, + ApplicabilityDecision, + ClaimEvidenceApplicability, + RegistryReference, + ScientificIdentifier, + canonical_json, + from_canonical_json, +) + + +def _reference(object_type: str, key: str) -> RegistryReference: + return RegistryReference( + ScientificIdentifier("dynamislm", object_type, key, "1.0.0"), + key, + ) + + +def _assessment( + axis: ApplicabilityAxis, + decision: ApplicabilityDecision, +) -> ApplicabilityAssessment: + return ApplicabilityAssessment( + axis=axis, + decision=decision, + required_for_claim=True, + evidence_references=(_reference("evidence", axis.value.lower()),), + authority_references=(), + conditions=(), + rationale=f"assessment for {axis.value}", + ) + + +def test_evidence_axes_remain_independently_inspectable_and_roundtrip() -> None: + applicability = ClaimEvidenceApplicability( + claim_intent_reference=_reference("claim-intent", "practical"), + assessments=tuple( + _assessment(axis, ApplicabilityDecision.SUPPORTED) for axis in ApplicabilityAxis + ), + registry_version="res70-1.0.0", + ) + + assert ( + applicability.assessment(ApplicabilityAxis.METHOD_VALIDITY).decision + is ApplicabilityDecision.SUPPORTED + ) + assert ( + applicability.assessment(ApplicabilityAxis.POPULATION_RELEVANCE).decision + is ApplicabilityDecision.SUPPORTED + ) + assert not hasattr(applicability, "confidence_score") + assert ( + from_canonical_json(canonical_json(applicability), ClaimEvidenceApplicability) + == applicability + ) + + +def test_population_limitation_does_not_rewrite_method_validity() -> None: + assessments = tuple( + _assessment( + axis, + ApplicabilityDecision.UNSUPPORTED + if axis is ApplicabilityAxis.POPULATION_RELEVANCE + else ApplicabilityDecision.SUPPORTED, + ) + for axis in ApplicabilityAxis + ) + applicability = ClaimEvidenceApplicability( + claim_intent_reference=_reference("claim-intent", "population-limited"), + assessments=assessments, + registry_version="res70-1.0.0", + ) + + assert ( + applicability.assessment(ApplicabilityAxis.METHOD_VALIDITY).decision + is ApplicabilityDecision.SUPPORTED + ) + assert ( + applicability.assessment(ApplicabilityAxis.POPULATION_RELEVANCE).decision + is ApplicabilityDecision.UNSUPPORTED + ) + assert not applicability.supports_required_axes() + + +def test_unassessed_required_axis_is_rejected_at_contract_boundary() -> None: + try: + _assessment(ApplicabilityAxis.CONTEXTUAL_RELEVANCE, ApplicabilityDecision.UNASSESSED) + except ValueError as exc: + assert "unassessed" in str(exc) + else: + raise AssertionError("required unassessed applicability axis must be rejected") From c6e8c0786149b0abcf3cf9b576b71175b61d22ad Mon Sep 17 00:00:00 2001 From: Julio Rodriguez <144072916+Litju@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:01:46 +0000 Subject: [PATCH 06/18] test(res70): qualify cross-source authority adversarially --- src/dynamislm/analysis/validation.py | 3 +- tests/test_res70_adversarial.py | 210 +++++++++++++++++++++++++++ 2 files changed, 212 insertions(+), 1 deletion(-) create mode 100644 tests/test_res70_adversarial.py diff --git a/src/dynamislm/analysis/validation.py b/src/dynamislm/analysis/validation.py index c37ef88..7ddca5a 100644 --- a/src/dynamislm/analysis/validation.py +++ b/src/dynamislm/analysis/validation.py @@ -167,7 +167,8 @@ def validate_level_of_analysis( counts = Counter(athlete_ids) if any(count > 1 for count in counts.values()) and not level.clustering_keys: raise AnalysisValidationError( - "repeated rows cannot be treated as independent between-athlete subjects", + "pseudoreplication risk: repeated rows cannot be treated as independent " + "between-athlete subjects", "RES70_PSEUDOREPLICATION_RISK", ("one independent unit per athlete or a registered clustering model",), ) diff --git a/tests/test_res70_adversarial.py b/tests/test_res70_adversarial.py new file mode 100644 index 0000000..14cd16c --- /dev/null +++ b/tests/test_res70_adversarial.py @@ -0,0 +1,210 @@ +from __future__ import annotations + +from dataclasses import replace + +import pytest + +from dynamislm import ( + AnalysisClass, + AnalysisEstimandLevel, + AnalysisLevelIdentity, + AnalysisUnitOfAnalysis, + ApplicabilityAssessment, + ApplicabilityAxis, + ApplicabilityDecision, + ClaimEvidenceApplicability, + ClaimIntent, + ClaimTarget, + InstanceIdentifier, + MeasurementClaimLevel, + RegistryReference, + RelationshipClaimLevel, + ScientificIdentifier, + authorize_claim, +) +from dynamislm.analysis.registry import RES70_CAPABILITY_REGISTRY +from dynamislm.analysis.validation import validate_level_of_analysis +from dynamislm.comparability import ( + BridgeRegistry, + RES70ComparabilityAuthorityError, + assess_cross_source_comparability, + validate_pairwise_decisions, +) +from dynamislm.comparability.res70_models import ( + CrossSourceComparabilityDecision, + CrossSourceComparabilityRequest, + ObservationAuthorityReference, +) +from dynamislm.evidence.res70 import validate_claim_evidence_applicability +from dynamislm.refusal import RefusalResult +from test_kernel import _derived_observation +from test_longitudinal_statistics import _entries, _support + + +def _reference(object_type: str, key: str, label: str = "fixture") -> RegistryReference: + return RegistryReference(ScientificIdentifier("dynamislm", object_type, key, "1.0.0"), label) + + +def _complete_pair() -> tuple[object, object, CrossSourceComparabilityRequest]: + left = _derived_observation("res70-adversarial-left") + right = _derived_observation("res70-adversarial-third") + unit = _reference("unit", "meter", "m") + del unit + request = CrossSourceComparabilityRequest( + request_id=InstanceIdentifier("cross-source-comparability-request", "adversarial"), + left_observation=ObservationAuthorityReference.from_observation(left), + right_observation=ObservationAuthorityReference.from_observation(right), + claim_intent=_reference("claim-intent", "adversarial"), + ) + return left, right, request + + +def test_caller_supplied_empty_registry_cannot_authorize_any_pair() -> None: + left, right, request = _complete_pair() + + with pytest.raises(RES70ComparabilityAuthorityError, match="caller-supplied"): + assess_cross_source_comparability( + request, + (left, right), # type: ignore[arg-type] + bridge_registry=BridgeRegistry(), + ) + + +def test_pairwise_collection_never_promotes_a_missing_third_pair() -> None: + left, middle, _ = _complete_pair() + right = _derived_observation("res70-adversarial-right") + claim = _reference("claim-intent", "pairwise") + + def decision(a: object, b: object, key: str) -> CrossSourceComparabilityDecision: + request = CrossSourceComparabilityRequest( + request_id=InstanceIdentifier("cross-source-comparability-request", key), + left_observation=ObservationAuthorityReference.from_observation(a), # type: ignore[arg-type] + right_observation=ObservationAuthorityReference.from_observation(b), # type: ignore[arg-type] + claim_intent=claim, + ) + return assess_cross_source_comparability(request, (a, b)) # type: ignore[arg-type] + + first = decision(left, middle, "ab") + second = decision(middle, right, "bc") + validate_pairwise_decisions((first, second)) + assert frozenset((left.observation_id.qualified, right.observation_id.qualified)) not in { # type: ignore[attr-defined] + item.pair_key for item in (first, second) + } + + +def test_between_rows_from_one_athlete_are_pseudoreplication_risk() -> None: + entries = _entries((10.0, 11.0), prefix="res70-pseudorep") + support = _support(entries) + request = __import__( + "dynamislm.analysis.models", + fromlist=["AnalysisAuthorizationRequest"], + ).AnalysisAuthorizationRequest( + request_id=InstanceIdentifier("analysis-authorization-request", "pseudo"), + analysis_class=AnalysisClass.BETWEEN_ATHLETE_ASSOCIATION, + support=support, + support_reference=None, + observations=tuple(entry.observation for entry in entries), + identity_hashes=(), + comparability_decisions=(), + requested_level=AnalysisLevelIdentity( + unit_of_analysis=AnalysisUnitOfAnalysis.ATHLETE, + estimand_level=AnalysisEstimandLevel.BETWEEN_ATHLETE, + subject_key="athlete_id", + clustering_keys=(), + grouping_keys=(), + ), + evidence_applicability=None, + context_references=(), + ) + capability = RES70_CAPABILITY_REGISTRY.resolve(AnalysisClass.BETWEEN_ATHLETE_ASSOCIATION) + assert capability is not None + + with pytest.raises(ValueError, match="pseudoreplication"): + validate_level_of_analysis(request, capability, support) + + +def test_evidence_axis_limitation_is_not_collapsed_to_method_failure() -> None: + bundle = ClaimEvidenceApplicability( + claim_intent_reference=_reference("claim-intent", "axis-limitation"), + assessments=tuple( + ApplicabilityAssessment( + axis=axis, + decision=( + ApplicabilityDecision.UNSUPPORTED + if axis is ApplicabilityAxis.POPULATION_RELEVANCE + else ApplicabilityDecision.SUPPORTED + ), + required_for_claim=True, + evidence_references=(_reference("evidence", axis.value.lower()),), + authority_references=(), + conditions=(), + rationale=axis.value, + ) + for axis in ApplicabilityAxis + ), + registry_version="res70-1.0.0", + ) + + validate_claim_evidence_applicability(bundle) + assert ( + bundle.assessment(ApplicabilityAxis.METHOD_VALIDITY).decision + is ApplicabilityDecision.SUPPORTED + ) + assert ( + bundle.assessment(ApplicabilityAxis.POPULATION_RELEVANCE).decision + is ApplicabilityDecision.UNSUPPORTED + ) + + +def test_tampered_applicability_hash_is_rejected() -> None: + bundle = ClaimEvidenceApplicability( + claim_intent_reference=_reference("claim-intent", "tamper"), + assessments=( + ApplicabilityAssessment( + axis=ApplicabilityAxis.METHOD_VALIDITY, + decision=ApplicabilityDecision.SUPPORTED, + required_for_claim=True, + evidence_references=(_reference("evidence", "method"),), + authority_references=(), + conditions=(), + rationale="method", + ), + ), + registry_version="res70-1.0.0", + ) + with pytest.raises(ValueError, match="hash"): + replace(bundle, applicability_hash="sha256:" + "0" * 64) + + +def test_unsupported_practical_and_causal_escalations_are_refused() -> None: + observation = _derived_observation("res70-escalation") + practical = ClaimIntent( + claim_reference=_reference("claim-intent", "practical"), + measurement_level=MeasurementClaimLevel.PRACTICAL_OR_DECISION_MEANINGFULNESS, + relationship_level=None, + predictive_intent=None, + target=ClaimTarget.INDIVIDUAL, + analysis_reference=None, + evidence_applicability_reference=None, + decision_criterion_reference=None, + observations=(observation,), + ) + causal = ClaimIntent( + claim_reference=_reference("claim-intent", "causal"), + measurement_level=None, + relationship_level=RelationshipClaimLevel.CAUSAL_EVIDENCE, + predictive_intent=None, + target=ClaimTarget.INDIVIDUAL, + analysis_reference=None, + evidence_applicability_reference=None, + decision_criterion_reference=None, + observations=(observation,), + ) + + practical_result = authorize_claim(practical) + causal_result = authorize_claim(causal) + + assert isinstance(practical_result.refusal_result, RefusalResult) + assert isinstance(causal_result.refusal_result, RefusalResult) + assert "RES70_UNSUPPORTED_CLAIM_ESCALATION" in practical_result.reason_codes + assert "RES70_UNSUPPORTED_CAUSAL_CLAIM" in causal_result.reason_codes From 978d2597c3137c06477bd5508fba6acbb1914191 Mon Sep 17 00:00:00 2001 From: Julio Rodriguez <144072916+Litju@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:04:16 +0000 Subject: [PATCH 07/18] fix(res70): remove redundant authority scaffolding --- src/dynamislm/claims/authority.py | 7 ------- src/dynamislm/comparability/res70_authority.py | 8 -------- tests/test_res70_adversarial.py | 2 -- 3 files changed, 17 deletions(-) diff --git a/src/dynamislm/claims/authority.py b/src/dynamislm/claims/authority.py index e7eee4b..5bba958 100644 --- a/src/dynamislm/claims/authority.py +++ b/src/dynamislm/claims/authority.py @@ -339,13 +339,6 @@ def _policy_result( ok, missing, reasons = _target_level_supports(intent) if not ok: return False, missing, reasons, (missing or "level-of-analysis identity",) - if policy.causal_operation_required: - return ( - False, - "registered causal operation", - ("RES70_UNSUPPORTED_CAUSAL_CLAIM",), - ("causal estimand and identification strategy",), - ) return True, None, (), () diff --git a/src/dynamislm/comparability/res70_authority.py b/src/dynamislm/comparability/res70_authority.py index 17c65f6..bf56136 100644 --- a/src/dynamislm/comparability/res70_authority.py +++ b/src/dynamislm/comparability/res70_authority.py @@ -175,14 +175,6 @@ def _dimension_values( _TRANSFORMATION_DIMENSIONS = { ComparabilityDimension.UNIT, } -_BRIDGE_DIMENSIONS = ( - set(ComparabilityDimension) - - _IRRECONCILABLE_DIMENSIONS - - { - ComparabilityDimension.UNIT, - ComparabilityDimension.FOOTBALL_WORLD_CONTEXT, - } -) _OPTIONAL_NOT_APPLICABLE_DIMENSIONS = { ComparabilityDimension.NORMALIZATION, ComparabilityDimension.REGISTERED_PROCESSING_OPERATION, diff --git a/tests/test_res70_adversarial.py b/tests/test_res70_adversarial.py index 14cd16c..33d8986 100644 --- a/tests/test_res70_adversarial.py +++ b/tests/test_res70_adversarial.py @@ -48,8 +48,6 @@ def _reference(object_type: str, key: str, label: str = "fixture") -> RegistryRe def _complete_pair() -> tuple[object, object, CrossSourceComparabilityRequest]: left = _derived_observation("res70-adversarial-left") right = _derived_observation("res70-adversarial-third") - unit = _reference("unit", "meter", "m") - del unit request = CrossSourceComparabilityRequest( request_id=InstanceIdentifier("cross-source-comparability-request", "adversarial"), left_observation=ObservationAuthorityReference.from_observation(left), From a178028b5de29ee53075a37a3076c0997816dd43 Mon Sep 17 00:00:00 2001 From: Julio Rodriguez <144072916+Litju@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:05:59 +0000 Subject: [PATCH 08/18] docs(res70): bind implementation evidence --- ...-comparability-analysis-claim-authority.md | 52 +++++++++++++ docs/decisions/RES70-RECEIPT.json | 78 +++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 docs/decisions/RES70-RECEIPT.json diff --git a/docs/decisions/RES70-DR-001-cross-source-comparability-analysis-claim-authority.md b/docs/decisions/RES70-DR-001-cross-source-comparability-analysis-claim-authority.md index 28d6909..63855c1 100644 --- a/docs/decisions/RES70-DR-001-cross-source-comparability-analysis-claim-authority.md +++ b/docs/decisions/RES70-DR-001-cross-source-comparability-analysis-claim-authority.md @@ -1124,3 +1124,55 @@ RES34_TO_RES69_AUTHORITY_CHANGED=NO The next authorized action is a separate implementation mission that realizes this record and adds adversarial tests. RES-70-DR-001 itself does not authorize production code, tests, bridges, deferred statistics, or model work. + +## Implementation evidence — RES-70-IMPLEMENTATION-001 + +This section records factual implementation evidence for the separately +authorized implementation mission. It does not alter the frozen scientific +design or authorize any deferred operation. + +```text +IMPLEMENTATION_STATUS=COMPLETE_PENDING_REVIEW +IMPLEMENTATION_MISSION=RES-70-IMPLEMENTATION-001 +IMPLEMENTATION_ENTRY_HEAD=5e01e409234b5fab242ff9937a76efbb7020c255 +SERIALIZATION_VERSION=3 +RES34_TO_RES69_AUTHORITY_CHANGED=NO +RES71_PLUS_IMPLEMENTED=NO +DEPENDENCY_EXPANSION=NO +``` + +Implemented authority surfaces: + +- exact-hash observation references and pairwise cross-source comparability; +- material-dimension findings with explicit six-state outcomes and no + transitive closure; +- canonical bridge declarations, deterministic execution, append-only output + observations, and provenance-bound transformation results; +- RES-69-consuming analysis capability registry and prerequisite gate; +- two-axis measurement/change and relationship/causal claim authority with + prediction kept separate; +- independent method, source, population, contextual, and statistical + applicability assessments; +- explicit level-of-analysis, repeated-unit, and pseudo-replication gates; +- RES-70 refusal codes, V3 contracts, hashes, and adversarial tests. + +Deferred or refused by implementation, as required by this record: + +- deferred RES-69 repeated-measures and mixed-effects operations; +- generic practical thresholds, readiness/fatigue/injury interpretation, and + causal estimation; +- LM runtime/tool calling, model training, GPU work, and RES-71 work; +- family-specific production bridges without their owning scientific evidence. + +Atomic implementation commits: + +```text +e7223dc feat(res70): add cross-source authority contracts +6975e40 feat(res70): implement comparability and bridge authority +f9f30dd feat(res70): implement analysis capability validation +fd16a89 feat(res70): implement claim authority adjudication +c6e8c07 test(res70): qualify cross-source authority adversarially +978d259 fix(res70): remove redundant authority scaffolding +``` + +The final implementation receipt is `docs/decisions/RES70-RECEIPT.json`. diff --git a/docs/decisions/RES70-RECEIPT.json b/docs/decisions/RES70-RECEIPT.json new file mode 100644 index 0000000..5071598 --- /dev/null +++ b/docs/decisions/RES70-RECEIPT.json @@ -0,0 +1,78 @@ +{ + "mission": "RES-70-IMPLEMENTATION-001", + "project": "DynamisLM", + "linear_issue": "RES-70", + "base_main": "83041cd3e69e0dc0947c181b19effd5402bd5a38", + "design_head": "5e01e409234b5fab242ff9937a76efbb7020c255", + "design_record": "docs/decisions/RES70-DR-001-cross-source-comparability-analysis-claim-authority.md", + "branch": "work/res-70-cross-source-comparability-analysis-claim-authority", + "implementation_entry_head": "5e01e409234b5fab242ff9937a76efbb7020c255", + "atomic_commits": [ + "e7223dc feat(res70): add cross-source authority contracts", + "6975e40 feat(res70): implement comparability and bridge authority", + "f9f30dd feat(res70): implement analysis capability validation", + "fd16a89 feat(res70): implement claim authority adjudication", + "c6e8c07 test(res70): qualify cross-source authority adversarially", + "978d259 fix(res70): remove redundant authority scaffolding" + ], + "authority_surfaces": { + "comparability": "PASS", + "bridge": "PASS", + "analysis_capability_validation": "PASS", + "claim_authority": "PASS", + "evidence_applicability": "PASS", + "level_of_analysis_gates": "PASS", + "refusal_taxonomy": "PASS", + "provenance_binding": "PASS" + }, + "guards": { + "caller_minted_authority": "BLOCKED", + "implicit_bridge_execution": "BLOCKED", + "transitive_comparability_inference": "BLOCKED", + "level_of_analysis_leakage": "BLOCKED", + "unsupported_claim_escalation": "BLOCKED", + "unsupported_causal_escalation": "BLOCKED", + "res34_69_science_changed": "NO", + "res71_plus_implemented": "NO", + "dependency_expansion": "NO", + "serialization_version": 3 + }, + "scope": { + "real_empirical_data_added": false, + "model_training_implemented": false, + "gpu_code_added": false, + "parallel_observation_or_provenance_system": false, + "deferred_res69_methods_implemented": false + }, + "focused_qa_before_canonical_gate": { + "test_count": 65, + "ruff": "PASS", + "format": "PASS", + "mypy": "PASS", + "git_diff_check": "PASS", + "tracked_mutation": "NONE" + }, + "codebase_memory": { + "used": true, + "project": "home-litju-projects-DynamisLM", + "indexed_root": "/home/litju/projects/DynamisLM", + "reindexed_after_implementation": true, + "changed_symbol_downstream_audit": "PASS", + "unexpected_downstream_impact": "NONE" + }, + "canonical_qa": { + "command": "./scripts/ci.sh", + "ruff": "PASS", + "format": "PASS", + "mypy": "PASS", + "repository_policy": "PASS", + "pytest": "PASS", + "test_count": 830, + "tracked_mutation": "NONE", + "git_diff_check": "PASS" + }, + "pr": "PENDING", + "linear_status": "IN_PROGRESS", + "review_state": "PENDING", + "next_authorized_action": "RES-70-REVIEW-001" +} From eb0baf4f20d9e780cda5b7ed17259972853a7d34 Mon Sep 17 00:00:00 2001 From: Julio Rodriguez <144072916+Litju@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:09:55 +0000 Subject: [PATCH 09/18] docs(res70): bind PR review handoff --- docs/decisions/RES70-RECEIPT.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/decisions/RES70-RECEIPT.json b/docs/decisions/RES70-RECEIPT.json index 5071598..61acee8 100644 --- a/docs/decisions/RES70-RECEIPT.json +++ b/docs/decisions/RES70-RECEIPT.json @@ -71,8 +71,9 @@ "tracked_mutation": "NONE", "git_diff_check": "PASS" }, - "pr": "PENDING", + "pr": 33, "linear_status": "IN_PROGRESS", - "review_state": "PENDING", + "hosted_ci": "IN_PROGRESS", + "review_state": "OPEN_PENDING_HOSTED_CI_AND_SCIENTIFIC_REVIEW", "next_authorized_action": "RES-70-REVIEW-001" } From 70dd4f06fb1d92f1229a44ff8eda0b010bdce97c Mon Sep 17 00:00:00 2001 From: Julio Rodriguez <144072916+Litju@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:56:30 +0000 Subject: [PATCH 10/18] fix(res70): bind comparability and bridge execution authority --- src/dynamislm/__init__.py | 2 + src/dynamislm/comparability/__init__.py | 2 + .../comparability/res70_authority.py | 56 +++- src/dynamislm/comparability/res70_models.py | 15 ++ .../comparability/res70_validation.py | 247 +++++++++++++++++- 5 files changed, 317 insertions(+), 5 deletions(-) diff --git a/src/dynamislm/__init__.py b/src/dynamislm/__init__.py index 15f2940..00d1c9b 100644 --- a/src/dynamislm/__init__.py +++ b/src/dynamislm/__init__.py @@ -97,6 +97,7 @@ validate_bridge_registration, validate_bridge_registry, validate_cross_source_decision, + validate_cross_source_decision_set, validate_cross_source_request, validate_pairwise_decisions, ) @@ -844,6 +845,7 @@ "validate_bridge_registry", "validate_bridge_registration", "validate_cross_source_decision", + "validate_cross_source_decision_set", "validate_cross_source_request", "validate_pairwise_decisions", "validate_analysis_authorization", diff --git a/src/dynamislm/comparability/__init__.py b/src/dynamislm/comparability/__init__.py index ff5e8b6..244a2e2 100644 --- a/src/dynamislm/comparability/__init__.py +++ b/src/dynamislm/comparability/__init__.py @@ -60,6 +60,7 @@ validate_bridge_registration, validate_bridge_registry, validate_cross_source_decision, + validate_cross_source_decision_set, validate_cross_source_request, validate_pairwise_decisions, ) @@ -112,6 +113,7 @@ "validate_bridge_registration", "validate_bridge_registry", "validate_cross_source_decision", + "validate_cross_source_decision_set", "validate_cross_source_request", "validate_pairwise_decisions", ] diff --git a/src/dynamislm/comparability/res70_authority.py b/src/dynamislm/comparability/res70_authority.py index bf56136..ad06d12 100644 --- a/src/dynamislm/comparability/res70_authority.py +++ b/src/dynamislm/comparability/res70_authority.py @@ -331,6 +331,8 @@ def assess_cross_source_comparability( bridge_registry: BridgeRegistry | None = None, rule_registry: ComparabilityRuleRegistry = RES70_COMPARABILITY_RULE_REGISTRY, bridge_execution: BridgeExecutionResult | None = None, + bridge_request: BridgeApplicationRequest | None = None, + football_contexts: Mapping[object, object] | Sequence[object] | None = None, family_result: ComparabilityResult | None = None, ) -> CrossSourceComparabilityDecision: """Adjudicate one pair without label matching or transitive closure.""" @@ -381,6 +383,7 @@ def assess_cross_source_comparability( reason_codes=reasons, missing_information=missing, leaf_result=leaf, + bridge_execution_hash=None, ) effective_left = left @@ -397,14 +400,32 @@ def assess_cross_source_comparability( ) try: if bridge_execution.source_observation == request.left_observation: - validate_bridge_execution(bridge_execution, None, left) + validate_bridge_execution( + bridge_execution, + bridge_request.request_hash + if bridge_request is not None + else bridge_execution.request_hash, + left, + bridge_registry=bridge_registry, + bridge_request=bridge_request, + target_identity=right.identity, + ) if bridge_execution.transformed_observation is None: raise ComparabilityAuthorityError( "executed bridge has no transformed observation" ) effective_left = bridge_execution.transformed_observation elif bridge_execution.source_observation == request.right_observation: - validate_bridge_execution(bridge_execution, None, right) + validate_bridge_execution( + bridge_execution, + bridge_request.request_hash + if bridge_request is not None + else bridge_execution.request_hash, + right, + bridge_registry=bridge_registry, + bridge_request=bridge_request, + target_identity=left.identity, + ) if bridge_execution.transformed_observation is None: raise ComparabilityAuthorityError( "executed bridge has no transformed observation" @@ -541,6 +562,11 @@ def assess_cross_source_comparability( reason_codes=reasons, missing_information=missing, leaf_result=leaf, + bridge_execution_hash=( + bridge_execution.canonical_execution_hash + if bridge_applied and bridge_execution is not None + else None + ), ) @@ -694,6 +720,8 @@ def execute_registered_bridge( "output_observation_hash": None, "uncertainty_model": bridge.uncertainty_model, "lossiness_description": bridge.lossiness_description, + "method_version": bridge.method_version, + "provenance_rule": bridge.provenance_rule, "status": BridgeExecutionStatus.DECLARATIVE_APPLIED, } execution_hash = canonical_hash(execution_content) @@ -713,6 +741,8 @@ def execute_registered_bridge( uncertainty_model=bridge.uncertainty_model, lossiness_description=bridge.lossiness_description, execution_hash=execution_hash, + method_version=bridge.method_version, + provenance_rule=bridge.provenance_rule, ) return result if request.target_identity is None: @@ -826,6 +856,8 @@ def execute_registered_bridge( "output_observation_hash": canonical_hash(transformed), "uncertainty_model": bridge.uncertainty_model, "lossiness_description": bridge.lossiness_description, + "method_version": bridge.method_version, + "provenance_rule": bridge.provenance_rule, "status": BridgeExecutionStatus.EXECUTED, } execution_hash = canonical_hash(execution_content) @@ -843,7 +875,27 @@ def execute_registered_bridge( uncertainty_model=bridge.uncertainty_model, lossiness_description=bridge.lossiness_description, execution_hash=execution_hash, + method_version=bridge.method_version, + provenance_rule=bridge.provenance_rule, ) + try: + validate_bridge_execution( + result, + request.request_hash, + source_observation, + bridge_registry=bridge_registry, + operation_registry=operation_registry, + bridge_request=request, + target_identity=request.target_identity, + ) + except RES70ValidationError as exc: + return build_res70_refusal( + "registered numerical bridge execution", + "RES70_BRIDGE_EXECUTION_FAILED", + refusal_class=RefusalClass.COMPUTATION_NOT_REGISTERED, + missing_information=(str(exc),), + observation_ids=(observation_id,), + ) return result diff --git a/src/dynamislm/comparability/res70_models.py b/src/dynamislm/comparability/res70_models.py index ff8dd3a..48cd786 100644 --- a/src/dynamislm/comparability/res70_models.py +++ b/src/dynamislm/comparability/res70_models.py @@ -412,6 +412,7 @@ class CrossSourceComparabilityDecision: reason_codes: tuple[str, ...] = () missing_information: tuple[str, ...] = () leaf_result: ComparabilityResult | None = None + bridge_execution_hash: str | None = None decision_hash: str | None = None def __post_init__(self) -> None: @@ -447,6 +448,8 @@ def __post_init__(self) -> None: _require_string_tuple(self.reason_codes, "reason_codes") _require_string_tuple(self.missing_information, "missing_information") _require_optional_instance(self.leaf_result, ComparabilityResult, "leaf_result") + if self.bridge_execution_hash is not None: + _require_hash(self.bridge_execution_hash, "bridge_execution_hash") if self.state is ComparabilityState.COMPARABLE_WITH_CONDITIONS and not self.conditions: raise ValueError("conditional comparability must retain explicit conditions") if self.state is ComparabilityState.REQUIRES_TRANSFORMATION and not ( @@ -483,6 +486,7 @@ def _content_hash(self) -> str: "reason_codes": self.reason_codes, "missing_information": self.missing_information, "leaf_result": self.leaf_result, + "bridge_execution_hash": self.bridge_execution_hash, } ) @@ -519,6 +523,7 @@ def create( reason_codes: tuple[str, ...] = (), missing_information: tuple[str, ...] = (), leaf_result: ComparabilityResult | None = None, + bridge_execution_hash: str | None = None, ) -> CrossSourceComparabilityDecision: """Construct a decision while deriving its immutable ID and hash.""" @@ -538,6 +543,7 @@ def create( "reason_codes": reason_codes, "missing_information": missing_information, "leaf_result": leaf_result, + "bridge_execution_hash": bridge_execution_hash, } decision_hash = canonical_hash(content) return cls( @@ -560,6 +566,7 @@ def create( reason_codes=reason_codes, missing_information=missing_information, leaf_result=leaf_result, + bridge_execution_hash=bridge_execution_hash, decision_hash=decision_hash, ) @@ -727,6 +734,8 @@ class BridgeExecutionResult: uncertainty_model: RegistryReference | None lossiness_description: str | None execution_hash: str | None = None + method_version: RegistryReference | None = None + provenance_rule: RegistryReference | None = None def __post_init__(self) -> None: _require_instance(self.execution_id, InstanceIdentifier, "execution_id") @@ -751,12 +760,16 @@ def __post_init__(self) -> None: _require_optional_instance(self.uncertainty_model, RegistryReference, "uncertainty_model") if self.lossiness_description is not None: _require_text(self.lossiness_description, "lossiness_description") + _require_optional_instance(self.method_version, RegistryReference, "method_version") + _require_optional_instance(self.provenance_rule, RegistryReference, "provenance_rule") if self.status is BridgeExecutionStatus.EXECUTED: if ( self.transformed_observation is None or self.processing_run is None or self.provenance is None or self.output_observation_hash is None + or self.method_version is None + or self.provenance_rule is None ): raise ValueError("executed bridge result must contain transformed provenance") if canonical_hash(self.transformed_observation) != self.output_observation_hash: @@ -775,6 +788,8 @@ def __post_init__(self) -> None: "output_observation_hash": self.output_observation_hash, "uncertainty_model": self.uncertainty_model, "lossiness_description": self.lossiness_description, + "method_version": self.method_version, + "provenance_rule": self.provenance_rule, "status": self.status, } ) diff --git a/src/dynamislm/comparability/res70_validation.py b/src/dynamislm/comparability/res70_validation.py index 431b27e..cd6ea7b 100644 --- a/src/dynamislm/comparability/res70_validation.py +++ b/src/dynamislm/comparability/res70_validation.py @@ -5,14 +5,18 @@ from collections.abc import Mapping, Sequence from dynamislm.comparability.res70_models import ( + BridgeApplicationRequest, BridgeExecutionResult, + BridgeExecutionStatus, BridgeRegistration, CrossSourceComparabilityDecision, CrossSourceComparabilityRequest, ObservationAuthorityReference, + SemanticIdentityKey, ) from dynamislm.comparability.res70_registry import ( RES70_CROSS_SOURCE_COMPARABILITY_RULE, + RES70_SOFTWARE_VERSION, BridgeOperationRegistry, BridgeRegistry, ComparabilityRuleRegistry, @@ -20,8 +24,15 @@ is_canonical_bridge_registry, is_canonical_operation_registry, ) -from dynamislm.measurement.identity import InstanceIdentifier, RegistryReference +from dynamislm.measurement.identity import ( + InstanceIdentifier, + MeasurementIdentity, + RegistryReference, +) from dynamislm.measurement.observation import ScientificMeasurementObservation +from dynamislm.measurement.result import ScalarValue +from dynamislm.measurement.taxonomy import ValueOrigin +from dynamislm.provenance.models import EvidenceReference from dynamislm.refusal.models import RefusalClass, RefusalResult, RefusalStatus from dynamislm.serialization import canonical_hash @@ -141,6 +152,13 @@ def validate_bridge_registration( and operation_registry.resolve(registration.transformation_operation) is None ): raise RES70ValidationError("bridge transformation operation is not registered") + if registration.bridge_mode.value == "NUMERICAL_TRANSFORMATION": + if registration.transformation_operation is None: + raise RES70ValidationError("numerical bridge is missing its registered operation") + elif registration.transformation_operation is not None: + raise RES70ValidationError( + "declarative bridge cannot carry a numerical transformation operation" + ) def validate_bridge_registry( @@ -154,8 +172,10 @@ def validate_bridge_registry( raise RES70ValidationError( "caller-supplied bridge registries cannot authorize production bridges" ) + from dynamislm.comparability.res70_registry import CANONICAL_BRIDGE_OPERATION_REGISTRY + for entry in registry.entries: - validate_bridge_registration(entry) + validate_bridge_registration(entry, operation_registry=CANONICAL_BRIDGE_OPERATION_REGISTRY) expected = canonical_hash( { "entries": registry.entries, @@ -173,6 +193,12 @@ def validate_cross_source_decision( *, bridge_registry: BridgeRegistry, rule_registry: ComparabilityRuleRegistry, + observations: Mapping[object, ScientificMeasurementObservation] + | Sequence[ScientificMeasurementObservation] + | None = None, + bridge_request: BridgeApplicationRequest | None = None, + bridge_execution: BridgeExecutionResult | None = None, + football_contexts: Mapping[object, object] | Sequence[object] | None = None, ) -> None: if not isinstance(decision, CrossSourceComparabilityDecision): raise RES70ValidationError("decision must be a CrossSourceComparabilityDecision") @@ -207,23 +233,139 @@ def validate_cross_source_decision( raise RES70ValidationError("conditional comparability requires conditions or a bridge") if decision.canonical_decision_hash != decision._content_hash(): raise RES70ValidationError("decision hash does not match immutable decision content") + if decision.bridge_execution_hash is not None: + if bridge_execution is None: + raise RES70ValidationError( + "decision bridge execution hash cannot be verified without its execution" + ) + if decision.bridge_execution_hash != bridge_execution.canonical_execution_hash: + raise RES70ValidationError("decision bridge execution hash does not match execution") + elif bridge_execution is not None: + raise RES70ValidationError("decision is missing its supplied bridge execution binding") + + if observations is not None: + _recompute_cross_source_decision( + decision, + request, + observations, + bridge_request=bridge_request, + bridge_execution=bridge_execution, + football_contexts=football_contexts, + bridge_registry=bridge_registry, + rule_registry=rule_registry, + ) def validate_bridge_execution( result: BridgeExecutionResult, request_hash: str | None, source_observation: ScientificMeasurementObservation, + *, + bridge_registry: BridgeRegistry | None = None, + operation_registry: BridgeOperationRegistry | None = None, + bridge_request: BridgeApplicationRequest | None = None, + target_identity: MeasurementIdentity | None = None, ) -> None: if not isinstance(result, BridgeExecutionResult): raise RES70ValidationError("result must be a BridgeExecutionResult") - if request_hash is not None and result.request_hash != request_hash: + if request_hash is None: + raise RES70ValidationError( + "bridge execution authority requires an exact bridge request hash" + ) + if result.request_hash != request_hash: raise RES70ValidationError("bridge execution request hash does not match request") + if bridge_registry is None: + from dynamislm.comparability.res70_registry import CANONICAL_BRIDGE_REGISTRY + + bridge_registry = CANONICAL_BRIDGE_REGISTRY + if operation_registry is None: + from dynamislm.comparability.res70_registry import CANONICAL_BRIDGE_OPERATION_REGISTRY + + operation_registry = CANONICAL_BRIDGE_OPERATION_REGISTRY + validate_bridge_registry(bridge_registry, require_canonical=True) + if not is_canonical_operation_registry(operation_registry): + raise RES70ValidationError( + "caller-supplied bridge operation registries cannot authorize execution" + ) + bridge = bridge_registry.resolve(result.bridge_reference) + if bridge is None: + raise RES70ValidationError("bridge execution references an unregistered bridge") + if result.bridge_hash != bridge.canonical_bridge_hash: + raise RES70ValidationError("bridge execution hash does not match canonical registration") + if bridge_request is not None: + if not isinstance(bridge_request, BridgeApplicationRequest): + raise RES70ValidationError("bridge_request must be a BridgeApplicationRequest") + if bridge_request.request_hash != result.request_hash: + raise RES70ValidationError("bridge execution request hash does not match exact request") + if bridge_request.source_observation != result.source_observation: + raise RES70ValidationError("bridge request source does not match execution") + if bridge_request.bridge_reference != result.bridge_reference: + raise RES70ValidationError("bridge request bridge does not match execution") + if bridge_request.requested_parameters not in ((), bridge.fixed_parameters): + raise RES70ValidationError( + "bridge request parameters do not match registered fixed parameters" + ) + target_identity = bridge_request.target_identity expected_source = ObservationAuthorityReference.from_observation(source_observation) if result.source_observation != expected_source: raise RES70ValidationError("bridge execution source reference does not match observation") + if SemanticIdentityKey.from_identity(source_observation.identity) != bridge.source_semantic_key: + raise RES70ValidationError("bridge source semantic key does not match registration") + if result.status is not BridgeExecutionStatus.EXECUTED: + return if result.transformed_observation is not None: if result.processing_run is None or result.provenance is None: raise RES70ValidationError("transformed bridge output must carry processing provenance") + transformed = result.transformed_observation + if SemanticIdentityKey.from_identity(transformed.identity) != bridge.target_semantic_key: + raise RES70ValidationError("bridge target semantic key does not match registration") + if target_identity is not None and transformed.identity != target_identity: + raise RES70ValidationError( + "bridge output identity does not match exact target identity" + ) + if bridge.transformation_operation is None: + raise RES70ValidationError("executed bridge has no registered transformation operation") + operation = operation_registry.resolve(bridge.transformation_operation) + if operation is None: + raise RES70ValidationError("bridge transformation operation is not registered") + if result.processing_run.method != bridge.transformation_operation: + raise RES70ValidationError("processing method does not match bridge registration") + if result.processing_run.parameters != bridge.fixed_parameters: + raise RES70ValidationError("processing parameters do not match bridge registration") + if result.processing_run.software_version != RES70_SOFTWARE_VERSION: + raise RES70ValidationError("bridge processing software version is not canonical") + if result.method_version != bridge.method_version: + raise RES70ValidationError("bridge method version is not bound to provenance") + if result.provenance_rule != bridge.provenance_rule: + raise RES70ValidationError("bridge provenance rule is not bound to execution") + expected_evidence = tuple(EvidenceReference(item) for item in bridge.evidence_references) + if not all(item in result.provenance.evidence_references for item in expected_evidence): + raise RES70ValidationError( + "bridge provenance is missing registered evidence references" + ) + if not all( + run == result.processing_run + for run in result.provenance.processing_runs + if run.processing_run_id == result.processing_run.processing_run_id + ): + raise RES70ValidationError("bridge provenance processing run is not exact") + if not isinstance(source_observation.result.value, ScalarValue): + raise RES70ValidationError("executed bridge source result must be scalar") + if not isinstance(transformed.result.value, ScalarValue): + raise RES70ValidationError("executed bridge output result must be scalar") + source_value = source_observation.result.value.value + output_value = transformed.result.value.value + if isinstance(source_value, bool) or not isinstance(source_value, int | float): + raise RES70ValidationError("executed bridge source result must be numeric") + if isinstance(output_value, bool) or not isinstance(output_value, int | float): + raise RES70ValidationError("executed bridge output result must be numeric") + expected_value = operation.execute(float(source_value), bridge.fixed_parameters) + if float(output_value) != expected_value: + raise RES70ValidationError( + "bridge output value does not match deterministic re-execution" + ) + if transformed.result.classification.value_origin is not ValueOrigin.DYNAMISLM_DERIVED: + raise RES70ValidationError("bridge output must be classified as derived") if result.processing_run.output_entity_id != result.transformed_observation.observation_id: raise RES70ValidationError( "bridge processing output does not match transformed observation" @@ -232,6 +374,104 @@ def validate_bridge_execution( raise RES70ValidationError("bridge output hash does not match transformed observation") +def _recompute_cross_source_decision( + decision: CrossSourceComparabilityDecision, + request: CrossSourceComparabilityRequest, + observations: Mapping[object, ScientificMeasurementObservation] + | Sequence[ScientificMeasurementObservation], + *, + bridge_request: BridgeApplicationRequest | None, + bridge_execution: BridgeExecutionResult | None, + football_contexts: Mapping[object, object] | Sequence[object] | None, + bridge_registry: BridgeRegistry, + rule_registry: ComparabilityRuleRegistry, +) -> None: + left, right = validate_cross_source_request(request, observations) + if bridge_execution is not None: + validate_bridge_execution( + bridge_execution, + ( + bridge_request.request_hash + if bridge_request is not None + else bridge_execution.request_hash + ), + left if bridge_execution.source_observation == request.left_observation else right, + bridge_registry=bridge_registry, + bridge_request=bridge_request, + ) + from dynamislm.comparability.res70_authority import assess_cross_source_comparability + + expected = assess_cross_source_comparability( + request, + observations, + bridge_registry=bridge_registry, + rule_registry=rule_registry, + bridge_execution=bridge_execution, + bridge_request=bridge_request, + football_contexts=football_contexts, + ) + if expected != decision: + raise RES70ValidationError( + "comparability decision does not recompute from exact observations, request, " + "and registries" + ) + + +def validate_cross_source_decision_set( + decisions: tuple[CrossSourceComparabilityDecision, ...], + requests: tuple[CrossSourceComparabilityRequest, ...], + observations: Mapping[object, ScientificMeasurementObservation] + | Sequence[ScientificMeasurementObservation], + *, + bridge_requests: tuple[BridgeApplicationRequest, ...] = (), + bridge_executions: tuple[BridgeExecutionResult, ...] = (), + football_contexts: Mapping[object, object] | Sequence[object] | None = None, + bridge_registry: BridgeRegistry | None = None, + rule_registry: ComparabilityRuleRegistry | None = None, +) -> None: + """Recompute every supplied decision from its exact canonical inputs.""" + + if bridge_registry is None: + from dynamislm.comparability.res70_registry import CANONICAL_BRIDGE_REGISTRY + + bridge_registry = CANONICAL_BRIDGE_REGISTRY + if rule_registry is None: + from dynamislm.comparability.res70_registry import RES70_COMPARABILITY_RULE_REGISTRY + + rule_registry = RES70_COMPARABILITY_RULE_REGISTRY + validate_pairwise_decisions(decisions) + request_by_pair = { + frozenset(item.qualified for item in request.observation_ids): request + for request in requests + } + if len(request_by_pair) != len(requests): + raise RES70ValidationError("comparability requests cannot contain duplicate pairs") + execution_by_hash = {item.canonical_execution_hash: item for item in bridge_executions} + bridge_request_by_hash = {item.request_hash: item for item in bridge_requests} + for decision in decisions: + request = request_by_pair.get(decision.pair_key) + if request is None: + raise RES70ValidationError("comparability decision has no exact originating request") + execution = ( + execution_by_hash.get(decision.bridge_execution_hash) + if decision.bridge_execution_hash is not None + else None + ) + bridge_request = ( + bridge_request_by_hash.get(execution.request_hash) if execution is not None else None + ) + validate_cross_source_decision( + decision, + request, + bridge_registry=bridge_registry, + rule_registry=rule_registry, + observations=observations, + bridge_request=bridge_request, + bridge_execution=execution, + football_contexts=football_contexts, + ) + + def validate_pairwise_decisions( decisions: tuple[CrossSourceComparabilityDecision, ...], ) -> None: @@ -251,6 +491,7 @@ def validate_pairwise_decisions( "validate_bridge_registration", "validate_bridge_registry", "validate_cross_source_decision", + "validate_cross_source_decision_set", "validate_cross_source_request", "validate_pairwise_decisions", ] From 0965ec868e853d1dbd62f207968b0c052a3e273d Mon Sep 17 00:00:00 2001 From: Julio Rodriguez <144072916+Litju@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:58:36 +0000 Subject: [PATCH 11/18] fix(res70): source-bind applicability authority --- src/dynamislm/__init__.py | 4 + src/dynamislm/analysis/validation.py | 14 ++- src/dynamislm/evidence/__init__.py | 4 + src/dynamislm/evidence/res70.py | 163 ++++++++++++++++++++++++++- 4 files changed, 183 insertions(+), 2 deletions(-) diff --git a/src/dynamislm/__init__.py b/src/dynamislm/__init__.py index 00d1c9b..1b66316 100644 --- a/src/dynamislm/__init__.py +++ b/src/dynamislm/__init__.py @@ -103,6 +103,7 @@ ) from dynamislm.evidence import ( ApplicabilityAssessment, + ApplicabilityAuthorityProvenance, ApplicabilityAxis, ApplicabilityDecision, ClaimEvidenceApplicability, @@ -110,6 +111,7 @@ EvidenceDecisionRecord, build_claim_evidence_applicability, validate_claim_evidence_applicability, + validate_claim_evidence_authority, ) from dynamislm.external_load import ( EXTERNAL_LOAD_ACCELERATION_EVENT_COUNT_METRIC, @@ -542,6 +544,7 @@ "AnalysisUnitOfAnalysis", "AnalysisValidationError", "ApplicabilityAssessment", + "ApplicabilityAuthorityProvenance", "ApplicabilityAxis", "BUILD_LONGITUDINAL_SOURCE_MANIFEST", "CANONICAL_ANALYSIS_CAPABILITY_REGISTRY", @@ -857,6 +860,7 @@ "validate_observation_hashes", "validate_support_shape", "validate_claim_evidence_applicability", + "validate_claim_evidence_authority", "validate_canonical_replay", "validate_complete_multi_source_provenance_graph", "validate_football_context_for_observation", diff --git a/src/dynamislm/analysis/validation.py b/src/dynamislm/analysis/validation.py index 7ddca5a..bca5cae 100644 --- a/src/dynamislm/analysis/validation.py +++ b/src/dynamislm/analysis/validation.py @@ -17,7 +17,10 @@ from dynamislm.comparability.models import ComparabilityState from dynamislm.comparability.res70_models import SemanticIdentityKey from dynamislm.comparability.res70_validation import validate_pairwise_decisions -from dynamislm.evidence.res70 import ApplicabilityAxis +from dynamislm.evidence.res70 import ( + ApplicabilityAxis, + validate_claim_evidence_authority, +) from dynamislm.longitudinal.statistics.models import StatisticalSupport from dynamislm.longitudinal.statistics.support import ( StatisticalConstraintError, @@ -314,6 +317,15 @@ def validate_evidence_applicability( request: AnalysisAuthorizationRequest, capability: AnalysisCapability, ) -> None: + if request.evidence_applicability is not None: + try: + validate_claim_evidence_authority(request.evidence_applicability) + except ValueError as exc: + raise AnalysisValidationError( + str(exc), + "RES70_INSUFFICIENT_EVIDENCE_APPLICABILITY", + ("canonical applicability authority provenance",), + ) from exc if not capability.required_evidence_axes: return if request.evidence_applicability is None: diff --git a/src/dynamislm/evidence/__init__.py b/src/dynamislm/evidence/__init__.py index 9cbf2eb..2a0c64c 100644 --- a/src/dynamislm/evidence/__init__.py +++ b/src/dynamislm/evidence/__init__.py @@ -7,14 +7,17 @@ ) from dynamislm.evidence.res70 import ( ApplicabilityAssessment, + ApplicabilityAuthorityProvenance, ApplicabilityAxis, ClaimEvidenceApplicability, build_claim_evidence_applicability, validate_claim_evidence_applicability, + validate_claim_evidence_authority, ) __all__ = [ "ApplicabilityAssessment", + "ApplicabilityAuthorityProvenance", "ApplicabilityAxis", "ApplicabilityDecision", "ClaimEvidenceApplicability", @@ -22,4 +25,5 @@ "EvidenceDecisionRecord", "build_claim_evidence_applicability", "validate_claim_evidence_applicability", + "validate_claim_evidence_authority", ] diff --git a/src/dynamislm/evidence/res70.py b/src/dynamislm/evidence/res70.py index d06ec91..aefe3c4 100644 --- a/src/dynamislm/evidence/res70.py +++ b/src/dynamislm/evidence/res70.py @@ -6,6 +6,10 @@ from enum import StrEnum from dynamislm.evidence.models import ApplicabilityDecision +from dynamislm.football.models import FootballWorldContext +from dynamislm.football.validation import validate_football_world_context +from dynamislm.longitudinal.statistics.models import StatisticalSupport +from dynamislm.longitudinal.statistics.support import validate_statistical_support from dynamislm.measurement.identity import ( RegistryReference, _require_enum, @@ -14,6 +18,18 @@ _require_tuple_items, require_tuple, ) +from dynamislm.population.models import ( + CanonicalPopulationDecision, + CanonicalPopulationStatus, + CanonicalSourceDecision, + CanonicalSourceStatus, + EvidenceClass, + V2EvidenceApplicability, +) +from dynamislm.population.qualification import ( + qualify_canonical_population, + qualify_canonical_source, +) from dynamislm.serialization import canonical_hash, register_serializable_type @@ -32,6 +48,43 @@ class ApplicabilityAxis(StrEnum): STATISTICAL_ADEQUACY = "STATISTICAL_ADEQUACY" +@register_serializable_type +@dataclass(frozen=True, slots=True) +class ApplicabilityAuthorityProvenance: + """Typed source authority that can support one applicability judgment.""" + + source_decisions: tuple[CanonicalSourceDecision, ...] = () + population_decisions: tuple[CanonicalPopulationDecision, ...] = () + evidence_applicabilities: tuple[V2EvidenceApplicability, ...] = () + statistical_support: StatisticalSupport | None = None + statistical_authority_hashes: tuple[str, ...] = () + football_contexts: tuple[FootballWorldContext, ...] = () + + def __post_init__(self) -> None: + _require_tuple_items(self.source_decisions, CanonicalSourceDecision, "source_decisions") + _require_tuple_items( + self.population_decisions, + CanonicalPopulationDecision, + "population_decisions", + ) + _require_tuple_items( + self.evidence_applicabilities, + V2EvidenceApplicability, + "evidence_applicabilities", + ) + if self.statistical_support is not None and not isinstance( + self.statistical_support, + StatisticalSupport, + ): + raise ValueError("statistical_support must be a StatisticalSupport") + _require_string_tuple(self.statistical_authority_hashes, "statistical_authority_hashes") + _require_tuple_items(self.football_contexts, FootballWorldContext, "football_contexts") + + @property + def canonical_provenance_hash(self) -> str: + return canonical_hash(self) + + @register_serializable_type @dataclass(frozen=True, slots=True) class ApplicabilityAssessment: @@ -44,6 +97,7 @@ class ApplicabilityAssessment: authority_references: tuple[RegistryReference, ...] conditions: tuple[str, ...] rationale: str + authority_provenance: ApplicabilityAuthorityProvenance | None = None def __post_init__(self) -> None: _require_enum(self.axis, ApplicabilityAxis, "axis") @@ -58,6 +112,11 @@ def __post_init__(self) -> None: ) _require_string_tuple(self.conditions, "conditions") _require_text(self.rationale, "rationale") + if self.authority_provenance is not None and not isinstance( + self.authority_provenance, + ApplicabilityAuthorityProvenance, + ): + raise ValueError("authority_provenance must be an ApplicabilityAuthorityProvenance") if self.required_for_claim and self.decision is ApplicabilityDecision.UNASSESSED: raise ValueError("a required applicability axis cannot be unassessed") @@ -135,7 +194,7 @@ def build_claim_evidence_applicability( def validate_claim_evidence_applicability(value: ClaimEvidenceApplicability) -> None: - """Recompute the applicability hash and reject tampering.""" + """Recompute the applicability hash and reject structural tampering.""" if not isinstance(value, ClaimEvidenceApplicability): raise ValueError("value must be a ClaimEvidenceApplicability") @@ -150,10 +209,112 @@ def validate_claim_evidence_applicability(value: ClaimEvidenceApplicability) -> raise ValueError("applicability hash does not match immutable content") +def _validate_source_authority( + decisions: tuple[CanonicalSourceDecision, ...], +) -> None: + if not decisions: + raise ValueError("affirmative applicability requires canonical source authority") + for decision in decisions: + if qualify_canonical_source(decision.source) != decision: + raise ValueError("source applicability authority does not recompute canonically") + if decision.status is not CanonicalSourceStatus.CANONICAL_EMPIRICAL_TARGET: + raise ValueError("source applicability authority is not an affirmative source decision") + + +def _validate_population_authority( + decisions: tuple[CanonicalPopulationDecision, ...], +) -> None: + if not decisions: + raise ValueError("affirmative applicability requires canonical population authority") + for decision in decisions: + if qualify_canonical_population(decision.population) != decision: + raise ValueError("population applicability authority does not recompute canonically") + if decision.status is not CanonicalPopulationStatus.PASS: + raise ValueError( + "population applicability authority is not an affirmative population decision" + ) + + +def _validate_evidence_authority( + provenance: ApplicabilityAuthorityProvenance, +) -> None: + if not provenance.evidence_applicabilities: + raise ValueError("affirmative method applicability requires canonical evidence authority") + if not provenance.source_decisions: + raise ValueError("evidence applicability must be bound to canonical source decisions") + source_ids = {item.source.source_id.identifier for item in provenance.source_decisions} + for evidence in provenance.evidence_applicabilities: + if evidence.source_id not in source_ids: + raise ValueError("evidence applicability source is not canonically qualified") + if evidence.decision not in ( + ApplicabilityDecision.SUPPORTED, + ApplicabilityDecision.LIMITED, + ): + raise ValueError("evidence applicability is not affirmative") + if evidence.evidence_class is EvidenceClass.REJECTED_OR_UNRESOLVED: + raise ValueError("rejected evidence cannot support affirmative applicability") + + +def _validate_context_authority( + provenance: ApplicabilityAuthorityProvenance, +) -> None: + if not provenance.football_contexts: + raise ValueError("contextual applicability requires typed football-world contexts") + for context in provenance.football_contexts: + validate_football_world_context(context) + + +def _validate_statistical_authority( + provenance: ApplicabilityAuthorityProvenance, +) -> None: + if provenance.statistical_support is None: + raise ValueError("statistical applicability requires exact statistical support") + validate_statistical_support(provenance.statistical_support) + if provenance.statistical_support.canonical_support_hash not in set( + provenance.statistical_authority_hashes + ): + raise ValueError("statistical applicability authority must bind the exact support hash") + + +def _validate_assessment_authority(assessment: ApplicabilityAssessment) -> None: + if assessment.decision not in ( + ApplicabilityDecision.SUPPORTED, + ApplicabilityDecision.LIMITED, + ): + return + provenance = assessment.authority_provenance + if provenance is None: + raise ValueError( + "caller-supplied affirmative applicability has no verifiable authority provenance" + ) + if not assessment.authority_references: + raise ValueError("affirmative applicability requires authority references") + if assessment.axis is ApplicabilityAxis.METHOD_VALIDITY: + _validate_evidence_authority(provenance) + elif assessment.axis is ApplicabilityAxis.SOURCE_QUALITY: + _validate_source_authority(provenance.source_decisions) + elif assessment.axis is ApplicabilityAxis.POPULATION_RELEVANCE: + _validate_population_authority(provenance.population_decisions) + elif assessment.axis is ApplicabilityAxis.CONTEXTUAL_RELEVANCE: + _validate_context_authority(provenance) + elif assessment.axis is ApplicabilityAxis.STATISTICAL_ADEQUACY: + _validate_statistical_authority(provenance) + + +def validate_claim_evidence_authority(value: ClaimEvidenceApplicability) -> None: + """Require canonical provenance for every affirmative applicability axis.""" + + validate_claim_evidence_applicability(value) + for assessment in value.assessments: + _validate_assessment_authority(assessment) + + __all__ = [ "ApplicabilityAssessment", + "ApplicabilityAuthorityProvenance", "ApplicabilityAxis", "ClaimEvidenceApplicability", "build_claim_evidence_applicability", "validate_claim_evidence_applicability", + "validate_claim_evidence_authority", ] From 781899161174747f1bc276be7502306e0d9351ca Mon Sep 17 00:00:00 2001 From: Julio Rodriguez <144072916+Litju@users.noreply.github.com> Date: Fri, 18 Sep 2026 23:02:20 +0000 Subject: [PATCH 12/18] fix(res70): revalidate upstream claim authority --- src/dynamislm/analysis/authority.py | 8 + src/dynamislm/analysis/models.py | 28 +++- src/dynamislm/analysis/validation.py | 32 +++- src/dynamislm/claims/authority.py | 213 ++++++++++++++++++++++++++- src/dynamislm/claims/models.py | 38 ++++- src/dynamislm/claims/validation.py | 4 +- 6 files changed, 311 insertions(+), 12 deletions(-) diff --git a/src/dynamislm/analysis/authority.py b/src/dynamislm/analysis/authority.py index 0f0c972..343f297 100644 --- a/src/dynamislm/analysis/authority.py +++ b/src/dynamislm/analysis/authority.py @@ -226,6 +226,7 @@ def authorize_analysis( safe_descriptions=( "the registered analysis capability is authorized at the requested level of analysis", ), + request_hash=request.request_hash, ) @@ -245,6 +246,10 @@ def validate_analysis_authorization( raise ValueError("analysis capability is absent from canonical registry") if authorization.request_id != request.request_id: raise ValueError("authorization request ID does not match request") + if authorization.request_hash is None: + raise ValueError("authorization is missing the exact originating request hash") + if authorization.request_hash != request.request_hash: + raise ValueError("authorization request hash does not match request") if authorization.capability_hash != capability.canonical_capability_hash: raise ValueError("authorization capability hash does not match registry") expected_support_hashes = ( @@ -252,6 +257,9 @@ def validate_analysis_authorization( ) if authorization.support_hashes != expected_support_hashes: raise ValueError("authorization support hash does not match request") + expected = authorize_analysis(request, registry=registry) + if not isinstance(expected, AnalysisAuthorization) or expected != authorization: + raise ValueError("analysis authorization does not recompute from canonical authority") __all__ = [ diff --git a/src/dynamislm/analysis/models.py b/src/dynamislm/analysis/models.py index 0b0959f..efe226b 100644 --- a/src/dynamislm/analysis/models.py +++ b/src/dynamislm/analysis/models.py @@ -6,7 +6,12 @@ from enum import StrEnum from dynamislm.comparability.models import ComparabilityState -from dynamislm.comparability.res70_models import CrossSourceComparabilityDecision +from dynamislm.comparability.res70_models import ( + BridgeApplicationRequest, + BridgeExecutionResult, + CrossSourceComparabilityDecision, + CrossSourceComparabilityRequest, +) from dynamislm.evidence.res70 import ClaimEvidenceApplicability from dynamislm.longitudinal.statistics.models import ( MeasurementScaleSemantics, @@ -257,6 +262,9 @@ class AnalysisAuthorizationRequest: requested_level: AnalysisLevelIdentity evidence_applicability: ClaimEvidenceApplicability | None context_references: tuple[RegistryReference, ...] + comparability_requests: tuple[CrossSourceComparabilityRequest, ...] = () + bridge_requests: tuple[BridgeApplicationRequest, ...] = () + bridge_executions: tuple[BridgeExecutionResult, ...] = () requested_parameters: tuple[MetadataEntry, ...] = () reliability_authority: ReliabilityDesignAuthority | None = None reliability_assessment: ReliabilityAssumptionAssessment | None = None @@ -289,6 +297,17 @@ def __post_init__(self) -> None: "evidence_applicability", ) _require_tuple_items(self.context_references, RegistryReference, "context_references") + _require_tuple_items( + self.comparability_requests, + CrossSourceComparabilityRequest, + "comparability_requests", + ) + _require_tuple_items(self.bridge_requests, BridgeApplicationRequest, "bridge_requests") + _require_tuple_items( + self.bridge_executions, + BridgeExecutionResult, + "bridge_executions", + ) _require_tuple_items(self.requested_parameters, MetadataEntry, "requested_parameters") _require_optional_instance( self.reliability_authority, @@ -345,6 +364,7 @@ class AnalysisAuthorization: reason_codes: tuple[str, ...] missing_information: tuple[str, ...] safe_descriptions: tuple[str, ...] + request_hash: str | None = None refusal_result: RefusalResult | None = None authorization_hash: str | None = None @@ -380,6 +400,8 @@ def __post_init__(self) -> None: _require_string_tuple(self.reason_codes, "reason_codes") _require_string_tuple(self.missing_information, "missing_information") _require_string_tuple(self.safe_descriptions, "safe_descriptions") + if self.request_hash is not None: + _require_hash(self.request_hash, "request_hash") _require_optional_instance(self.refusal_result, RefusalResult, "refusal_result") if ( self.status is AnalysisAuthorizationStatus.AUTHORIZED @@ -407,6 +429,7 @@ def __post_init__(self) -> None: "reason_codes": self.reason_codes, "missing_information": self.missing_information, "safe_descriptions": self.safe_descriptions, + "request_hash": self.request_hash, "refusal_result": self.refusal_result, } ) @@ -441,6 +464,7 @@ def create( reason_codes: tuple[str, ...], missing_information: tuple[str, ...], safe_descriptions: tuple[str, ...], + request_hash: str | None = None, refusal_result: RefusalResult | None = None, ) -> AnalysisAuthorization: content = { @@ -461,6 +485,7 @@ def create( "reason_codes": reason_codes, "missing_information": missing_information, "safe_descriptions": safe_descriptions, + "request_hash": request_hash, "refusal_result": refusal_result, } authorization_hash = canonical_hash(content) @@ -485,6 +510,7 @@ def create( reason_codes=reason_codes, missing_information=missing_information, safe_descriptions=safe_descriptions, + request_hash=request_hash, refusal_result=refusal_result, authorization_hash=authorization_hash, ) diff --git a/src/dynamislm/analysis/validation.py b/src/dynamislm/analysis/validation.py index bca5cae..c9199e0 100644 --- a/src/dynamislm/analysis/validation.py +++ b/src/dynamislm/analysis/validation.py @@ -16,7 +16,11 @@ ) from dynamislm.comparability.models import ComparabilityState from dynamislm.comparability.res70_models import SemanticIdentityKey -from dynamislm.comparability.res70_validation import validate_pairwise_decisions +from dynamislm.comparability.res70_validation import ( + RES70ValidationError, + validate_cross_source_decision_set, + validate_pairwise_decisions, +) from dynamislm.evidence.res70 import ( ApplicabilityAxis, validate_claim_evidence_authority, @@ -268,6 +272,25 @@ def validate_comparability_authority( decisions = request.comparability_decisions if decisions: validate_pairwise_decisions(decisions) + try: + validate_cross_source_decision_set( + decisions, + request.comparability_requests, + tuple(entry.observation for entry in support.included_entries), + bridge_requests=request.bridge_requests, + bridge_executions=request.bridge_executions, + ) + except RES70ValidationError as exc: + raise AnalysisValidationError( + str(exc), + "RES70_COMPARABILITY_AUTHORITY_MISSING", + ("exact originating comparability request and canonical decision",), + ) from exc + elif request.comparability_requests or request.bridge_executions: + raise AnalysisValidationError( + "comparability requests or executions were supplied without decisions", + "RES70_REGISTRY_INTEGRITY_FAILURE", + ) entries = support.entries required_pairs = tuple( (left, right) @@ -296,16 +319,17 @@ def validate_comparability_authority( "RES70_BRIDGE_NOT_EXECUTED", ("executed registered bridge output",), ) - if decision.state not in ( + allowed_states = capability.required_comparability_states or ( ComparabilityState.COMPARABLE, ComparabilityState.COMPARABLE_WITH_CONDITIONS, - ): + ) + if decision.state not in allowed_states: raise AnalysisValidationError( "cross-source comparability is not affirmative", "RES70_COMPARABILITY_AUTHORITY_MISSING", ) hashes.append(decision.canonical_decision_hash) - if capability.required_bridge_execution and decision.bridge_application_reference is None: + if capability.required_bridge_execution and decision.bridge_execution_hash is None: raise AnalysisValidationError( "registered capability requires executed bridge authority", "RES70_BRIDGE_NOT_EXECUTED", diff --git a/src/dynamislm/claims/authority.py b/src/dynamislm/claims/authority.py index 5bba958..149a75a 100644 --- a/src/dynamislm/claims/authority.py +++ b/src/dynamislm/claims/authority.py @@ -4,7 +4,9 @@ from typing import cast +from dynamislm.analysis.authority import validate_analysis_authorization from dynamislm.analysis.models import AnalysisAuthorizationStatus +from dynamislm.analysis.registry import CANONICAL_ANALYSIS_CAPABILITY_REGISTRY from dynamislm.claims.models import ( ClaimAuthorityResult, ClaimAuthorityStatus, @@ -21,10 +23,16 @@ ClaimPolicyRegistry, is_canonical_claim_registry, ) -from dynamislm.comparability.res70_validation import build_res70_refusal +from dynamislm.comparability.res70_validation import ( + RES70ValidationError, + build_res70_refusal, + validate_cross_source_decision_set, +) from dynamislm.evidence.models import ApplicabilityDecision -from dynamislm.evidence.res70 import validate_claim_evidence_applicability +from dynamislm.evidence.res70 import validate_claim_evidence_authority +from dynamislm.longitudinal.statistics.validation import validate_statistical_result from dynamislm.measurement.identity import InstanceIdentifier +from dynamislm.measurement.observation import ScientificMeasurementObservation from dynamislm.measurement.result import ResultStatus from dynamislm.refusal.models import RefusalClass, RefusalResult from dynamislm.serialization import canonical_hash @@ -86,6 +94,116 @@ def _safe_observation(intent: ClaimIntent) -> bool: return intent.statistical_result is not None and intent.statistical_result.support is not None +class ClaimUpstreamValidationError(ValueError): + """Raised when a typed upstream authority cannot be canonically revalidated.""" + + def __init__(self, message: str, code: str, missing_information: tuple[str, ...] = ()) -> None: + super().__init__(message) + self.code = code + self.missing_information = missing_information + + +def _exact_upstream_observations( + intent: ClaimIntent, +) -> tuple[ScientificMeasurementObservation, ...]: + if intent.analysis_authorization_request is not None: + support = intent.analysis_authorization_request.support + if support is not None: + return tuple(entry.observation for entry in support.included_entries) + if intent.statistical_result is not None and intent.statistical_result.support is not None: + return tuple( + entry.observation for entry in intent.statistical_result.support.included_entries + ) + return intent.observations + + +def _validate_upstream_authority(intent: ClaimIntent) -> None: + authorization = intent.analysis_authorization + authorization_request = intent.analysis_authorization_request + if authorization is not None: + if authorization_request is None: + raise ClaimUpstreamValidationError( + "authorized analysis is missing its exact originating request", + "RES70_REGISTRY_INTEGRITY_FAILURE", + ("AnalysisAuthorizationRequest",), + ) + try: + validate_analysis_authorization(authorization, authorization_request) + except (TypeError, ValueError) as exc: + raise ClaimUpstreamValidationError( + str(exc), + "RES70_REGISTRY_INTEGRITY_FAILURE", + ("canonical analysis authorization recomputation",), + ) from exc + elif authorization_request is not None: + raise ClaimUpstreamValidationError( + "analysis authorization request was supplied without its authority result", + "RES70_REGISTRY_INTEGRITY_FAILURE", + ) + + if intent.comparability_decisions: + requests = intent.comparability_requests + if not requests and authorization_request is not None: + requests = authorization_request.comparability_requests + if not requests: + raise ClaimUpstreamValidationError( + "comparability decisions are missing their exact originating requests", + "RES70_COMPARABILITY_AUTHORITY_MISSING", + ("CrossSourceComparabilityRequest",), + ) + bridge_requests = intent.bridge_requests + bridge_executions = intent.bridge_executions + if authorization_request is not None: + bridge_requests = bridge_requests or authorization_request.bridge_requests + bridge_executions = bridge_executions or authorization_request.bridge_executions + try: + validate_cross_source_decision_set( + intent.comparability_decisions, + requests, + _exact_upstream_observations(intent), + bridge_requests=bridge_requests, + bridge_executions=bridge_executions, + ) + except (RES70ValidationError, ValueError) as exc: + raise ClaimUpstreamValidationError( + str(exc), + "RES70_COMPARABILITY_AUTHORITY_MISSING", + ("canonical pairwise comparability recomputation",), + ) from exc + + if intent.evidence_applicability is not None: + try: + validate_claim_evidence_authority(intent.evidence_applicability) + except ValueError as exc: + raise ClaimUpstreamValidationError( + str(exc), + "RES70_INSUFFICIENT_EVIDENCE_APPLICABILITY", + ("canonical evidence applicability provenance",), + ) from exc + + if intent.statistical_result is not None: + try: + validate_statistical_result(intent.statistical_result) + except (TypeError, ValueError) as exc: + raise ClaimUpstreamValidationError( + str(exc), + "RES70_STATISTICAL_AUTHORITY_INSUFFICIENT", + ("canonical StatisticalResult validation",), + ) from exc + if authorization_request is not None and authorization_request.support is not None: + result_support = intent.statistical_result.support + if ( + result_support is None + or result_support.canonical_support_hash + != authorization_request.support.canonical_support_hash + ): + raise ClaimUpstreamValidationError( + "statistical result support does not match exact analysis support", + "RES70_STATISTICAL_AUTHORITY_INSUFFICIENT", + ("exact statistical support",), + ) + + def _analysis_matches( intent: ClaimIntent, level: object, @@ -112,6 +230,40 @@ def _analysis_matches( "analysis reference bound to the claim intent", ("RES70_REGISTRY_INTEGRITY_FAILURE",), ) + capability = CANONICAL_ANALYSIS_CAPABILITY_REGISTRY.resolve_reference(auth.capability_reference) + if capability is None: + return ( + False, + "canonical analysis capability", + ("RES70_REGISTRY_INTEGRITY_FAILURE",), + ) + if capability.output_claim_floor is not None: + if isinstance(level, MeasurementClaimLevel): + try: + measurement_floor = MeasurementClaimLevel(capability.output_claim_floor) + except ValueError: + measurement_floor = None + if measurement_floor is None or _MEASUREMENT_LEVELS.index( + level + ) > _MEASUREMENT_LEVELS.index(measurement_floor): + return ( + False, + "analysis capability output claim floor", + ("RES70_UNSUPPORTED_CLAIM_ESCALATION",), + ) + else: + try: + relationship_floor = RelationshipClaimLevel(capability.output_claim_floor) + except ValueError: + relationship_floor = None + if relationship_floor is None or _RELATIONSHIP_LEVELS.index( + level + ) > _RELATIONSHIP_LEVELS.index(relationship_floor): + return ( + False, + "analysis capability output claim floor", + ("RES70_UNSUPPORTED_CLAIM_ESCALATION",), + ) if isinstance(level, MeasurementClaimLevel): allowed = { MeasurementClaimLevel.NUMERICAL_CHANGE: { @@ -208,7 +360,7 @@ def _evidence_supports( ("RES70_APPLICABILITY_AXIS_UNASSESSED",), ) try: - validate_claim_evidence_applicability(bundle) + validate_claim_evidence_authority(bundle) except ValueError: return ( False, @@ -449,6 +601,61 @@ def authorize_claim( refusal_result=registry_refusal, ) + try: + _validate_upstream_authority(intent) + except ClaimUpstreamValidationError as exc: + requested_measurement = ( + (intent.measurement_level,) if intent.measurement_level is not None else () + ) + requested_relationship = ( + (intent.relationship_level,) if intent.relationship_level is not None else () + ) + blocked = tuple(item.value for item in (*requested_measurement, *requested_relationship)) + if intent.predictive_intent is not None: + blocked = (*blocked, intent.predictive_intent.stable_id) + upstream_refusal = build_res70_refusal( + "claim authority upstream validation", + exc.code, + refusal_class=RefusalClass.COMPUTATION_NOT_REGISTERED + if exc.code == "RES70_REGISTRY_INTEGRITY_FAILURE" + else RefusalClass.EVIDENCE_SCOPE_UNSUPPORTED, + missing_information=exc.missing_information, + observation_ids=_observation_ids(intent), + ) + return ClaimAuthorityResult.create( + status=ClaimAuthorityStatus.REFUSED, + claim_intent_reference=intent.claim_reference, + claim_intent_hash=intent.intent_hash, + allowed_measurement_levels=(), + allowed_relationship_levels=(), + prediction_status=( + PredictionStatus.REFUSED + if intent.predictive_intent is not None + else PredictionStatus.NOT_REQUESTED + ), + blocked_claims=blocked, + first_blocking_prerequisite=exc.missing_information[0] + if exc.missing_information + else str(exc), + reason_codes=(exc.code,), + missing_information=exc.missing_information, + safe_descriptions=( + "exact observations remain independently describable under their recorded identity", + ), + support_hashes=_support_hashes(intent), + analysis_hashes=_analysis_hashes(intent), + comparability_hashes=_comparability_hashes(intent), + bridge_hashes=_bridge_hashes(intent), + evidence_applicability_hash=( + intent.evidence_applicability.canonical_applicability_hash + if intent.evidence_applicability is not None + else None + ), + registry_version=registry.registry_version, + software_version="dynamislm-res70-1.0.0", + refusal_result=upstream_refusal, + ) + ( allowed_measurement, blocked_measurement, diff --git a/src/dynamislm/claims/models.py b/src/dynamislm/claims/models.py index ab44c09..a4e25c7 100644 --- a/src/dynamislm/claims/models.py +++ b/src/dynamislm/claims/models.py @@ -5,8 +5,13 @@ from dataclasses import dataclass from enum import StrEnum -from dynamislm.analysis.models import AnalysisAuthorization -from dynamislm.comparability.res70_models import CrossSourceComparabilityDecision +from dynamislm.analysis.models import AnalysisAuthorization, AnalysisAuthorizationRequest +from dynamislm.comparability.res70_models import ( + BridgeApplicationRequest, + BridgeExecutionResult, + CrossSourceComparabilityDecision, + CrossSourceComparabilityRequest, +) from dynamislm.evidence.res70 import ClaimEvidenceApplicability from dynamislm.longitudinal.statistics.models import StatisticalResult from dynamislm.measurement.identity import ( @@ -99,6 +104,10 @@ class ClaimIntent: comparability_decisions: tuple[CrossSourceComparabilityDecision, ...] = () statistical_result: StatisticalResult | None = None evidence_applicability: ClaimEvidenceApplicability | None = None + analysis_authorization_request: AnalysisAuthorizationRequest | None = None + comparability_requests: tuple[CrossSourceComparabilityRequest, ...] = () + bridge_requests: tuple[BridgeApplicationRequest, ...] = () + bridge_executions: tuple[BridgeExecutionResult, ...] = () def __post_init__(self) -> None: _require_instance(self.claim_reference, RegistryReference, "claim_reference") @@ -148,6 +157,22 @@ def __post_init__(self) -> None: ClaimEvidenceApplicability, "evidence_applicability", ) + _require_optional_instance( + self.analysis_authorization_request, + AnalysisAuthorizationRequest, + "analysis_authorization_request", + ) + _require_tuple_items( + self.comparability_requests, + CrossSourceComparabilityRequest, + "comparability_requests", + ) + _require_tuple_items(self.bridge_requests, BridgeApplicationRequest, "bridge_requests") + _require_tuple_items( + self.bridge_executions, + BridgeExecutionResult, + "bridge_executions", + ) if self.evidence_applicability is not None: if ( self.evidence_applicability_reference is not None @@ -158,6 +183,15 @@ def __post_init__(self) -> None: if self.analysis_authorization is not None and self.analysis_reference is not None: if self.analysis_authorization.capability_reference != self.analysis_reference: raise ValueError("analysis reference does not match its authorization") + if ( + self.analysis_authorization is not None + and self.analysis_authorization_request is not None + ): + if ( + self.analysis_authorization.request_id + != self.analysis_authorization_request.request_id + ): + raise ValueError("analysis request does not match its authorization") @property def intent_hash(self) -> str: diff --git a/src/dynamislm/claims/validation.py b/src/dynamislm/claims/validation.py index cc2926e..74b7ae0 100644 --- a/src/dynamislm/claims/validation.py +++ b/src/dynamislm/claims/validation.py @@ -9,7 +9,7 @@ ClaimPolicyRegistry, is_canonical_claim_registry, ) -from dynamislm.evidence.res70 import validate_claim_evidence_applicability +from dynamislm.evidence.res70 import validate_claim_evidence_authority from dynamislm.serialization import canonical_hash @@ -30,7 +30,7 @@ def validate_claim_authority( if result.claim_intent_hash != intent.intent_hash: raise ValueError("claim result intent hash does not match claim intent") if intent.evidence_applicability is not None: - validate_claim_evidence_applicability(intent.evidence_applicability) + validate_claim_evidence_authority(intent.evidence_applicability) if result.evidence_applicability_hash != ( intent.evidence_applicability.canonical_applicability_hash ): From 23a02a1ec161653f7a54c77f8df74e9490b75d5e Mon Sep 17 00:00:00 2001 From: Julio Rodriguez <144072916+Litju@users.noreply.github.com> Date: Fri, 18 Sep 2026 23:07:48 +0000 Subject: [PATCH 13/18] fix(res70): enforce claim hierarchy and capability prerequisites --- src/dynamislm/__init__.py | 4 + src/dynamislm/analysis/__init__.py | 4 + src/dynamislm/analysis/authority.py | 13 +- src/dynamislm/analysis/validation.py | 255 +++++++++++++++++++++++++++ src/dynamislm/claims/authority.py | 49 +++-- 5 files changed, 310 insertions(+), 15 deletions(-) diff --git a/src/dynamislm/__init__.py b/src/dynamislm/__init__.py index 1b66316..991e35b 100644 --- a/src/dynamislm/__init__.py +++ b/src/dynamislm/__init__.py @@ -23,8 +23,10 @@ validate_analysis_authorization, validate_analysis_capability_registry, validate_comparability_authority, + validate_context_prerequisites, validate_evidence_applicability, validate_exact_support, + validate_identity_dimensions, validate_level_of_analysis, validate_observation_hashes, validate_support_shape, @@ -854,8 +856,10 @@ "validate_analysis_authorization", "validate_analysis_capability_registry", "validate_comparability_authority", + "validate_context_prerequisites", "validate_evidence_applicability", "validate_exact_support", + "validate_identity_dimensions", "validate_level_of_analysis", "validate_observation_hashes", "validate_support_shape", diff --git a/src/dynamislm/analysis/__init__.py b/src/dynamislm/analysis/__init__.py index eb0e9bf..58a3252 100644 --- a/src/dynamislm/analysis/__init__.py +++ b/src/dynamislm/analysis/__init__.py @@ -26,8 +26,10 @@ AnalysisValidationError, validate_analysis_capability_registry, validate_comparability_authority, + validate_context_prerequisites, validate_evidence_applicability, validate_exact_support, + validate_identity_dimensions, validate_level_of_analysis, validate_observation_hashes, validate_support_shape, @@ -56,8 +58,10 @@ "validate_analysis_authorization", "validate_analysis_capability_registry", "validate_comparability_authority", + "validate_context_prerequisites", "validate_evidence_applicability", "validate_exact_support", + "validate_identity_dimensions", "validate_level_of_analysis", "validate_observation_hashes", "validate_support_shape", diff --git a/src/dynamislm/analysis/authority.py b/src/dynamislm/analysis/authority.py index 343f297..63407d1 100644 --- a/src/dynamislm/analysis/authority.py +++ b/src/dynamislm/analysis/authority.py @@ -16,8 +16,10 @@ AnalysisValidationError, validate_analysis_capability_registry, validate_comparability_authority, + validate_context_prerequisites, validate_evidence_applicability, validate_exact_support, + validate_identity_dimensions, validate_level_of_analysis, validate_observation_hashes, validate_support_shape, @@ -26,6 +28,8 @@ from dynamislm.longitudinal.statistics.models import StatisticalOperationDisposition from dynamislm.longitudinal.statistics.registry import ( RES69_OPERATION_REGISTRY, + RES69_RELIABILITY_ASSUMPTION_ASSESSMENT_OPERATION, + RES69_RELIABILITY_DESIGN_OPERATION, RES69_SCALE_REGISTRY, RES69_SCALE_SEMANTICS_AUTHORITY, ) @@ -124,7 +128,12 @@ def _statistical_authority_check( if request.scale_semantics is not None: hashes.append(canonical_hash(request.scale_semantics)) required = {item.stable_id for item in capability.required_statistical_authority} - if any("reliability-design" in item for item in required): + if required.intersection( + { + RES69_RELIABILITY_DESIGN_OPERATION.stable_id, + RES69_RELIABILITY_ASSUMPTION_ASSESSMENT_OPERATION.stable_id, + } + ): if request.reliability_authority is None: raise AnalysisValidationError( "source-bound reliability design authority is required", @@ -186,6 +195,8 @@ def authorize_analysis( _operation_check(capability) support = validate_exact_support(request) identity_hashes = validate_observation_hashes(request, support) + validate_identity_dimensions(request, capability, support) + validate_context_prerequisites(request, capability, support) validate_level_of_analysis(request, capability, support) validate_support_shape(request, capability, support) comparability_hashes = validate_comparability_authority(request, capability, support) diff --git a/src/dynamislm/analysis/validation.py b/src/dynamislm/analysis/validation.py index c9199e0..5ceead1 100644 --- a/src/dynamislm/analysis/validation.py +++ b/src/dynamislm/analysis/validation.py @@ -25,12 +25,22 @@ ApplicabilityAxis, validate_claim_evidence_authority, ) +from dynamislm.longitudinal.statistics.agreement import calculate_bland_altman_summary +from dynamislm.longitudinal.statistics.descriptive import calculate_reference_window_deviation from dynamislm.longitudinal.statistics.models import StatisticalSupport +from dynamislm.longitudinal.statistics.registry import ( + RES69_METHOD_COMPARISON_DESIGN_OPERATION, + RES69_RELIABILITY_ASSUMPTION_ASSESSMENT_OPERATION, + RES69_RELIABILITY_DESIGN_OPERATION, + RES69_SCALE_SEMANTICS_AUTHORITY, +) +from dynamislm.longitudinal.statistics.reliability import calculate_two_replicate_random_error from dynamislm.longitudinal.statistics.support import ( StatisticalConstraintError, validate_statistical_support, ) from dynamislm.measurement.result import ScalarValue +from dynamislm.refusal.models import RefusalResult from dynamislm.serialization import canonical_hash @@ -43,6 +53,60 @@ def __init__(self, message: str, code: str, missing_information: tuple[str, ...] self.missing_information = missing_information +_SUPPORTED_IDENTITY_DIMENSIONS = { + "CONSTRUCT", + "TEST_FAMILY", + "MEASURAND", + "METRIC_DEFINITION", + "PROTOCOL", + "EVENT_DEFINITION", + "PHASE_DEFINITION", + "UNIT", + "NORMALIZATION", + "ESTIMATOR", + "REGISTERED_PROCESSING_OPERATION", + "PROCESSING_PARAMETERS", + "FILTERING_SMOOTHING_RESAMPLING", + "SAMPLING_AND_TIMEBASE", + "CALIBRATION_REFERENCE", + "DEVICE_MEASURING_SYSTEM", + "PROVIDER", + "SOFTWARE_ALGORITHM_VERSION", + "HARDWARE_FIRMWARE_VERSION", + "SIGN_CONVENTION_AND_REFERENCE_FRAME", + "THRESHOLD_IDENTITY", + "TRIAL_SELECTION_POLICY", + "AGGREGATION_POLICY", + "SESSION_SEGMENTATION", + "VALUE_ORIGIN", +} +_SUPPORTED_CONTEXT_PREREQUISITES = { + "FOOTBALL_WORLD_CONTEXT", + "MATCH_TRAINING_TESTING_EXPOSURE", +} +_SUPPORTED_SUPPORT_SHAPES = { + "exactly-two-scalar-entries", + "nonzero-denominator", + "strictly-positive-values", + "one-current-entry", + "at-least-two-reference-entries", + "positive-reference-sd", + "repeated-observations", + "explicit-clustering", + "two-replicate-pairs", + "one-pair-per-independent-subject", + "distinct-test-identities", + "temporal-or-lag-policy", + "explicit-random-effects", +} +_SUPPORTED_STATISTICAL_AUTHORITY = { + RES69_SCALE_SEMANTICS_AUTHORITY.stable_id, + RES69_RELIABILITY_DESIGN_OPERATION.stable_id, + RES69_RELIABILITY_ASSUMPTION_ASSESSMENT_OPERATION.stable_id, + RES69_METHOD_COMPARISON_DESIGN_OPERATION.stable_id, +} + + def validate_analysis_capability_registry(registry: AnalysisCapabilityRegistry) -> None: if not isinstance(registry, AnalysisCapabilityRegistry): raise AnalysisValidationError( @@ -67,6 +131,40 @@ def validate_analysis_capability_registry(registry: AnalysisCapabilityRegistry) "analysis capability registry hash does not match entries", "RES70_REGISTRY_INTEGRITY_FAILURE", ) + for capability in registry.entries: + if capability.disposition.value != "IMPLEMENTED": + continue + unknown = ( + set(capability.required_identity_dimensions) - _SUPPORTED_IDENTITY_DIMENSIONS + ) | (set(capability.required_context) - _SUPPORTED_CONTEXT_PREREQUISITES) + unknown |= set(capability.required_support_shape) - _SUPPORTED_SUPPORT_SHAPES + unknown |= { + item.stable_id for item in capability.required_statistical_authority + } - _SUPPORTED_STATISTICAL_AUTHORITY + if unknown: + raise AnalysisValidationError( + "implemented capability declares an unknown prerequisite token", + "RES70_REGISTRY_INTEGRITY_FAILURE", + tuple(sorted(unknown)), + ) + if capability.output_claim_floor is not None and capability.output_claim_floor not in { + "OBSERVED_VALUE", + "NUMERICAL_CHANGE", + "COMPARABLE_CHANGE", + "CHANGE_RELATIVE_TO_MEASUREMENT_ERROR", + "PRACTICAL_OR_DECISION_MEANINGFULNESS", + "OBSERVATION", + "DESCRIPTIVE_CHANGE", + "ASSOCIATION", + "TEMPORAL_ASSOCIATION", + "MECHANISTIC_HYPOTHESIS", + "CAUSAL_EVIDENCE", + }: + raise AnalysisValidationError( + "implemented capability declares an unknown output claim floor", + "RES70_REGISTRY_INTEGRITY_FAILURE", + (capability.output_claim_floor,), + ) def validate_exact_support(request: AnalysisAuthorizationRequest) -> StatisticalSupport: @@ -124,6 +222,99 @@ def validate_observation_hashes( return expected +def validate_identity_dimensions( + request: AnalysisAuthorizationRequest, + capability: AnalysisCapability, + support: StatisticalSupport, +) -> None: + """Verify every declared identity dimension exists on exact support.""" + + del request + unknown = set(capability.required_identity_dimensions) - _SUPPORTED_IDENTITY_DIMENSIONS + if unknown: + raise AnalysisValidationError( + "unknown identity prerequisite token", + "RES70_REGISTRY_INTEGRITY_FAILURE", + tuple(sorted(unknown)), + ) + for dimension in capability.required_identity_dimensions: + for entry in support.included_entries: + identity = entry.observation.identity + semantic = identity.semantic + processing = identity.processing + result = entry.observation.result + values: dict[str, object] = { + "CONSTRUCT": semantic.construct, + "TEST_FAMILY": semantic.test_family, + "MEASURAND": semantic.measurand, + "METRIC_DEFINITION": semantic.metric_definition, + "PROTOCOL": semantic.protocol, + "EVENT_DEFINITION": processing.event_definitions, + "PHASE_DEFINITION": processing.phase_definitions, + "UNIT": result.unit or processing.unit, + "NORMALIZATION": processing.normalization, + "ESTIMATOR": processing.estimator, + "REGISTERED_PROCESSING_OPERATION": processing.registered_operation, + "PROCESSING_PARAMETERS": processing.method_parameters, + "FILTERING_SMOOTHING_RESAMPLING": processing.filtering, + "SAMPLING_AND_TIMEBASE": identity.acquisition.sampling, + "CALIBRATION_REFERENCE": identity.acquisition.calibration_reference, + "DEVICE_MEASURING_SYSTEM": identity.acquisition.device, + "PROVIDER": tuple( + item for item in processing.method_parameters if item.key == "provider" + ), + "SOFTWARE_ALGORITHM_VERSION": identity.version.software_version, + "HARDWARE_FIRMWARE_VERSION": identity.version.hardware_firmware, + "SIGN_CONVENTION_AND_REFERENCE_FRAME": processing.sign_convention, + "THRESHOLD_IDENTITY": tuple( + item for item in processing.method_parameters if "threshold" in item.key.lower() + ), + "TRIAL_SELECTION_POLICY": processing.trial_selection, + "AGGREGATION_POLICY": processing.aggregation, + "SESSION_SEGMENTATION": tuple( + item + for item in processing.method_parameters + if item.key == "session_segmentation" + ), + "VALUE_ORIGIN": result.classification.value_origin, + } + if values[dimension] is None or values[dimension] == (): + raise AnalysisValidationError( + "declared identity dimension is unresolved", + "RES70_UNRESOLVED_IDENTITY", + (dimension,), + ) + + +def validate_context_prerequisites( + request: AnalysisAuthorizationRequest, + capability: AnalysisCapability, + support: StatisticalSupport, +) -> None: + unknown = set(capability.required_context) - _SUPPORTED_CONTEXT_PREREQUISITES + if unknown: + raise AnalysisValidationError( + "unknown context prerequisite token", + "RES70_REGISTRY_INTEGRITY_FAILURE", + tuple(sorted(unknown)), + ) + for context_token in capability.required_context: + if not request.context_references: + raise AnalysisValidationError( + "declared context prerequisite is missing", + "RES70_INCOMPATIBLE_CONTEXT", + (context_token,), + ) + if context_token == "MATCH_TRAINING_TESTING_EXPOSURE" and any( + entry.football_context.exposure is None for entry in support.included_entries + ): + raise AnalysisValidationError( + "match/training/testing exposure context is unresolved", + "RES70_INCOMPATIBLE_CONTEXT", + (context_token,), + ) + + def _scalar_entries(support: StatisticalSupport) -> tuple[object, ...]: return tuple( entry @@ -199,6 +390,13 @@ def validate_support_shape( ) -> None: entries = support.entries scalar_count = len(_scalar_entries(support)) + unknown = set(capability.required_support_shape) - _SUPPORTED_SUPPORT_SHAPES + if unknown: + raise AnalysisValidationError( + "unknown support-shape prerequisite token", + "RES70_REGISTRY_INTEGRITY_FAILURE", + tuple(sorted(unknown)), + ) for shape in capability.required_support_shape: if shape == "exactly-two-scalar-entries" and (len(entries) != 2 or scalar_count != 2): raise AnalysisValidationError( @@ -262,6 +460,61 @@ def validate_support_shape( "cross-test association requires distinct registered test identities", "RES70_STATISTICAL_AUTHORITY_INSUFFICIENT", ) + if shape == "positive-reference-sd": + result = calculate_reference_window_deviation(support) + if isinstance(result, RefusalResult): + raise AnalysisValidationError( + "RES-69 reference-window validator rejected the support", + "RES70_STATISTICAL_AUTHORITY_INSUFFICIENT", + result.missing_information, + ) + if shape == "two-replicate-pairs": + if request.reliability_authority is None: + raise AnalysisValidationError( + "two-replicate support requires source-bound reliability authority", + "RES70_STATISTICAL_AUTHORITY_INSUFFICIENT", + ) + result = calculate_two_replicate_random_error( + support, + request.reliability_authority, + ) + if isinstance(result, RefusalResult): + raise AnalysisValidationError( + "RES-69 reliability validator rejected the support", + "RES70_STATISTICAL_AUTHORITY_INSUFFICIENT", + result.missing_information, + ) + if shape == "one-pair-per-independent-subject": + if request.method_comparison_authority is None: + raise AnalysisValidationError( + "one-pair support requires source-bound method-comparison authority", + "RES70_STATISTICAL_AUTHORITY_INSUFFICIENT", + ) + result = calculate_bland_altman_summary( + support, + request.method_comparison_authority, + ) + if isinstance(result, RefusalResult): + raise AnalysisValidationError( + "RES-69 method-comparison validator rejected the support", + "RES70_STATISTICAL_AUTHORITY_INSUFFICIENT", + result.missing_information, + ) + if ( + shape == "temporal-or-lag-policy" + and request.requested_level.temporal_order_policy is None + ): + raise AnalysisValidationError( + "temporal or lag policy is required", + "RES70_WRONG_LEVEL_OF_ANALYSIS", + ) + if shape == "explicit-random-effects" and not any( + item.key in {"random_effects", "fixed_effects"} for item in request.requested_parameters + ): + raise AnalysisValidationError( + "explicit random-effects specification is required", + "RES70_WRONG_LEVEL_OF_ANALYSIS", + ) def validate_comparability_authority( @@ -383,8 +636,10 @@ def validate_evidence_applicability( "AnalysisValidationError", "validate_analysis_capability_registry", "validate_comparability_authority", + "validate_context_prerequisites", "validate_evidence_applicability", "validate_exact_support", + "validate_identity_dimensions", "validate_level_of_analysis", "validate_observation_hashes", "validate_support_shape", diff --git a/src/dynamislm/claims/authority.py b/src/dynamislm/claims/authority.py index 149a75a..93752c7 100644 --- a/src/dynamislm/claims/authority.py +++ b/src/dynamislm/claims/authority.py @@ -19,6 +19,7 @@ from dynamislm.claims.registry import ( CANONICAL_CLAIM_POLICY_REGISTRY, ClaimAxis, + ClaimLevel, ClaimPolicy, ClaimPolicyRegistry, is_canonical_claim_registry, @@ -506,10 +507,11 @@ def _evaluate_axis( tuple[str, ...], str | None, ]: - requested = ( + requested = cast( + ClaimLevel, intent.measurement_level if axis is ClaimAxis.MEASUREMENT_CHANGE - else intent.relationship_level + else intent.relationship_level, ) if requested is None: return (), (), (), (), None @@ -519,27 +521,46 @@ def _evaluate_axis( missing: list[str] = [] first: str | None = None lower_failure: tuple[str | None, tuple[str, ...], tuple[str, ...]] | None = None - for level in levels: - policy = registry.resolve(axis, level) + for index, level in enumerate(levels): + current_level = cast(ClaimLevel, level) + policy = registry.resolve(axis, current_level) if policy is None: - blocked.append(level.value) + blocked.append(current_level.value) reasons.append("RES70_REGISTRY_INTEGRITY_FAILURE") first = first or "canonical claim policy" break + if policy.required_lower_level is not None and policy.required_lower_level not in allowed: + failure = lower_failure or ( + "registered lower-level claim authority", + ("RES70_UNSUPPORTED_CLAIM_ESCALATION",), + ("required lower-level claim",), + ) + requested_policy = registry.resolve(axis, requested) + if requested_policy is not None and requested is not current_level: + _, _, requested_reasons, _ = _policy_result(intent, requested_policy) + reasons.extend(requested_reasons) + blocked.extend(item.value for item in levels[index:]) + reasons.extend(failure[1]) + reasons.append("RES70_UNSUPPORTED_CLAIM_ESCALATION") + missing.extend(failure[2]) + first = first or failure[0] + break ok, missing_item, reason_codes, missing_items = _policy_result(intent, policy) if ok: - allowed.append(level) - if level is requested: + allowed.append(current_level) + if current_level is requested: break else: - if level is not requested: - blocked.append(level.value) - lower_failure = (missing_item, reason_codes, missing_items) - continue - blocked.append(level.value) - if lower_failure is not None and not reason_codes: - missing_item, reason_codes, missing_items = lower_failure + blocked.append(current_level.value) + blocked.extend(item.value for item in levels[index + 1 :]) + lower_failure = (missing_item, reason_codes, missing_items) reasons.extend(reason_codes) + if current_level is not requested: + requested_policy = registry.resolve(axis, requested) + if requested_policy is not None: + _, _, requested_reasons, _ = _policy_result(intent, requested_policy) + reasons.extend(requested_reasons) + reasons.append("RES70_UNSUPPORTED_CLAIM_ESCALATION") missing.extend(missing_items) first = first or missing_item break From 9040ef10e08a308d36854b03bbe50ddab651b71c Mon Sep 17 00:00:00 2001 From: Julio Rodriguez <144072916+Litju@users.noreply.github.com> Date: Fri, 18 Sep 2026 23:11:58 +0000 Subject: [PATCH 14/18] fix(res70): adjudicate exact claim context --- src/dynamislm/analysis/validation.py | 3 + src/dynamislm/claims/authority.py | 13 ++ .../comparability/res70_authority.py | 155 ++++++++++++++++-- src/dynamislm/comparability/res70_models.py | 5 + src/dynamislm/evidence/res70.py | 46 ++++-- 5 files changed, 195 insertions(+), 27 deletions(-) diff --git a/src/dynamislm/analysis/validation.py b/src/dynamislm/analysis/validation.py index 5ceead1..92b9220 100644 --- a/src/dynamislm/analysis/validation.py +++ b/src/dynamislm/analysis/validation.py @@ -532,6 +532,9 @@ def validate_comparability_authority( tuple(entry.observation for entry in support.included_entries), bridge_requests=request.bridge_requests, bridge_executions=request.bridge_executions, + football_contexts=tuple( + entry.football_context for entry in support.included_entries + ), ) except RES70ValidationError as exc: raise AnalysisValidationError( diff --git a/src/dynamislm/claims/authority.py b/src/dynamislm/claims/authority.py index 93752c7..dbf7c4c 100644 --- a/src/dynamislm/claims/authority.py +++ b/src/dynamislm/claims/authority.py @@ -118,6 +118,18 @@ def _exact_upstream_observations( return intent.observations +def _exact_upstream_contexts(intent: ClaimIntent) -> tuple[object, ...]: + if intent.analysis_authorization_request is not None: + support = intent.analysis_authorization_request.support + if support is not None: + return tuple(entry.football_context for entry in support.included_entries) + if intent.statistical_result is not None and intent.statistical_result.support is not None: + return tuple( + entry.football_context for entry in intent.statistical_result.support.included_entries + ) + return () + + def _validate_upstream_authority(intent: ClaimIntent) -> None: authorization = intent.analysis_authorization authorization_request = intent.analysis_authorization_request @@ -164,6 +176,7 @@ def _validate_upstream_authority(intent: ClaimIntent) -> None: _exact_upstream_observations(intent), bridge_requests=bridge_requests, bridge_executions=bridge_executions, + football_contexts=_exact_upstream_contexts(intent), ) except (RES70ValidationError, ValueError) as exc: raise ClaimUpstreamValidationError( diff --git a/src/dynamislm/comparability/res70_authority.py b/src/dynamislm/comparability/res70_authority.py index ad06d12..e7645ae 100644 --- a/src/dynamislm/comparability/res70_authority.py +++ b/src/dynamislm/comparability/res70_authority.py @@ -21,6 +21,7 @@ BridgeExecutionStatus, BridgeMode, BridgeRegistration, + ClaimContext, ComparabilityDimension, CrossSourceComparabilityDecision, CrossSourceComparabilityRequest, @@ -45,6 +46,13 @@ validate_bridge_registry, validate_cross_source_request, ) +from dynamislm.football.models import ( + FootballWorldContext, + MatchSession, + TestingSession, + TrainingSession, +) +from dynamislm.football.validation import validate_football_world_context from dynamislm.measurement.identity import ( InstanceIdentifier, MetadataEntry, @@ -80,10 +88,85 @@ def _metadata_text(value: object) -> str | None: return canonical_hash(value) +def _football_context_signature(context: FootballWorldContext) -> str: + validate_football_world_context(context) + session = context.session + session_kind = type(session).__name__ + session_details: tuple[tuple[str, object], ...] = () + if isinstance(session, MatchSession): + session_details = ( + ("competition_kind", session.competition_context.competition_kind.value), + ("venue_role", session.venue_role.value), + ) + elif isinstance(session, TrainingSession): + session_details = ( + ("training_type", session.training_type.stable_id if session.training_type else None), + ) + elif isinstance(session, TestingSession): + session_details = ( + ("test_type", session.test_type.stable_id if session.test_type else None), + ) + exposure = context.exposure + exposure_signature = ( + ( + type(exposure).__name__, + exposure.participation_state.value, + exposure.observed_duration_seconds, + ) + if exposure is not None + else None + ) + return canonical_hash( + { + "session_kind": session_kind, + "session_details": session_details, + "exposure": exposure_signature, + "microcycle_present": context.microcycle_context is not None, + } + ) + + +def _context_candidates( + observation: ScientificMeasurementObservation, + football_contexts: Mapping[object, object] | Sequence[object] | None, +) -> tuple[FootballWorldContext, ...]: + if football_contexts is None: + return () + values = ( + tuple(football_contexts.values()) + if isinstance(football_contexts, Mapping) + else tuple(football_contexts) + ) + candidates: list[FootballWorldContext] = [] + for value in values: + if not isinstance(value, FootballWorldContext): + raise ComparabilityAuthorityError( + "football context resolver must contain typed FootballWorldContext values" + ) + if value.observation_context_id == observation.context.context_id or ( + value.athlete.athlete_id == observation.context.athlete_id + and value.session.session_id == observation.context.session_id + ): + if value not in candidates: + candidates.append(value) + return tuple(candidates) + + +def _context_for_observation( + observation: ScientificMeasurementObservation, + football_contexts: Mapping[object, object] | Sequence[object] | None, +) -> FootballWorldContext | None: + candidates = _context_candidates(observation, football_contexts) + if len(candidates) != 1: + return None + return candidates[0] + + def _dimension_values( observation: ScientificMeasurementObservation, *, - claim_context_present: bool, + claim_context: ClaimContext | None, + football_context: FootballWorldContext | None, ) -> dict[ComparabilityDimension, str | None]: identity = observation.identity semantic = identity.semantic @@ -93,6 +176,14 @@ def _dimension_values( context = observation.context result = observation.result parameters = {item.key: item.value for item in processing.method_parameters} + requested_world_context = ( + claim_context.football_world_context if claim_context is not None else None + ) + exposure_value = ( + _football_context_signature(football_context) + if football_context is not None and requested_world_context is not None + else None + ) return { ComparabilityDimension.CONSTRUCT: semantic.construct.stable_id, ComparabilityDimension.TEST_FAMILY: semantic.test_family.stable_id, @@ -151,16 +242,18 @@ def _dimension_values( ComparabilityDimension.ACQUISITION_CONTEXT: _canonical_value( (context.environment, context.context_metadata) ), - ComparabilityDimension.EXPOSURE_CONTEXT_MATCH_OR_TRAINING: ( - context.population_context if claim_context_present else None - ), + ComparabilityDimension.EXPOSURE_CONTEXT_MATCH_OR_TRAINING: (exposure_value), ComparabilityDimension.VALUE_ORIGIN: result.classification.value_origin.value, ComparabilityDimension.UNCERTAINTY_ERROR_MODEL: _canonical_value(result.uncertainty), ComparabilityDimension.POPULATION_APPLICABILITY: context.population_context, ComparabilityDimension.EVIDENCE_APPLICABILITY: _canonical_value( observation.provenance.evidence_references ), - ComparabilityDimension.FOOTBALL_WORLD_CONTEXT: None, + ComparabilityDimension.FOOTBALL_WORLD_CONTEXT: ( + _football_context_signature(football_context) + if football_context is not None and requested_world_context is not None + else None + ), } @@ -216,10 +309,26 @@ def _findings( left: ScientificMeasurementObservation, right: ScientificMeasurementObservation, *, - claim_context_present: bool, + claim_context: ClaimContext | None, + football_contexts: Mapping[object, object] | Sequence[object] | None, ) -> tuple[DimensionFinding, ...]: - left_values = _dimension_values(left, claim_context_present=claim_context_present) - right_values = _dimension_values(right, claim_context_present=claim_context_present) + left_context = _context_for_observation(left, football_contexts) + right_context = _context_for_observation(right, football_contexts) + left_values = _dimension_values( + left, + claim_context=claim_context, + football_context=left_context, + ) + right_values = _dimension_values( + right, + claim_context=claim_context, + football_context=right_context, + ) + requested_context_value = ( + _football_context_signature(claim_context.football_world_context) + if claim_context is not None and claim_context.football_world_context is not None + else None + ) findings: list[DimensionFinding] = [] for dimension in ComparabilityDimension: left_value = left_values[dimension] @@ -230,10 +339,32 @@ def _findings( ComparabilityDimension.FOOTBALL_WORLD_CONTEXT, ComparabilityDimension.EXPOSURE_CONTEXT_MATCH_OR_TRAINING, } - and not claim_context_present + and claim_context is None ): status = DimensionFindingStatus.NOT_APPLICABLE reason_codes: tuple[str, ...] = () + elif ( + dimension + in { + ComparabilityDimension.FOOTBALL_WORLD_CONTEXT, + ComparabilityDimension.EXPOSURE_CONTEXT_MATCH_OR_TRAINING, + } + and requested_context_value is not None + and (left_value is None or right_value is None) + ): + status = DimensionFindingStatus.UNKNOWN + reason_codes = ("MISSING_TYPED_CONTEXT",) + elif ( + dimension + in { + ComparabilityDimension.FOOTBALL_WORLD_CONTEXT, + ComparabilityDimension.EXPOSURE_CONTEXT_MATCH_OR_TRAINING, + } + and requested_context_value is not None + and (left_value != requested_context_value or right_value != requested_context_value) + ): + status = DimensionFindingStatus.MISMATCH + reason_codes = (_reason_for_dimension(dimension),) elif ( left_value is None and right_value is None @@ -361,7 +492,8 @@ def assess_cross_source_comparability( findings = _findings( left, right, - claim_context_present=request.claim_context is not None, + claim_context=request.claim_context, + football_contexts=football_contexts, ) leaf = family_result return CrossSourceComparabilityDecision.create( @@ -446,7 +578,8 @@ def assess_cross_source_comparability( findings = _findings( effective_left, effective_right, - claim_context_present=request.claim_context is not None, + claim_context=request.claim_context, + football_contexts=football_contexts, ) unknown = tuple( finding for finding in findings if finding.status is DimensionFindingStatus.UNKNOWN diff --git a/src/dynamislm/comparability/res70_models.py b/src/dynamislm/comparability/res70_models.py index 48cd786..cdef587 100644 --- a/src/dynamislm/comparability/res70_models.py +++ b/src/dynamislm/comparability/res70_models.py @@ -16,6 +16,8 @@ ComparabilityState, TransformationRequest, ) +from dynamislm.football.models import FootballWorldContext +from dynamislm.football.validation import validate_football_world_context from dynamislm.measurement.identity import ( InstanceIdentifier, MeasurementIdentity, @@ -300,11 +302,14 @@ class ClaimContext: context_reference: RegistryReference context_kind: str attributes: tuple[MetadataEntry, ...] = () + football_world_context: FootballWorldContext | None = None def __post_init__(self) -> None: _require_instance(self.context_reference, RegistryReference, "context_reference") _require_text(self.context_kind, "context_kind") _require_tuple_items(self.attributes, MetadataEntry, "attributes") + if self.football_world_context is not None: + validate_football_world_context(self.football_world_context) @register_serializable_type diff --git a/src/dynamislm/evidence/res70.py b/src/dynamislm/evidence/res70.py index aefe3c4..1f2e0b1 100644 --- a/src/dynamislm/evidence/res70.py +++ b/src/dynamislm/evidence/res70.py @@ -4,12 +4,9 @@ from dataclasses import dataclass from enum import StrEnum +from typing import TYPE_CHECKING from dynamislm.evidence.models import ApplicabilityDecision -from dynamislm.football.models import FootballWorldContext -from dynamislm.football.validation import validate_football_world_context -from dynamislm.longitudinal.statistics.models import StatisticalSupport -from dynamislm.longitudinal.statistics.support import validate_statistical_support from dynamislm.measurement.identity import ( RegistryReference, _require_enum, @@ -18,20 +15,17 @@ _require_tuple_items, require_tuple, ) -from dynamislm.population.models import ( - CanonicalPopulationDecision, - CanonicalPopulationStatus, - CanonicalSourceDecision, - CanonicalSourceStatus, - EvidenceClass, - V2EvidenceApplicability, -) -from dynamislm.population.qualification import ( - qualify_canonical_population, - qualify_canonical_source, -) from dynamislm.serialization import canonical_hash, register_serializable_type +if TYPE_CHECKING: + from dynamislm.football.models import FootballWorldContext + from dynamislm.longitudinal.statistics.models import StatisticalSupport + from dynamislm.population.models import ( + CanonicalPopulationDecision, + CanonicalSourceDecision, + V2EvidenceApplicability, + ) + def _require_string_tuple(value: object, field_name: str) -> None: require_tuple(value, field_name) @@ -61,6 +55,14 @@ class ApplicabilityAuthorityProvenance: football_contexts: tuple[FootballWorldContext, ...] = () def __post_init__(self) -> None: + from dynamislm.football.models import FootballWorldContext + from dynamislm.longitudinal.statistics.models import StatisticalSupport + from dynamislm.population.models import ( + CanonicalPopulationDecision, + CanonicalSourceDecision, + V2EvidenceApplicability, + ) + _require_tuple_items(self.source_decisions, CanonicalSourceDecision, "source_decisions") _require_tuple_items( self.population_decisions, @@ -212,6 +214,9 @@ def validate_claim_evidence_applicability(value: ClaimEvidenceApplicability) -> def _validate_source_authority( decisions: tuple[CanonicalSourceDecision, ...], ) -> None: + from dynamislm.population.models import CanonicalSourceStatus + from dynamislm.population.qualification import qualify_canonical_source + if not decisions: raise ValueError("affirmative applicability requires canonical source authority") for decision in decisions: @@ -224,6 +229,9 @@ def _validate_source_authority( def _validate_population_authority( decisions: tuple[CanonicalPopulationDecision, ...], ) -> None: + from dynamislm.population.models import CanonicalPopulationStatus + from dynamislm.population.qualification import qualify_canonical_population + if not decisions: raise ValueError("affirmative applicability requires canonical population authority") for decision in decisions: @@ -238,6 +246,8 @@ def _validate_population_authority( def _validate_evidence_authority( provenance: ApplicabilityAuthorityProvenance, ) -> None: + from dynamislm.population.models import EvidenceClass + if not provenance.evidence_applicabilities: raise ValueError("affirmative method applicability requires canonical evidence authority") if not provenance.source_decisions: @@ -258,6 +268,8 @@ def _validate_evidence_authority( def _validate_context_authority( provenance: ApplicabilityAuthorityProvenance, ) -> None: + from dynamislm.football.validation import validate_football_world_context + if not provenance.football_contexts: raise ValueError("contextual applicability requires typed football-world contexts") for context in provenance.football_contexts: @@ -267,6 +279,8 @@ def _validate_context_authority( def _validate_statistical_authority( provenance: ApplicabilityAuthorityProvenance, ) -> None: + from dynamislm.longitudinal.statistics.support import validate_statistical_support + if provenance.statistical_support is None: raise ValueError("statistical applicability requires exact statistical support") validate_statistical_support(provenance.statistical_support) From ac6b5c2ba1fa46f1083701441412f93eb0b4e685 Mon Sep 17 00:00:00 2001 From: Julio Rodriguez <144072916+Litju@users.noreply.github.com> Date: Fri, 18 Sep 2026 23:16:52 +0000 Subject: [PATCH 15/18] test(res70): qualify authority forgery and prerequisite bypasses --- tests/test_res70_adversarial.py | 227 +++++++++++++++++++++++++++++- tests/test_res70_bridges.py | 67 +++++++++ tests/test_res70_comparability.py | 101 +++++++++++++ 3 files changed, 394 insertions(+), 1 deletion(-) diff --git a/tests/test_res70_adversarial.py b/tests/test_res70_adversarial.py index 33d8986..de35d9f 100644 --- a/tests/test_res70_adversarial.py +++ b/tests/test_res70_adversarial.py @@ -5,6 +5,8 @@ import pytest from dynamislm import ( + AnalysisAuthorization, + AnalysisAuthorizationRequest, AnalysisClass, AnalysisEstimandLevel, AnalysisLevelIdentity, @@ -20,12 +22,20 @@ RegistryReference, RelationshipClaimLevel, ScientificIdentifier, + authorize_analysis, authorize_claim, ) from dynamislm.analysis.registry import RES70_CAPABILITY_REGISTRY -from dynamislm.analysis.validation import validate_level_of_analysis +from dynamislm.analysis.validation import ( + AnalysisValidationError, + validate_context_prerequisites, + validate_identity_dimensions, + validate_level_of_analysis, + validate_support_shape, +) from dynamislm.comparability import ( BridgeRegistry, + ComparabilityState, RES70ComparabilityAuthorityError, assess_cross_source_comparability, validate_pairwise_decisions, @@ -35,6 +45,11 @@ CrossSourceComparabilityRequest, ObservationAuthorityReference, ) +from dynamislm.comparability.res70_registry import ( + RES70_COMPARABILITY_RULE_REGISTRY, + RES70_CROSS_SOURCE_COMPARABILITY_RULE, + canonical_registry_hash, +) from dynamislm.evidence.res70 import validate_claim_evidence_applicability from dynamislm.refusal import RefusalResult from test_kernel import _derived_observation @@ -206,3 +221,213 @@ def test_unsupported_practical_and_causal_escalations_are_refused() -> None: assert isinstance(causal_result.refusal_result, RefusalResult) assert "RES70_UNSUPPORTED_CLAIM_ESCALATION" in practical_result.reason_codes assert "RES70_UNSUPPORTED_CAUSAL_CLAIM" in causal_result.reason_codes + + +def test_forged_comparable_decision_is_rejected_at_analysis_intake() -> None: + entries = _entries((10.0, 12.0), prefix="res70-forged-comparable") + support = _support(entries) + claim = _reference("claim-intent", "forged-comparable") + pair_request = CrossSourceComparabilityRequest( + request_id=InstanceIdentifier("cross-source-comparability-request", "forged-analysis"), + left_observation=ObservationAuthorityReference.from_observation(entries[0].observation), + right_observation=ObservationAuthorityReference.from_observation(entries[1].observation), + claim_intent=claim, + ) + forged = CrossSourceComparabilityDecision.create( + request_hash=pair_request.request_hash, + state=ComparabilityState.COMPARABLE, + dimension_findings=(), + conditions=(), + transformations_required=(), + bridge_application_reference=None, + rule_reference=RES70_CROSS_SOURCE_COMPARABILITY_RULE, + evidence_references=(), + registry_version=RES70_COMPARABILITY_RULE_REGISTRY.registry_version, + registry_hash=canonical_registry_hash(), + left_observation=pair_request.left_observation, + right_observation=pair_request.right_observation, + ) + request = AnalysisAuthorizationRequest( + request_id=InstanceIdentifier("analysis-authorization-request", "forged-analysis"), + analysis_class=AnalysisClass.SCALAR_ABSOLUTE_CHANGE, + support=support, + support_reference=None, + observations=tuple(entry.observation for entry in entries), + identity_hashes=(), + comparability_decisions=(forged,), + requested_level=AnalysisLevelIdentity( + unit_of_analysis=AnalysisUnitOfAnalysis.ATHLETE, + estimand_level=AnalysisEstimandLevel.WITHIN_ATHLETE, + subject_key="athlete_id", + ), + evidence_applicability=None, + context_references=(), + comparability_requests=(pair_request,), + ) + + result = authorize_analysis(request) + + assert isinstance(result, RefusalResult) + assert "RES70_COMPARABILITY_AUTHORITY_MISSING" in result.reason_codes + + +def test_caller_minted_supported_applicability_is_not_claim_authority() -> None: + observation = _derived_observation("res70-caller-supported") + bundle = ClaimEvidenceApplicability( + claim_intent_reference=_reference("claim-intent", "caller-supported"), + assessments=( + ApplicabilityAssessment( + axis=ApplicabilityAxis.METHOD_VALIDITY, + decision=ApplicabilityDecision.SUPPORTED, + required_for_claim=True, + evidence_references=(_reference("evidence", "caller"),), + authority_references=(), + conditions=(), + rationale="caller assertion", + ), + ), + registry_version="res70-1.0.0", + ) + intent = ClaimIntent( + claim_reference=_reference("claim-intent", "caller-supported"), + measurement_level=MeasurementClaimLevel.OBSERVED_VALUE, + relationship_level=None, + predictive_intent=None, + target=ClaimTarget.INDIVIDUAL, + analysis_reference=None, + evidence_applicability_reference=None, + decision_criterion_reference=None, + observations=(observation,), + evidence_applicability=bundle, + ) + + result = authorize_claim(intent) + + assert result.status.value == "REFUSED" + assert "RES70_INSUFFICIENT_EVIDENCE_APPLICABILITY" in result.reason_codes + + +def test_forged_authorized_analysis_is_rejected_at_claim_boundary() -> None: + entries = _entries((10.0, 12.0), prefix="res70-forged-authorized") + support = _support(entries) + request = AnalysisAuthorizationRequest( + request_id=InstanceIdentifier("analysis-authorization-request", "forged-authorized"), + analysis_class=AnalysisClass.SCALAR_ABSOLUTE_CHANGE, + support=support, + support_reference=None, + observations=tuple(entry.observation for entry in entries), + identity_hashes=(), + comparability_decisions=(), + requested_level=AnalysisLevelIdentity( + unit_of_analysis=AnalysisUnitOfAnalysis.ATHLETE, + estimand_level=AnalysisEstimandLevel.WITHIN_ATHLETE, + subject_key="athlete_id", + ), + evidence_applicability=None, + context_references=(), + ) + authorized = authorize_analysis(request) + assert isinstance(authorized, AnalysisAuthorization) + forged = AnalysisAuthorization.create( + status=authorized.status, + request_id=authorized.request_id, + analysis_class=authorized.analysis_class, + capability_reference=authorized.capability_reference, + capability_hash=authorized.capability_hash, + operation_reference=authorized.operation_reference, + estimator_reference=authorized.estimator_reference, + support_hashes=authorized.support_hashes, + identity_hashes=authorized.identity_hashes, + comparability_hashes=authorized.comparability_hashes, + statistical_authority_hashes=authorized.statistical_authority_hashes, + resolved_level=authorized.resolved_level, + registry_version=authorized.registry_version, + software_version=authorized.software_version, + reason_codes=authorized.reason_codes, + missing_information=authorized.missing_information, + safe_descriptions=authorized.safe_descriptions, + ) + intent = ClaimIntent( + claim_reference=_reference("claim-intent", "forged-authorized"), + measurement_level=MeasurementClaimLevel.NUMERICAL_CHANGE, + relationship_level=None, + predictive_intent=None, + target=ClaimTarget.INDIVIDUAL, + analysis_reference=authorized.capability_reference, + evidence_applicability_reference=None, + decision_criterion_reference=None, + observations=tuple(entry.observation for entry in entries), + analysis_authorization=forged, + analysis_authorization_request=request, + ) + + result = authorize_claim(intent) + + assert result.status.value == "REFUSED" + assert "RES70_REGISTRY_INTEGRITY_FAILURE" in result.reason_codes + + +def test_unknown_capability_prerequisite_tokens_fail_closed() -> None: + entries = _entries((10.0, 12.0), prefix="res70-unknown-token") + support = _support(entries) + request = AnalysisAuthorizationRequest( + request_id=InstanceIdentifier("analysis-authorization-request", "unknown-token"), + analysis_class=AnalysisClass.SCALAR_ABSOLUTE_CHANGE, + support=support, + support_reference=None, + observations=tuple(entry.observation for entry in entries), + identity_hashes=(), + comparability_decisions=(), + requested_level=AnalysisLevelIdentity( + unit_of_analysis=AnalysisUnitOfAnalysis.ATHLETE, + estimand_level=AnalysisEstimandLevel.WITHIN_ATHLETE, + subject_key="athlete_id", + ), + evidence_applicability=None, + context_references=(), + ) + capability = RES70_CAPABILITY_REGISTRY.resolve(AnalysisClass.SCALAR_ABSOLUTE_CHANGE) + assert capability is not None + bad_shape = replace( + capability, + required_support_shape=("unknown-support-token",), + capability_hash=None, + ) + bad_identity = replace( + capability, + required_identity_dimensions=("UNKNOWN_IDENTITY",), + capability_hash=None, + ) + bad_context = replace( + capability, + required_context=("UNKNOWN_CONTEXT",), + capability_hash=None, + ) + + with pytest.raises(AnalysisValidationError, match="unknown support-shape"): + validate_support_shape(request, bad_shape, support) + with pytest.raises(AnalysisValidationError, match="unknown identity"): + validate_identity_dimensions(request, bad_identity, support) + with pytest.raises(AnalysisValidationError, match="unknown context"): + validate_context_prerequisites(request, bad_context, support) + + +def test_claim_hierarchy_reports_failed_predecessor_and_blocks_descendant() -> None: + result = authorize_claim( + ClaimIntent( + claim_reference=_reference("claim-intent", "skip-predecessor"), + measurement_level=MeasurementClaimLevel.COMPARABLE_CHANGE, + relationship_level=None, + predictive_intent=None, + target=ClaimTarget.INDIVIDUAL, + analysis_reference=None, + evidence_applicability_reference=None, + decision_criterion_reference=None, + observations=(_derived_observation("res70-skip-predecessor"),), + ) + ) + + assert MeasurementClaimLevel.NUMERICAL_CHANGE.value in result.blocked_claims + assert MeasurementClaimLevel.COMPARABLE_CHANGE.value in result.blocked_claims + assert MeasurementClaimLevel.COMPARABLE_CHANGE not in result.allowed_measurement_levels + assert result.first_blocking_prerequisite is not None diff --git a/tests/test_res70_bridges.py b/tests/test_res70_bridges.py index 5363026..f5248d8 100644 --- a/tests/test_res70_bridges.py +++ b/tests/test_res70_bridges.py @@ -2,6 +2,8 @@ from dataclasses import replace +import pytest + import dynamislm.comparability.res70_registry as res70_registry from dynamislm import ( BridgeApplicationRequest, @@ -18,7 +20,9 @@ SemanticIdentityKey, UnitReference, assess_cross_source_comparability, + canonical_hash, execute_registered_bridge, + validate_bridge_execution, ) from dynamislm.comparability import ( RES70_AFFINE_BRIDGE_OPERATION, @@ -26,6 +30,7 @@ BridgeExecutionResult, BridgeExecutionStatus, BridgeRegistry, + RES70ValidationError, ) from dynamislm.measurement.observation import ScientificMeasurementObservation from dynamislm.measurement.taxonomy import ValueOrigin @@ -177,3 +182,65 @@ def test_declared_bridge_without_execution_never_supports_transformed_values() - assert decision.state.value == "REQUIRES_TRANSFORMATION" assert "RES70_BRIDGE_NOT_EXECUTED" in decision.reason_codes assert decision.transformations_required + + +def test_forged_bridge_execution_hash_and_registration_are_rejected() -> None: + source, target, bridge, registry, claim = _bridge_fixture() + original_registry = res70_registry.CANONICAL_BRIDGE_REGISTRY + res70_registry.CANONICAL_BRIDGE_REGISTRY = registry + try: + request = BridgeApplicationRequest( + request_id=InstanceIdentifier("bridge-request", "res70-forged-execution"), + source_observation=ObservationAuthorityReference.from_observation(source), + bridge_reference=bridge.bridge_reference, + claim_intent=claim, + target_identity=target.identity, + ) + execution = execute_registered_bridge(request, source) + assert isinstance(execution, BridgeExecutionResult) + forged_content = { + "request_hash": execution.request_hash, + "bridge_reference": execution.bridge_reference, + "bridge_hash": "sha256:" + "0" * 64, + "source_observation": execution.source_observation, + "transformed_observation": execution.transformed_observation, + "processing_run": execution.processing_run, + "provenance": execution.provenance, + "output_observation_hash": execution.output_observation_hash, + "uncertainty_model": execution.uncertainty_model, + "lossiness_description": execution.lossiness_description, + "method_version": execution.method_version, + "provenance_rule": execution.provenance_rule, + "status": execution.status, + } + forged_hash = canonical_hash(forged_content) + forged = BridgeExecutionResult( + execution_id=InstanceIdentifier( + "bridge-execution", forged_hash.removeprefix("sha256:") + ), + status=execution.status, + request_hash=execution.request_hash, + bridge_reference=execution.bridge_reference, + bridge_hash="sha256:" + "0" * 64, + source_observation=execution.source_observation, + transformed_observation=execution.transformed_observation, + processing_run=execution.processing_run, + provenance=execution.provenance, + output_observation_hash=execution.output_observation_hash, + uncertainty_model=execution.uncertainty_model, + lossiness_description=execution.lossiness_description, + method_version=execution.method_version, + provenance_rule=execution.provenance_rule, + execution_hash=forged_hash, + ) + with pytest.raises(RES70ValidationError, match="canonical registration"): + validate_bridge_execution( + forged, + request.request_hash, + source, + bridge_registry=registry, + bridge_request=request, + target_identity=target.identity, + ) + finally: + res70_registry.CANONICAL_BRIDGE_REGISTRY = original_registry diff --git a/tests/test_res70_comparability.py b/tests/test_res70_comparability.py index d7f0ede..74893fd 100644 --- a/tests/test_res70_comparability.py +++ b/tests/test_res70_comparability.py @@ -7,6 +7,7 @@ import dynamislm.comparability.res70_registry as res70_registry from dynamislm import ( BridgeAuthorityOrigin, + ClaimContext, ComparabilityDimension, CrossSourceComparabilityRequest, InstanceIdentifier, @@ -15,6 +16,7 @@ ScientificIdentifier, UnitReference, assess_cross_source_comparability, + build_longitudinal_observation_entry, validate_cross_source_decision, ) from dynamislm.comparability import ( @@ -22,8 +24,11 @@ RES70ComparabilityAuthorityError, validate_pairwise_decisions, ) +from dynamislm.football.models import ParticipationState +from dynamislm.longitudinal.models import LongitudinalObservationEntry from dynamislm.measurement.observation import ScientificMeasurementObservation from test_kernel import _derived_observation +from test_longitudinal import _entry def _reference(object_type: str, key: str, label: str) -> RegistryReference: @@ -38,6 +43,38 @@ def _unit() -> UnitReference: return UnitReference(ScientificIdentifier("dynamislm", "unit", "meter", "1.0.0"), "m") +def _complete_context_entry( + key: str, + *, + kind: str = "training", +) -> LongitudinalObservationEntry: + entry = _entry( + key, + kind=kind, + exposure_state=( + ParticipationState.STARTER if kind == "match" else ParticipationState.PRESENT + ), + ) + identity = replace( + entry.observation.identity, + processing=replace( + entry.observation.identity.processing, + estimator=_reference("estimator", "context-estimator", "Context estimator"), + unit=_unit(), + ), + ) + observation = replace( + entry.observation, + identity=identity, + result=replace(entry.observation.result, unit=_unit()), + ) + return build_longitudinal_observation_entry( + observation, + entry.football_context, + entry.source_qualification_bindings, + ) + + def _observation_pair() -> tuple[ ScientificMeasurementObservation, ScientificMeasurementObservation ]: @@ -143,3 +180,67 @@ def test_caller_supplied_synthetic_registry_cannot_authorize_comparability() -> assert bridge_key.stable_id not in { item.bridge_reference.stable_id for item in synthetic_registry.entries } + + +def test_typed_claim_context_is_content_adjudicated_for_positive_training_pair() -> None: + left = _complete_context_entry("res70-context-left") + right = _complete_context_entry("res70-context-right") + claim_context = ClaimContext( + context_reference=_reference("context", "training-world", "ignored label"), + context_kind="label-only-kind", + football_world_context=left.football_context, + ) + request = CrossSourceComparabilityRequest( + request_id=InstanceIdentifier("cross-source-comparability-request", "context-positive"), + left_observation=ObservationAuthorityReference.from_observation(left.observation), + right_observation=ObservationAuthorityReference.from_observation(right.observation), + claim_intent=_claim(), + claim_context=claim_context, + ) + + decision = assess_cross_source_comparability( + request, + (left.observation, right.observation), + football_contexts=(left.football_context, right.football_context), + ) + + assert decision.state is ComparabilityState.COMPARABLE + context_findings = { + item.dimension: item.status + for item in decision.dimension_findings + if item.dimension + in { + ComparabilityDimension.FOOTBALL_WORLD_CONTEXT, + ComparabilityDimension.EXPOSURE_CONTEXT_MATCH_OR_TRAINING, + } + } + assert all(status.value == "MATCH" for status in context_findings.values()) + + +def test_typed_claim_context_mismatch_blocks_match_training_comparison() -> None: + training = _complete_context_entry("res70-context-training") + match = _complete_context_entry("res70-context-match", kind="match") + claim_context = ClaimContext( + context_reference=_reference("context", "training-world", "training"), + context_kind="not-authoritative-label", + football_world_context=training.football_context, + ) + request = CrossSourceComparabilityRequest( + request_id=InstanceIdentifier("cross-source-comparability-request", "context-mismatch"), + left_observation=ObservationAuthorityReference.from_observation(training.observation), + right_observation=ObservationAuthorityReference.from_observation(match.observation), + claim_intent=_claim(), + claim_context=claim_context, + ) + + decision = assess_cross_source_comparability( + request, + (training.observation, match.observation), + football_contexts=(training.football_context, match.football_context), + ) + + assert decision.state in { + ComparabilityState.BRIDGE_VALIDATION_REQUIRED, + ComparabilityState.NOT_COMPARABLE, + } + assert "EXPOSURE_CONTEXT_MISMATCH" in decision.reason_codes From 0767aa64add22899e8ad282f87ba418585bdb092 Mon Sep 17 00:00:00 2001 From: Julio Rodriguez <144072916+Litju@users.noreply.github.com> Date: Fri, 18 Sep 2026 23:18:54 +0000 Subject: [PATCH 16/18] docs(res70): refresh review-fix evidence --- .../RES70-REVIEW-FIX-001-RECEIPT.json | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 docs/decisions/RES70-REVIEW-FIX-001-RECEIPT.json diff --git a/docs/decisions/RES70-REVIEW-FIX-001-RECEIPT.json b/docs/decisions/RES70-REVIEW-FIX-001-RECEIPT.json new file mode 100644 index 0000000..2da47f6 --- /dev/null +++ b/docs/decisions/RES70-REVIEW-FIX-001-RECEIPT.json @@ -0,0 +1,74 @@ +{ + "mission": "RES-70-REVIEW-FIX-001", + "project": "DynamisLM", + "linear_issue": "RES-70", + "design_record": "docs/decisions/RES70-DR-001-cross-source-comparability-analysis-claim-authority.md", + "entry_head": "eb0baf4f20d9e780cda5b7ed17259972853a7d34", + "code_head_before_receipt_commit": "ac6b5c2", + "base_main": "83041cd3e69e0dc0947c181b19effd5402bd5a38", + "pr": 33, + "branch": "work/res-70-cross-source-comparability-analysis-claim-authority", + "serialization_version": 3, + "atomic_code_commits": [ + "70dd4f0 fix(res70): bind comparability and bridge execution authority", + "0965ec8 fix(res70): source-bind applicability authority", + "7818991 fix(res70): revalidate upstream claim authority", + "23a02a1 fix(res70): enforce claim hierarchy and capability prerequisites", + "9040ef1 fix(res70): adjudicate exact claim context", + "ac6b5c2 test(res70): qualify authority forgery and prerequisite bypasses" + ], + "authority_seams": { + "comparability_authority_origin": "PASS", + "evidence_applicability_authority": "PASS", + "claim_upstream_revalidation": "PASS", + "bridge_execution_authority": "PASS", + "claim_predecessor_enforcement": "PASS", + "capability_prerequisites_executable": "PASS", + "claim_context_adjudication": "PASS" + }, + "adversarial_guards": { + "caller_minted_comparability": "BLOCKED", + "caller_minted_applicability": "BLOCKED", + "forged_analysis_authority": "BLOCKED", + "forged_bridge_execution": "BLOCKED", + "skipped_claim_predecessor": "BLOCKED", + "unknown_prerequisite_token": "REFUSED", + "res34_69_science_changed": "NO", + "res71_plus_implemented": "NO", + "dependency_expansion": "NO", + "deferred_res69_methods_implemented": "NO" + }, + "local_qa": { + "command": "./scripts/ci.sh", + "ruff": "PASS", + "format": "PASS", + "mypy": "PASS", + "repository_policy": "PASS", + "pytest": "PASS", + "test_count": 838, + "tracked_mutation": "NONE", + "git_diff_check": "PASS" + }, + "codebase_memory": { + "project": "home-litju-projects-DynamisLM", + "indexed_root": "/home/litju/projects/DynamisLM", + "reindexed_at_code_head": "ac6b5c2", + "index_status": "ready", + "changed_symbol_downstream_audit": "PASS", + "unexpected_downstream_impact": "NONE" + }, + "scope": { + "real_empirical_data_added": false, + "model_training_implemented": false, + "gpu_code_added": false, + "parallel_observation_or_provenance_system": false, + "scientific_design_reopened": false + }, + "remote_state_at_receipt_preparation": { + "hosted_ci": "PENDING_NEW_HEAD", + "coderabbit_review": "PENDING_NEW_HEAD", + "unresolved_review_threads": "PENDING_NEW_HEAD" + }, + "linear_status": "IN_PROGRESS", + "next_authorized_action": "RES-70-FINAL-REVIEW-001" +} From 87ae21d70e24230bb8f267f906f0121b5c016f63 Mon Sep 17 00:00:00 2001 From: Julio Rodriguez <144072916+Litju@users.noreply.github.com> Date: Sat, 19 Sep 2026 00:59:13 +0000 Subject: [PATCH 17/18] fix(res70): close declarative bridge review findings --- .../comparability/res70_authority.py | 59 ++++++++++++++- src/dynamislm/comparability/res70_models.py | 5 ++ tests/test_res70_bridges.py | 73 ++++++++++++++++++- 3 files changed, 134 insertions(+), 3 deletions(-) diff --git a/src/dynamislm/comparability/res70_authority.py b/src/dynamislm/comparability/res70_authority.py index e7645ae..d906872 100644 --- a/src/dynamislm/comparability/res70_authority.py +++ b/src/dynamislm/comparability/res70_authority.py @@ -454,6 +454,49 @@ def _bridge_for_request( return candidates[0] if candidates else None +def _bridge_covered_dimensions( + bridge: BridgeRegistration, +) -> frozenset[ComparabilityDimension]: + """Resolve which material dimensions a semantic bridge actually covers.""" + + component_dimensions = { + "semantic.construct": ComparabilityDimension.CONSTRUCT, + "semantic.test_family": ComparabilityDimension.TEST_FAMILY, + "semantic.protocol": ComparabilityDimension.PROTOCOL, + "semantic.measurand": ComparabilityDimension.MEASURAND, + "semantic.metric_definition": ComparabilityDimension.METRIC_DEFINITION, + "acquisition.device": ComparabilityDimension.DEVICE_MEASURING_SYSTEM, + "acquisition.sensor_channel": ComparabilityDimension.DEVICE_MEASURING_SYSTEM, + "acquisition.sampling": ComparabilityDimension.SAMPLING_AND_TIMEBASE, + "acquisition.calibration_reference": ComparabilityDimension.CALIBRATION_REFERENCE, + "acquisition.hardware_firmware": ComparabilityDimension.HARDWARE_FIRMWARE_VERSION, + "processing.event_definitions": ComparabilityDimension.EVENT_DEFINITION, + "processing.phase_definitions": ComparabilityDimension.PHASE_DEFINITION, + "processing.estimator": ComparabilityDimension.ESTIMATOR, + "processing.registered_operation": ComparabilityDimension.REGISTERED_PROCESSING_OPERATION, + "processing.method_parameters": ComparabilityDimension.PROCESSING_PARAMETERS, + "processing.filtering": ComparabilityDimension.FILTERING_SMOOTHING_RESAMPLING, + "processing.differentiation_method": ComparabilityDimension.ESTIMATOR, + "processing.integration_method": ComparabilityDimension.ESTIMATOR, + "processing.unit": ComparabilityDimension.UNIT, + "processing.sign_convention": ComparabilityDimension.SIGN_CONVENTION_AND_REFERENCE_FRAME, + "processing.normalization": ComparabilityDimension.NORMALIZATION, + "processing.trial_selection": ComparabilityDimension.TRIAL_SELECTION_POLICY, + "processing.aggregation": ComparabilityDimension.AGGREGATION_POLICY, + "version.processing_method": ComparabilityDimension.SOFTWARE_ALGORITHM_VERSION, + "version.method_registry_version": ComparabilityDimension.SOFTWARE_ALGORITHM_VERSION, + "version.software_version": ComparabilityDimension.SOFTWARE_ALGORITHM_VERSION, + "version.hardware_firmware": ComparabilityDimension.HARDWARE_FIRMWARE_VERSION, + } + source = bridge.source_semantic_key.as_mapping() + target = bridge.target_semantic_key.as_mapping() + return frozenset( + dimension + for key, dimension in component_dimensions.items() + if source.get(key) != target.get(key) + ) + + def assess_cross_source_comparability( request: CrossSourceComparabilityRequest, observations: Mapping[object, ScientificMeasurementObservation] @@ -636,19 +679,31 @@ def assess_cross_source_comparability( reasons = tuple(dict.fromkeys(("RES70_REGISTRY_INTEGRITY_FAILURE", *reasons))) missing = ("canonical production bridge registry",) elif bridge.bridge_mode is BridgeMode.DECLARATIVE_EQUIVALENCE: - state = ComparabilityState.COMPARABLE_WITH_CONDITIONS bridge_reference = bridge.bridge_reference conditions = tuple( f"{item.key}={item.value}" for item in bridge.applicability_conditions ) evidence = bridge.evidence_references + covered_dimensions = _bridge_covered_dimensions(bridge) + uncovered_mismatches = tuple( + finding for finding in mismatches if finding.dimension not in covered_dimensions + ) for index, finding in enumerate(findings): - if finding.status is DimensionFindingStatus.MISMATCH: + if ( + finding.status is DimensionFindingStatus.MISMATCH + and finding.dimension in covered_dimensions + ): findings = ( *findings[:index], replace(finding, status=DimensionFindingStatus.BRIDGED), *findings[index + 1 :], ) + if uncovered_mismatches: + state = ComparabilityState.BRIDGE_VALIDATION_REQUIRED + conditions = ("registered bridge does not cover every material mismatch",) + reasons = tuple(dict.fromkeys(("RES70_BRIDGE_REQUIRED", *reasons))) + else: + state = ComparabilityState.COMPARABLE_WITH_CONDITIONS elif bridge_applied: state = ComparabilityState.COMPARABLE_WITH_CONDITIONS bridge_reference = bridge.bridge_reference diff --git a/src/dynamislm/comparability/res70_models.py b/src/dynamislm/comparability/res70_models.py index cdef587..99f45be 100644 --- a/src/dynamislm/comparability/res70_models.py +++ b/src/dynamislm/comparability/res70_models.py @@ -637,6 +637,11 @@ def __post_init__(self) -> None: MetadataEntry, "applicability_conditions", ) + if ( + self.bridge_mode is BridgeMode.DECLARATIVE_EQUIVALENCE + and not self.applicability_conditions + ): + raise ValueError("declarative bridges require applicability conditions") _require_instance(self.method_version, RegistryReference, "method_version") _require_reference_tuple(self.evidence_references, "evidence_references") _require_reference_tuple(self.evidence_applicability, "evidence_applicability") diff --git a/tests/test_res70_bridges.py b/tests/test_res70_bridges.py index f5248d8..a31b396 100644 --- a/tests/test_res70_bridges.py +++ b/tests/test_res70_bridges.py @@ -11,6 +11,7 @@ BridgeInvertibility, BridgeMode, BridgeRegistration, + ComparabilityDimension, CrossSourceComparabilityRequest, InstanceIdentifier, MetadataEntry, @@ -165,7 +166,7 @@ def test_executed_bridge_supports_conditional_pairwise_comparability() -> None: def test_declared_bridge_without_execution_never_supports_transformed_values() -> None: - source, target, bridge, registry, claim = _bridge_fixture() + source, target, _bridge, registry, claim = _bridge_fixture() original_registry = res70_registry.CANONICAL_BRIDGE_REGISTRY res70_registry.CANONICAL_BRIDGE_REGISTRY = registry try: @@ -184,6 +185,76 @@ def test_declared_bridge_without_execution_never_supports_transformed_values() - assert decision.transformations_required +def test_declarative_bridge_preserves_uncovered_material_mismatches() -> None: + source, target, bridge, _registry, claim = _bridge_fixture() + declarative = replace( + bridge, + bridge_mode=BridgeMode.DECLARATIVE_EQUIVALENCE, + transformation_operation=None, + fixed_parameters=(), + bridge_hash=None, + ) + registry = BridgeRegistry(entries=(declarative,)) + original_registry = res70_registry.CANONICAL_BRIDGE_REGISTRY + res70_registry.CANONICAL_BRIDGE_REGISTRY = registry + try: + request = CrossSourceComparabilityRequest( + request_id=InstanceIdentifier( + "cross-source-comparability-request", "res70-declarative" + ), + left_observation=ObservationAuthorityReference.from_observation(source), + right_observation=ObservationAuthorityReference.from_observation(target), + claim_intent=claim, + ) + covered = assess_cross_source_comparability(request, (source, target)) + changed_target = replace( + target, + context=replace( + target.context, + environment=(MetadataEntry("surface", "outdoor"),), + ), + ) + uncovered_request = replace( + request, + request_id=InstanceIdentifier( + "cross-source-comparability-request", "res70-declarative-uncovered" + ), + right_observation=ObservationAuthorityReference.from_observation(changed_target), + ) + uncovered = assess_cross_source_comparability( + uncovered_request, + (source, changed_target), + ) + finally: + res70_registry.CANONICAL_BRIDGE_REGISTRY = original_registry + + assert covered.state.value == "COMPARABLE_WITH_CONDITIONS" + assert any( + item.dimension is ComparabilityDimension.DEVICE_MEASURING_SYSTEM + and item.status.value == "BRIDGED" + for item in covered.dimension_findings + ) + assert uncovered.state.value == "BRIDGE_VALIDATION_REQUIRED" + assert any( + item.dimension is ComparabilityDimension.ACQUISITION_CONTEXT + and item.status.value == "MISMATCH" + for item in uncovered.dimension_findings + ) + + +def test_declarative_bridge_requires_applicability_conditions() -> None: + _source, _target, bridge, _registry, _claim = _bridge_fixture() + + with pytest.raises(ValueError, match="applicability conditions"): + replace( + bridge, + bridge_mode=BridgeMode.DECLARATIVE_EQUIVALENCE, + transformation_operation=None, + applicability_conditions=(), + bridge_hash=None, + ) + + def test_forged_bridge_execution_hash_and_registration_are_rejected() -> None: source, target, bridge, registry, claim = _bridge_fixture() original_registry = res70_registry.CANONICAL_BRIDGE_REGISTRY From 6fe05252bc6dc8babcb7ae3927400fb0d6e6305a Mon Sep 17 00:00:00 2001 From: Julio Rodriguez <144072916+Litju@users.noreply.github.com> Date: Sat, 19 Sep 2026 01:00:18 +0000 Subject: [PATCH 18/18] docs(res70): record review follow-up qualification --- .../RES70-REVIEW-FIX-002-RECEIPT.json | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 docs/decisions/RES70-REVIEW-FIX-002-RECEIPT.json diff --git a/docs/decisions/RES70-REVIEW-FIX-002-RECEIPT.json b/docs/decisions/RES70-REVIEW-FIX-002-RECEIPT.json new file mode 100644 index 0000000..e036010 --- /dev/null +++ b/docs/decisions/RES70-REVIEW-FIX-002-RECEIPT.json @@ -0,0 +1,37 @@ +{ + "mission": "RES-70-REVIEW-FIX-002", + "project": "DynamisLM", + "linear_issue": "RES-70", + "pull_request": 33, + "base_main": "83041cd3e69e0dc0947c181b19effd5402bd5a38", + "prior_head": "0767aa64add22899e8ad282f87ba418585bdb092", + "code_head": "87ae21d", + "follow_ups_closed": { + "declarative_bridge_uncovered_mismatches": "PASS", + "empty_declarative_applicability_conditions": "PASS", + "unused_bridge_test_binding": "PASS" + }, + "local_qa": { + "command": "./scripts/ci.sh", + "ruff": "PASS", + "format": "PASS", + "mypy": "PASS", + "repository_policy": "PASS", + "pytest": "PASS", + "test_count": 840, + "tracked_mutation": "NONE" + }, + "codebase_memory": { + "project": "home-litju-projects-DynamisLM", + "indexed_root": "/home/litju/projects/DynamisLM", + "reindexed_at_code_head": "87ae21d", + "changed_symbol_downstream_audit": "PASS" + }, + "scope": { + "res34_69_science_changed": "NO", + "res71_plus_implemented": "NO", + "dependency_expansion": "NO", + "serialization_version": 3 + }, + "next_authorized_action": "RES-70-FINAL-REVIEW-001" +}