diff --git a/docs/decisions/RES69-DR-001-longitudinal-reliability-uncertainty.md b/docs/decisions/RES69-DR-001-longitudinal-reliability-uncertainty.md new file mode 100644 index 0000000..e1e5684 --- /dev/null +++ b/docs/decisions/RES69-DR-001-longitudinal-reliability-uncertainty.md @@ -0,0 +1,348 @@ +# RES-69 DR-001: Longitudinal reliability, uncertainty, and within-athlete statistics + +Status: `FROZEN / IMPLEMENTED V1` + +Mission: `RES-69-IMPLEMENTATION-001` + +## Scope and authority + +RES-69 adds a deterministic statistical layer beneath the existing +RES-62 longitudinal authority. It does not replace or copy +`LongitudinalObservationEntry`, `MultiSourceAnalysisInput`, +`MultiSourceProcessingRun`, or `MultiSourceProvenanceGraph`. Because one +RES-62 input is intentionally scoped to one athlete, a multi-subject +reliability study is represented by an immutable tuple of existing RES-62 +inputs, one per subject. + +All accepted numerical estimates are computed by registered standard-library +Python operations. The language-model layer cannot supply formulas, +thresholds, units, scale flags, denominators, comparability verdicts, or +numerical results. + +RES-60 population/source qualification, RES-61 football context, RES-62 +record/input/lineage, RES-63 ingestion, and RES-64 through RES-68 family +authorities remain unchanged and are consumed as existing evidence. + +## Level-of-analysis distinctions + +The implementation keeps these quantities distinct: + +* a numerical longitudinal change is not an interpretation of improvement, + decline, fatigue, readiness, adaptation, or meaningfulness; +* descriptive within-athlete sample SD is not repeatability random-error SD; +* a replicate mean shift is not random error and is not labelled learning or + fatigue; +* a method-comparison bias/SD summary is not interchangeability or agreement + acceptability; +* a between-subject tuple of pairs is not a within-athlete longitudinal + association or causal effect. + +## Implemented V1 operations + +The registered operations are: + +1. absolute change, `follow-up - baseline`, for exactly two observations; +2. arithmetic relative and percent change from one ratio-change estimand; +3. natural log ratio for registered strictly-positive ratio-scale support; +4. explicit count-window and time-window mean, median, sample SD, minimum, + maximum, and range; +5. current-minus-prior-reference-mean divided by prior reference sample SD; +6. centered timestamp OLS intercept and slope; +7. descriptive within-athlete sample SD; +8. two-replicate mean trial shift, SD of differences, and random-error SD; +9. two-replicate pooled raw relative error percent; and +10. simple B-minus-A method-comparison bias and SD of differences. + +The scale-dependent operations in items 2, 3, and 9 (and the log-scale +reliability operation) remain registered deterministic paths, but the +current production scale registry has zero keys. They therefore refuse for +real production data until an owning scientific registry supplies an +authorized production entry. + +The reliability operations have an additional authority gate. A numerical +implementation does not itself establish that a repeated protocol measures a +stable underlying quantity, which error scale is scientifically applicable, or +how a systematic trial effect should be assessed. Public production +reliability claims therefore require a registered reliability-assumption +declaration in addition to exact source support. + +Every numerical output is a `StatisticalEstimate` with its own typed unit. +The `StatisticalResult` contains the immutable support snapshot and an +immutable `StatisticalAnalysisRun` with RES-62 processing runs and provenance +graphs. + +## Equations and minimum support + +For two observations: + +```text +change = follow-up - baseline +relative_change = (follow-up - baseline) / baseline +percent_change = 100 * relative_change +log_ratio = ln(follow-up / baseline) +``` + +Relative and percent outputs share the same registered arithmetic ratio-change +run and support. A displayed exponential transformation of a log ratio is +not treated as a second percent-change estimand. + +For a window of `n` scalar observations: + +```text +mean = sum(x) / n +sample_SD = sqrt(sum((x - mean)^2) / (n - 1)) +range = maximum - minimum +``` + +Mean, median, minimum, maximum, and range require `n >= 1`; sample SD +requires `n >= 2`. For `n = 1`, range is explicitly represented as zero and +sample SD is explicitly represented as non-computable in the result envelope. +Window selection is never implicit: all omitted RES-62 entries have an +explicit exclusion reason and status. + +For a current value and an explicitly prior reference window: + +```text +z_reference = (current - reference_mean) / reference_sample_SD +``` + +The current observation is not part of the reference statistics. The reference +window has at least two observations and positive sample SD. + +For exact timestamps, let `x_i` be elapsed seconds from the earliest included +timestamp: + +```text +beta_1 = sum((x_i - xbar) * (y_i - ybar)) / sum((x_i - xbar)^2) +beta_0 = ybar - beta_1 * xbar +``` + +The intercept has the dependent metric unit. The slope has a deterministic +typed numerator-per-second unit. No observation-index time substitution, +p-value, confidence interval, causal trend, or adaptation-rate claim is +implemented. + +For `N` complete, ordered two-replicate subject pairs: + +```text +d_i = y_i2 - y_i1 +mean_trial_shift = mean(d_i) +sd_difference = sample_SD(d_i) +random_error_sd = sd_difference / sqrt(2) +``` + +`TWO_REPLICATE_WITHIN_SUBJECT_RANDOM_ERROR_SD_V1` is the one canonical V1 +estimator. The public typical-error name is an alias to this operation; it is +not a second numerical authority. `N >= 2` is required for random-error SD. + +For the RAW_RELATIVE design only, the denominator is derived from the exact +same pair support: + +```text +raw_reference_mean = sum_i(y_i1 + y_i2) / (2N) +raw_relative_error_percent = 100 * random_error_sd / raw_reference_mean +``` + +The denominator identity is +`TWO_REPLICATE_POOLED_RAW_GRAND_MEAN_V1`. It cannot be supplied by a caller, +and unbalanced support is refused. This is not a generic coefficient of +variation. + +For a LOG_MULTIPLICATIVE design: + +```text +log_d_i = ln(y_i2) - ln(y_i1) +mean_log_shift = mean(log_d_i) +sd_log_difference = sample_SD(log_d_i) +te_log = sd_log_difference / sqrt(2) +factor = exp(te_log) +lower_factor = exp(-te_log) +upper_factor = exp(te_log) +``` + +If percentage bounds are exposed, they are separately derived: + +```text +lower_percent = 100 * (1 - exp(-te_log)) +upper_percent = 100 * (exp(te_log) - 1) +``` + +The canonical factor interval is preserved. The implementation never emits a +naive symmetric `+/- x%` representation. + +## Exact-unit rule + +`UnitReference` remains identity-only. Direct scalar arithmetic requires exact +equality of the common `UnitReference`. Compatible physical dimensions do not +prove that a conversion was executed. Metres and centimetres, or m/s and +km/h, are refused unless a future registered conversion operation supplies the +conversion and provenance. Each derived estimate owns its own unit: source +units for changes/errors/BA, registered dimensionless and percent units for +ratios, a registered log-ratio representation for log ratios, and a typed +derived unit for OLS slope. + +## Scale-semantic authority + +`MeasurementScaleSemanticKeyV1` is derived from stable construct, test-family, +measurand, metric-definition, unit, normalization, registered operation, +estimator, optional protocol constraint, and optional explicitly registered +scale-relevant parameters. Observation ID, result ID, athlete, timestamp, +session, source artifact, acquisition instance, and processing-run instance +are excluded. + +Lookup is `MeasurementIdentity -> semantic key -> exactly one registered +MeasurementScaleSemantics`. Caller flags such as `ratio_scale=True`, +`positive=True`, `meaningful_zero=True`, or `log_allowed=True` never confer +authority. Missing or conflicting registry entries fail closed. + +The machine-readable scale audit is exposed as `SCALE_REGISTRY_AUDIT`, +`REGISTERED_SCALE_KEYS`, `UNREGISTERED_SCALE_KEYS`, and +`WHY_EACH_REGISTERED_KEY_IS_AUTHORIZED` in +`dynamislm.longitudinal.statistics.registry`. The V1 production registry +contains zero production entries. This is deliberate: the RES-34..68 +family registries own the scientific metric identities, while no RES-69 +scale decision was authorized to import those family-specific identities into +the generic package. The complete audit is therefore an explicit set of +unregistered public scalar-metric categories in `UNREGISTERED_SCALE_KEYS`, +and `WHY_EACH_REGISTERED_KEY_IS_AUTHORIZED` is empty. Synthetic tests may add +one exact `MeasurementScaleSemantics` entry only through the +`SYNTHETIC_TEST` origin and a new immutable registry value. The exact +RES-34..68 metric-ID enumeration is recorded in +`docs/decisions/RES69-SCALE-REGISTRY-AUDIT.md`. Public calculators resolve +only the canonical production registry; a caller-supplied synthetic registry +or synthetic entry can never authorize a `StatisticalResult`. + +## Reliability-design authority + +`ReliabilityAssumptionDeclarationV1` is the scientific declaration owned by a +registered study/protocol authority. It binds the declaration reference, +study and protocol identities, stable-underlying-quantity status, systematic +trial-effect assessment/policy, exact error-scale identity, evidence +references, producing authority, registry version, production or synthetic +origin, and an immutable declaration hash. The canonical +`RES69_RELIABILITY_ASSUMPTION_DECLARATION_REGISTRY` is the only production +resolution path. + +`ReliabilityAssumptionSourceEvidence` carries exact support/source bindings +and a declaration reference/hash; it no longer carries free scientific +assumption claims. The registered `ReliabilityAssumptionAssessment` derives +stable-quantity status, systematic-trial-effect assessment, and error scale +from the resolved declaration, then binds them to the exact support ID/hash, +analysis-input IDs/hashes, source records, source artifacts/provenance, +protocol, evidence references, producing method, registry version, source +evidence hash, declaration hash, and authority token. Caller-supplied +assumption claims and caller-supplied declaration registries cannot mint +authority. Synthetic declarations are test-only and cannot authorize a +public `StatisticalResult`. + +The current audit finds no RES-34..68 or RES-62 authority that establishes a +complete production reliability-assumption declaration: +`PRODUCTION_RELIABILITY_ASSUMPTION_DECLARATIONS=0`. Consequently, public +reliability claims that require an assumption declaration fail closed until +an owning scientific study/protocol authority provides one. Pure internal +arithmetic helpers remain available for gold-equation tests and do not grant +production claim authority. + +`ReliabilityDesignEvidence` accepts only a source-bound assessment; free enum +fields cannot mint design authority. Only the validated design normalizer +produces a `ReliabilityDesignAuthority` with `AuthorityStatus.SOURCE_BOUND`; +direct construction or an unverified/synthetic assessment is refused by +public calculations. + +The normalized authority binds the study/design identity, exact athletes, +ordered replicate references, RES-62 entry and observation IDs/hashes, source +record IDs/hashes, session/occasion/trial mapping, target identity and +measurand, method/device/rater references, protocol/evidence references, +repeatability question, stable-underlying-quantity assessment, systematic +trial-effect assessment, error-scale assessment, missingness policy, +balanced-design status, source artifacts, source provenance, producing method, +registry version, source-evidence hash, and authority hash. + +## Method-comparison authority and BA boundary + +`MethodComparisonDesignEvidence` is normalized only after its exact subject +pairings, stable `MethodComparisonMethodKeyV1` method keys, target +construct/measurand, metrics, units, +occasion policy, `B_MINUS_A` sign convention, no-hidden-transformation state, +one-pair-per-subject policy, source lineage, evidence, producing method, and +registry version match the actual support. Direct construction of +`MethodComparisonDesignAuthority` is unverified and cannot authorize BA. + +The stable method key uses material semantic, processing, version, unit, and +acquisition dimensions while excluding observation-instance, result, +artifact, acquisition-instance, processing-run, athlete, session, and +timestamp identities. The producing method must equal the registered +`RES69_METHOD_COMPARISON_DESIGN_OPERATION`. + +V1 emits only: + +```text +difference_i = B_i - A_i +bias = mean(difference_i) +sd_difference = sample_SD(difference_i) +``` + +Methods/devices may differ when the source-bound method-comparison authority +proves the same target construct and measurand. Exact common units remain +mandatory. Classical lower/upper limits of agreement are registered as +`REPRESENT_BUT_DO_NOT_COMPUTE`; a request returns a structured refusal. +Coverage, acceptable agreement, interchangeability, substitute validity, and +equivalence are not numerically or semantically inferred. + +## Represented, deferred, and rejected work + +The operation registry represents but does not calculate: + +* SEM from a registered ICC; +* MDC/SDC; +* ICC variants; and +* classical BA limits. + +It defers log BA, repeated-measures BA, confidence intervals, covariance +propagation, repeated-measures correlation, and mixed-effects models. Generic +SEM, generic meaningful change, and readiness/fatigue/injury interpretation +are rejected. These dispositions are structured registry outcomes, not +placeholder arithmetic. + +## Missingness and provenance + +Only exact selected entries are included. Missing, invalid, or excluded +observations are not imputed, zero-filled, or silently dropped. Support stores +the full RES-62 input IDs/hashes, selected entries, explicit exclusions and +reasons/statuses, source records, time-window identity, comparability +evidence, and support hash. + +Every result stores its support snapshot and a new analysis-run identity. The +run stores operation and estimator identities, exact calculation-changing +parameters, software version, registry version, RES-62 processing runs, +complete source provenance graphs, evidence references, scale identity where +applicable, design-authority hashes, and output hash. A changed method, +parameter set, support, registry version, or software version therefore +creates a new immutable run/result identity. + +## Dependencies, limitations, and non-goals + +No production dependency was added. All arithmetic uses Python standard +library `math`, `math.fsum`, and `statistics`; Serialization V3 remains +version `3`. Real empirical bytes and canonical rows remain outside Git. + +V1 does not provide a cross-source claim-authority ladder, bridge registry, +LM-selected analysis validation, causal authority, readiness/fatigue +classification, injury prediction, model training, GPU code, or any RES-70+ +work. Reliability and BA results are descriptive statistical outputs bounded +by their source/protocol authorities and do not establish practical meaning. + +## Adversarial guarantees and implementation evidence + +`tests/test_longitudinal_statistics.py` covers gold fixtures and negative +cases for temporal order, identity, exact units, unregistered scale semantics, +caller flags, zero/nonpositive domains, explicit windows, reference leakage, +duplicate timestamps, source-bound reliability and method-comparison +authorities, incomplete/unbalanced pairs, derived raw denominators, +log-factor asymmetry, synthetic scale-authority refusal, source-bound +assumption binding/tampering, stable method-key resolution across instance +IDs, BA LoA refusal, serialization round-trips, support and authority +tampering, and V3 hash stability. + +The existing `tests/test_longitudinal.py` remains authoritative for RES-62 and +passes without semantic mutation. diff --git a/docs/decisions/RES69-RECEIPT.json b/docs/decisions/RES69-RECEIPT.json new file mode 100644 index 0000000..f73b4ea --- /dev/null +++ b/docs/decisions/RES69-RECEIPT.json @@ -0,0 +1,114 @@ +{ + "mission": "RES-69-REVIEW-FIX-002", + "project": "DynamisLM", + "linear_issue": "RES-69", + "base_main": "e17c415dbf1e181a9dc5dc02b7f16d69d71d18b0", + "entry_head": "757593d84e3ad1ff39b0d8db1eea3e647904900e", + "branch": "work/res-69-longitudinal-reliability-uncertainty", + "design_status": "FROZEN", + "reliability_assumption_authority": { + "caller_assumption_claims_authoritative": false, + "canonical_registry": "RES69_RELIABILITY_ASSUMPTION_DECLARATION_REGISTRY", + "caller_minted_registry_authority": false, + "synthetic_declaration_public_authority": false, + "production_declarations": 0, + "no_production_declaration_fails_closed": true + }, + "implemented_surface": [ + "RES-62-backed StatisticalSupport and StatisticalAnalysisRun", + "MeasurementScaleSemanticKeyV1 and fail-closed scale registry", + "canonical multi-input ordering by athlete ID and input ID", + "absolute, relative, percent, log-ratio, and window descriptives", + "reference-window deviation, timestamp OLS, and descriptive within-athlete SD", + "source-bound reliability assumption assessment and two-replicate random-error estimator", + "registered reliability-assumption declaration contract with canonical production resolution", + "caller assumption claims, caller registries, and synthetic declarations blocked from production authority", + "duplicate exact analysis-input IDs rejected without silent deduplication", + "raw relative error with derived pooled 2N grand mean", + "log-multiplicative typical error and factor interval", + "stable method-key method-comparison authority and narrow Bland-Altman summary", + "synthetic scale authority blocked from public production results", + "production reliability-assumption declaration count is zero; public claims fail closed", + "V3 serialization and tamper validation for new public contracts" + ], + "deferred_surface": [ + "SEM from registered ICC", + "MDC/SDC", + "ICC variants", + "classical Bland-Altman limits", + "log/repeated-measures Bland-Altman", + "confidence intervals", + "covariance propagation", + "repeated-measures correlation", + "mixed-effects models" + ], + "rejected_surface": [ + "generic SEM", + "generic meaningful change", + "readiness/fatigue/injury interpretation" + ], + "changed_files": [ + "docs/decisions/RES69-DR-001-longitudinal-reliability-uncertainty.md", + "docs/decisions/RES69-RECEIPT.json", + "docs/decisions/RES69-SCALE-REGISTRY-AUDIT.md", + "src/dynamislm/__init__.py", + "src/dynamislm/longitudinal/__init__.py", + "src/dynamislm/longitudinal/statistics/__init__.py", + "src/dynamislm/longitudinal/statistics/agreement.py", + "src/dynamislm/longitudinal/statistics/descriptive.py", + "src/dynamislm/longitudinal/statistics/models.py", + "src/dynamislm/longitudinal/statistics/registry.py", + "src/dynamislm/longitudinal/statistics/reliability.py", + "src/dynamislm/longitudinal/statistics/support.py", + "src/dynamislm/longitudinal/statistics/validation.py", + "tests/test_longitudinal_statistics.py" + ], + "local_qa": { + "focused_longitudinal": "PASS", + "focused_statistics": "PASS", + "full_command": "./scripts/ci.sh", + "full_status": "PASS", + "ruff": "PASS", + "format": "PASS", + "mypy": "PASS", + "repository_policy": "PASS", + "pytest": "PASS", + "test_count": 797, + "tracked_mutation": "NONE" + }, + "serialization_version": 3, + "dependency_status": { + "expansion_required": false, + "pyproject_modified": false, + "lockfile_modified": false, + "proposed_dependencies": [] + }, + "historical_authority_mutation_audit": { + "res34_68_changed": false, + "res62_reused": true, + "res62_semantics_changed": false, + "serialization_migrated": false, + "parallel_longitudinal_system": false + }, + "codebase_memory_audit": { + "used": true, + "indexed_root": "/home/litju/projects/DynamisLM", + "initial_map": "RES-62 longitudinal models, lineage, registry, validation, generic measurement identity/result, comparability authority, refusal taxonomy, provenance, population/source qualification, ingestion, V3 serialization, exports, tests, and policy were mapped before editing; downstream callers of the reliability source/assessment/design builders were traced before the signature repair.", + "changed_symbol_audit": "PASS", + "unexpected_downstream_impact": "NONE" + }, + "pr_state": { + "created": false, + "number": 32, + "head": "PENDING_EXACT_REPAIR_HEAD", + "hosted_ci": "PENDING_EXACT_REPAIR_HEAD", + "review_findings": "PENDING_EXACT_REPAIR_HEAD", + "linear_status": "IN_REVIEW" + }, + "commit_state": { + "commit_created": false, + "implementation_commit": null, + "implementation_tree": null, + "pushed_existing_branch": false + } +} diff --git a/docs/decisions/RES69-SCALE-REGISTRY-AUDIT.md b/docs/decisions/RES69-SCALE-REGISTRY-AUDIT.md new file mode 100644 index 0000000..f014520 --- /dev/null +++ b/docs/decisions/RES69-SCALE-REGISTRY-AUDIT.md @@ -0,0 +1,126 @@ +# RES-69 V1 scale-registry audit + +This is the deterministic audit required by RES-69. It is a report of the +public scalar metric identities already present in the RES-34..68 registries; +it is not a new scale authority for any of them. + +## Registered scale keys + +`REGISTERED_SCALE_KEYS = ()` + +No family-specific scale entry is imported into the generic RES-69 package. +There is consequently no production scale authorization rationale in V1. +`PRODUCTION_SCALE_KEYS=0`, and every public scale-dependent calculation fails +closed for production observations. A caller-supplied registry is not a +production authority. + +## Unregistered public scalar metric keys + +Every key below is intentionally unregistered and fails closed for +scale-dependent operations. The entries remain understood by their owning +scientific registries. + +```text +dynamislm:metric:30-15-ift-stage-completion@1.0.0 +dynamislm:metric:30-15-ift-stage-velocity@1.0.0 +dynamislm:metric:30-15-ift-termination@1.0.0 +dynamislm:metric:505-asymmetry@1.0.0 +dynamislm:metric:505-cod-deficit@1.0.0 +dynamislm:metric:acceleration-event-count@1.0.0 +dynamislm:metric:bench-press-throw-dynamislm-time-weighted-mean-bar-velocity@1.0.0 +dynamislm:metric:bench-press-throw-provider-maximum-velocity@1.0.0 +dynamislm:metric:bench-press-throw-provider-mean-power@1.0.0 +dynamislm:metric:bench-press-throw-provider-mean-propulsive-velocity@1.0.0 +dynamislm:metric:bench-press-throw-provider-mean-velocity@1.0.0 +dynamislm:metric:bench-press-throw-provider-peak-power@1.0.0 +dynamislm:metric:bench-press-throw-sampled-maximum-bar-velocity@1.0.0 +dynamislm:metric:bench-press-throw-source-qualification@1.0.0 +dynamislm:metric:bench-press-throw-velocity-series@1.0.0 +dynamislm:metric:body-mass@1.0.0 +dynamislm:metric:change-of-direction-left-event-count@1.0.0 +dynamislm:metric:change-of-direction-right-event-count@1.0.0 +dynamislm:metric:cumulative-split-time@1.0.0 +dynamislm:metric:deceleration-event-count@1.0.0 +dynamislm:metric:delivered-velocity-series@1.0.0 +dynamislm:metric:drop-jump-flight-time-jump-height@1.0.0 +dynamislm:metric:drop-jump-ground-contact-time@1.0.0 +dynamislm:metric:drop-jump-rebound-flight-time@1.0.0 +dynamislm:metric:drop-jump-rsi-jh-ct@1.0.0 +dynamislm:metric:drop-jump-rsr-ft-ct@1.0.0 +dynamislm:metric:drop-jump-source-qualification@1.0.0 +dynamislm:metric:drop-jump-source-series@1.0.0 +dynamislm:metric:estimated-one-repetition-maximum@1.0.0 +dynamislm:metric:explosive-effort-event-count@1.0.0 +dynamislm:metric:field-test-source-qualification@1.0.0 +dynamislm:metric:imtp-baseline-force-mean@1.0.0 +dynamislm:metric:imtp-force-at-100-ms@1.0.0 +dynamislm:metric:imtp-force-at-150-ms@1.0.0 +dynamislm:metric:imtp-force-at-200-ms@1.0.0 +dynamislm:metric:imtp-force-at-50-ms@1.0.0 +dynamislm:metric:imtp-force-per-body-mass@1.0.0 +dynamislm:metric:imtp-force-time-series@1.0.0 +dynamislm:metric:imtp-gross-endpoint-average-rfd@1.0.0 +dynamislm:metric:imtp-gross-force-at-registered-time@1.0.0 +dynamislm:metric:imtp-gross-force-impulse@1.0.0 +dynamislm:metric:imtp-gross-sampled-peak-force@1.0.0 +dynamislm:metric:imtp-impulse-0-100-ms@1.0.0 +dynamislm:metric:imtp-impulse-0-150-ms@1.0.0 +dynamislm:metric:imtp-impulse-0-200-ms@1.0.0 +dynamislm:metric:imtp-impulse-0-50-ms@1.0.0 +dynamislm:metric:imtp-net-endpoint-average-rfd-above-baseline@1.0.0 +dynamislm:metric:imtp-net-force-at-registered-time@1.0.0 +dynamislm:metric:imtp-net-force-impulse-above-baseline@1.0.0 +dynamislm:metric:imtp-net-sampled-peak-force-above-baseline@1.0.0 +dynamislm:metric:imtp-rfd-0-100-ms@1.0.0 +dynamislm:metric:imtp-rfd-0-150-ms@1.0.0 +dynamislm:metric:imtp-rfd-0-200-ms@1.0.0 +dynamislm:metric:imtp-rfd-0-50-ms@1.0.0 +dynamislm:metric:imtp-trial-qualification@1.0.0 +dynamislm:metric:individual-linear-load-velocity@1.0.0 +dynamislm:metric:interval-split-time@1.0.0 +dynamislm:metric:jump-event-count@1.0.0 +dynamislm:metric:maximum-speed@1.0.0 +dynamislm:metric:maximum-sprint-velocity@1.0.0 +dynamislm:metric:mean-concentric-velocity@1.0.0 +dynamislm:metric:mean-propulsive-velocity@1.0.0 +dynamislm:metric:measured-one-repetition-maximum@1.0.0 +dynamislm:metric:medicine-ball-instrumented-release-velocity@1.0.0 +dynamislm:metric:medicine-ball-throw-distance@1.0.0 +dynamislm:metric:medicine-ball-throw-source-qualification@1.0.0 +dynamislm:metric:minutes-exposure@1.0.0 +dynamislm:metric:peak-velocity@1.0.0 +dynamislm:metric:provider-load@1.0.0 +dynamislm:metric:relative-distance@1.0.0 +dynamislm:metric:repeated-high-intensity-effort@1.0.0 +dynamislm:metric:rhie-bout-count@1.0.0 +dynamislm:metric:rhie-efforts-per-bout@1.0.0 +dynamislm:metric:rhie-recovery-time@1.0.0 +dynamislm:metric:rsa-best-time@1.0.0 +dynamislm:metric:rsa-mean-time@1.0.0 +dynamislm:metric:rsa-percent-decrement@1.0.0 +dynamislm:metric:rsa-sprint-time@1.0.0 +dynamislm:metric:rsa-total-time@1.0.0 +dynamislm:metric:segment-average-velocity@1.0.0 +dynamislm:metric:session-duration@1.0.0 +dynamislm:metric:sprint-distance@1.0.0 +dynamislm:metric:sprint-event-count@1.0.0 +dynamislm:metric:sprint-time@1.0.0 +dynamislm:metric:sprint-velocity-series@1.0.0 +dynamislm:metric:standard-505-time@1.0.0 +dynamislm:metric:threshold-distance@1.0.0 +dynamislm:metric:threshold-event-count@1.0.0 +dynamislm:metric:threshold-time@1.0.0 +dynamislm:metric:total-distance@1.0.0 +dynamislm:metric:vift@1.0.0 +dynamislm:metric:within-set-velocity-loss@1.0.0 +``` + +The generic Python audit reports the same decision by category through +`UNREGISTERED_SCALE_KEYS` and `SCALE_REGISTRY_AUDIT`; this Markdown report +keeps the family-specific stable IDs outside the generic public package, whose +repository policy prohibits test-family coupling. + +Synthetic `MeasurementScaleSemantics` entries remain available only as +explicit test fixtures. `SYNTHETIC_TEST` authority is rejected by public +relative change, log-ratio, raw-relative-error, and log-error calculators; +synthetic entries never authorize a production `StatisticalResult`. diff --git a/src/dynamislm/__init__.py b/src/dynamislm/__init__.py index 8a0b7f2..66c078e 100644 --- a/src/dynamislm/__init__.py +++ b/src/dynamislm/__init__.py @@ -326,6 +326,7 @@ validate_provenance_graph, validate_source_artifact_qualification_binding, ) +from dynamislm.longitudinal import statistics as _longitudinal_statistics from dynamislm.measurement import ( AcquisitionIdentity, CategoricalValue, @@ -838,3 +839,7 @@ "summarize_velocity_threshold", "threshold_summary_from_velocity_series", ] + +for _statistics_name in _longitudinal_statistics.__all__: + globals().setdefault(_statistics_name, getattr(_longitudinal_statistics, _statistics_name)) +__all__ += [name for name in _longitudinal_statistics.__all__ if name not in __all__] diff --git a/src/dynamislm/longitudinal/__init__.py b/src/dynamislm/longitudinal/__init__.py index bda665a..e3083cc 100644 --- a/src/dynamislm/longitudinal/__init__.py +++ b/src/dynamislm/longitudinal/__init__.py @@ -115,3 +115,10 @@ "validate_provenance_graph", "validate_source_artifact_qualification_binding", ] + +# RES-69 is an additive statistical layer over the immutable RES-62 objects. +from dynamislm.longitudinal import statistics as _statistics + +for _statistics_name in _statistics.__all__: + globals()[_statistics_name] = getattr(_statistics, _statistics_name) +__all__ += [name for name in _statistics.__all__ if name not in __all__] diff --git a/src/dynamislm/longitudinal/statistics/__init__.py b/src/dynamislm/longitudinal/statistics/__init__.py new file mode 100644 index 0000000..755d9c2 --- /dev/null +++ b/src/dynamislm/longitudinal/statistics/__init__.py @@ -0,0 +1,32 @@ +"""RES-69 deterministic longitudinal statistics authority.""" + +from dynamislm.longitudinal.statistics import agreement as _agreement +from dynamislm.longitudinal.statistics import descriptive as _descriptive +from dynamislm.longitudinal.statistics import models as _models +from dynamislm.longitudinal.statistics import registry as _registry +from dynamislm.longitudinal.statistics import reliability as _reliability +from dynamislm.longitudinal.statistics import support as _support +from dynamislm.longitudinal.statistics import validation as _validation +from dynamislm.longitudinal.statistics.agreement import * # noqa: F403 +from dynamislm.longitudinal.statistics.descriptive import * # noqa: F403 +from dynamislm.longitudinal.statistics.models import * # noqa: F403 +from dynamislm.longitudinal.statistics.registry import * # noqa: F403 +from dynamislm.longitudinal.statistics.reliability import * # noqa: F403 +from dynamislm.longitudinal.statistics.support import * # noqa: F403 +from dynamislm.longitudinal.statistics.validation import * # noqa: F403 + +__all__ = [ + *_models.__all__, + *_registry.__all__, + *_support.__all__, + *_descriptive.__all__, + *_reliability.__all__, + *_agreement.__all__, + *_validation.__all__, +] +for _module in (_models, _registry, _support, _descriptive, _reliability, _agreement, _validation): + for _name in _module.__all__: + if _name == "StatisticalConstraintError": + continue + globals()[_name] = getattr(_module, _name) +__all__ = [name for name in dict.fromkeys(__all__) if name != "StatisticalConstraintError"] diff --git a/src/dynamislm/longitudinal/statistics/agreement.py b/src/dynamislm/longitudinal/statistics/agreement.py new file mode 100644 index 0000000..63df7e2 --- /dev/null +++ b/src/dynamislm/longitudinal/statistics/agreement.py @@ -0,0 +1,561 @@ +"""Narrow source-bound method comparison and Bland-Altman V1 authority.""" + +from __future__ import annotations + +import math +from dataclasses import replace + +from dynamislm.longitudinal.models import ( + LongitudinalAthletePerformanceRecord, + LongitudinalObservationEntry, +) +from dynamislm.longitudinal.statistics.models import ( + AuthorityStatus, + MethodComparisonDesignAuthority, + MethodComparisonDesignEvidence, + MethodComparisonMethodKeyV1, + MethodComparisonPair, + RepeatedPairPolicy, + RES69ReasonCode, + StatisticalNonComputable, + StatisticalObservationReference, + StatisticalResult, + StatisticalSourceRecordReference, + StatisticalSupport, +) +from dynamislm.longitudinal.statistics.registry import ( + RES69_B_MINUS_A_SIGN_CONVENTION, + RES69_BA_BIAS_ESTIMAND, + RES69_BA_ESTIMATOR, + RES69_BA_SD_DIFFERENCE_ESTIMAND, + RES69_METHOD_COMPARISON_DESIGN_OPERATION, + RES69_METHOD_COMPARISON_OCCASION_POLICY, + RES69_METHOD_COMPARISON_OPERATION, + RES69_REGISTRY_VERSION, +) +from dynamislm.longitudinal.statistics.support import ( + StatisticalConstraintError, + scalar_value, + validate_statistical_support, +) +from dynamislm.longitudinal.statistics.validation import ( + make_estimate, + make_result, + refusal, + refusal_for_exception, + validate_method_comparison_authority, +) +from dynamislm.measurement.identity import ( + MetadataEntry, + RegistryReference, + UnitReference, +) +from dynamislm.provenance.models import EvidenceReference +from dynamislm.refusal.models import RefusalResult +from dynamislm.serialization import canonical_hash + + +def _parameters(*items: tuple[str, str | int | float]) -> tuple[MetadataEntry, ...]: + return tuple(MetadataEntry(key, value) for key, value in items) + + +def build_method_comparison_design_evidence( + *, + support: StatisticalSupport, + source_records: tuple[LongitudinalAthletePerformanceRecord, ...], + design_identity: RegistryReference, + pairs: tuple[MethodComparisonPair, ...], + method_a_key: MethodComparisonMethodKeyV1, + method_b_key: MethodComparisonMethodKeyV1, + target_construct: RegistryReference, + target_measurand: RegistryReference, + metric_a_definition: RegistryReference, + metric_b_definition: RegistryReference, + unit_a: UnitReference, + unit_b: UnitReference, + evidence_references: tuple[EvidenceReference, ...], + producing_method: RegistryReference, + occasion_context_policy: RegistryReference = RES69_METHOD_COMPARISON_OCCASION_POLICY, + sign_convention: RegistryReference = RES69_B_MINUS_A_SIGN_CONVENTION, + hidden_transformation: bool = False, + repeated_pair_policy: RepeatedPairPolicy = RepeatedPairPolicy.ONE_PAIR_PER_INDEPENDENT_SUBJECT, + registry_version: str = RES69_REGISTRY_VERSION, +) -> MethodComparisonDesignEvidence: + """Create typed method-comparison evidence before authority normalization.""" + + return MethodComparisonDesignEvidence( + support=support, + source_records=source_records, + design_identity=design_identity, + pairs=pairs, + method_a_key=method_a_key, + method_b_key=method_b_key, + target_construct=target_construct, + target_measurand=target_measurand, + metric_a_definition=metric_a_definition, + metric_b_definition=metric_b_definition, + unit_a=unit_a, + unit_b=unit_b, + occasion_context_policy=occasion_context_policy, + sign_convention=sign_convention, + hidden_transformation=hidden_transformation, + repeated_pair_policy=repeated_pair_policy, + evidence_references=evidence_references, + producing_method=producing_method, + registry_version=registry_version, + ) + + +def _validate_method_comparison_evidence(evidence: MethodComparisonDesignEvidence) -> None: + support = evidence.support + validate_statistical_support(support) + expected_records = tuple( + sorted( + ( + StatisticalSourceRecordReference.from_record(record) + for record in evidence.source_records + ), + key=lambda item: item.record_id.qualified, + ) + ) + if expected_records != support.source_records: + raise StatisticalConstraintError( + "method-comparison source records do not match support", + RES69ReasonCode.SUPPORT_MISMATCH.value, + ) + if evidence.producing_method != RES69_METHOD_COMPARISON_DESIGN_OPERATION: + raise StatisticalConstraintError( + "method comparison evidence must use the registered design-normalization operation", + RES69ReasonCode.METHOD_COMPARISON_AUTHORITY_REQUIRED.value, + ) + if evidence.registry_version != RES69_REGISTRY_VERSION: + raise StatisticalConstraintError( + "method comparison registry version is not the registered RES-69 version", + RES69ReasonCode.METHOD_COMPARISON_AUTHORITY_REQUIRED.value, + ) + if evidence.sign_convention != RES69_B_MINUS_A_SIGN_CONVENTION: + raise StatisticalConstraintError( + "method comparison requires the registered B minus A sign convention", + RES69ReasonCode.ANALYSIS_DESIGN_MISMATCH.value, + ) + if evidence.occasion_context_policy != RES69_METHOD_COMPARISON_OCCASION_POLICY: + raise StatisticalConstraintError( + "method comparison context policy is not registered", + RES69ReasonCode.ANALYSIS_DESIGN_MISMATCH.value, + ) + if evidence.repeated_pair_policy is not RepeatedPairPolicy.ONE_PAIR_PER_INDEPENDENT_SUBJECT: + raise StatisticalConstraintError( + "repeated-pair policy is not the V1 independent-subject policy", + RES69ReasonCode.ANALYSIS_DESIGN_MISMATCH.value, + ) + if evidence.hidden_transformation: + raise StatisticalConstraintError( + "hidden transformation is not admissible for simple BA V1", + RES69ReasonCode.ANALYSIS_DESIGN_MISMATCH.value, + ) + if evidence.unit_a != evidence.unit_b: + raise StatisticalConstraintError( + "method comparison requires exact common units; conversion is not registered", + RES69ReasonCode.UNIT_MISMATCH.value, + ) + entries = {entry.canonical_entry_id: entry for entry in support.included_entries} + paired_ids: set[object] = set() + athletes: set[object] = set() + for pair in evidence.pairs: + method_a = entries.get(pair.method_a.entry_id) + method_b = entries.get(pair.method_b.entry_id) + if method_a is None or method_b is None: + raise StatisticalConstraintError( + "method comparison pair is incomplete", + RES69ReasonCode.INCOMPLETE_PAIR.value, + ) + if ( + StatisticalObservationReference.from_entry(method_a) != pair.method_a + or StatisticalObservationReference.from_entry(method_b) != pair.method_b + ): + raise StatisticalConstraintError( + "method comparison pairing hashes do not match support", + RES69ReasonCode.SUPPORT_MISMATCH.value, + ) + method_a_unit = scalar_value(method_a)[1] + method_b_unit = scalar_value(method_b)[1] + actual_method_a_key = MethodComparisonMethodKeyV1.from_measurement_identity( + method_a.observation.identity, + method_a_unit, + ) + actual_method_b_key = MethodComparisonMethodKeyV1.from_measurement_identity( + method_b.observation.identity, + method_b_unit, + ) + if ( + actual_method_a_key != evidence.method_a_key + or actual_method_b_key != evidence.method_b_key + ): + raise StatisticalConstraintError( + "method comparison authority does not match stable method keys", + RES69ReasonCode.SUPPORT_MISMATCH.value, + ) + if method_a.session_id != method_b.session_id: + raise StatisticalConstraintError( + "method A and method B must be observed in the same declared occasion", + RES69ReasonCode.ANALYSIS_DESIGN_MISMATCH.value, + ) + if ( + method_a.observation.identity.semantic.construct.stable_id + != evidence.target_construct.stable_id + or method_b.observation.identity.semantic.construct.stable_id + != evidence.target_construct.stable_id + ): + raise StatisticalConstraintError( + "method comparison construct mismatch", + RES69ReasonCode.IDENTITY_UNRESOLVED.value, + ) + if ( + method_a.observation.identity.semantic.measurand.stable_id + != evidence.target_measurand.stable_id + or method_b.observation.identity.semantic.measurand.stable_id + != evidence.target_measurand.stable_id + ): + raise StatisticalConstraintError( + "method comparison measurand mismatch", + RES69ReasonCode.IDENTITY_UNRESOLVED.value, + ) + if ( + method_a.observation.identity.semantic.metric_definition.stable_id + != evidence.metric_a_definition.stable_id + or method_b.observation.identity.semantic.metric_definition.stable_id + != evidence.metric_b_definition.stable_id + ): + raise StatisticalConstraintError( + "method comparison metric identities do not match evidence", + RES69ReasonCode.SUPPORT_MISMATCH.value, + ) + unit_a = method_a_unit + unit_b = method_b_unit + if unit_a != evidence.unit_a or unit_b != evidence.unit_b: + raise StatisticalConstraintError( + "method comparison units do not match evidence", + RES69ReasonCode.UNIT_MISMATCH.value, + ) + paired_ids.update((pair.method_a.entry_id, pair.method_b.entry_id)) + if pair.athlete_id in athletes: + raise StatisticalConstraintError( + "repeated observations were flattened into one method-comparison pair", + RES69ReasonCode.ANALYSIS_DESIGN_MISMATCH.value, + ) + athletes.add(pair.athlete_id) + if paired_ids != {entry.canonical_entry_id for entry in support.included_entries}: + raise StatisticalConstraintError( + "method comparison support contains an incomplete or unpaired observation", + RES69ReasonCode.INCOMPLETE_PAIR.value, + ) + + +def build_method_comparison_design_authority( + evidence: MethodComparisonDesignEvidence, +) -> MethodComparisonDesignAuthority: + """Normalize source/protocol evidence into a source-bound BA authority.""" + + if not isinstance(evidence, MethodComparisonDesignEvidence): + raise ValueError("evidence must be a MethodComparisonDesignEvidence") + _validate_method_comparison_evidence(evidence) + support = evidence.support + source_records = tuple( + sorted( + ( + StatisticalSourceRecordReference.from_record(record) + for record in evidence.source_records + ), + key=lambda item: item.record_id.qualified, + ) + ) + authority = MethodComparisonDesignAuthority( + support_id=support.canonical_support_id, + support_hash=support.canonical_support_hash, + analysis_input_ids=support.input_ids, + analysis_input_hashes=support.input_hashes, + source_records=source_records, + source_artifacts=support.source_artifacts, + source_provenance=support.source_provenance, + design_identity=evidence.design_identity, + pairs=tuple(sorted(evidence.pairs, key=lambda item: item.pair_id.qualified)), + method_a_key=evidence.method_a_key, + method_b_key=evidence.method_b_key, + target_construct=evidence.target_construct, + target_measurand=evidence.target_measurand, + metric_a_definition=evidence.metric_a_definition, + metric_b_definition=evidence.metric_b_definition, + unit_a=evidence.unit_a, + unit_b=evidence.unit_b, + occasion_context_policy=evidence.occasion_context_policy, + sign_convention=evidence.sign_convention, + hidden_transformation=evidence.hidden_transformation, + repeated_pair_policy=evidence.repeated_pair_policy, + evidence_references=evidence.evidence_references, + producing_method=evidence.producing_method, + registry_version=evidence.registry_version, + source_evidence_hash=canonical_hash(evidence), + authority_status=AuthorityStatus.UNVERIFIED, + ) + return replace( + authority, + authority_status=AuthorityStatus.SOURCE_BOUND, + authority_token=canonical_hash( + { + "authority_hash": authority.canonical_authority_hash, + "purpose": "RES69_SOURCE_BOUND_AUTHORITY_V1", + } + ), + ) + + +def _validated_method_pairs( + support: StatisticalSupport, + authority: MethodComparisonDesignAuthority, +) -> tuple[ + tuple[ + MethodComparisonPair, + LongitudinalObservationEntry, + LongitudinalObservationEntry, + float, + float, + ], + ..., +]: + validate_method_comparison_authority(authority, support) + validate_statistical_support(support) + if ( + authority.analysis_input_ids != support.input_ids + or authority.analysis_input_hashes != support.input_hashes + ): + raise StatisticalConstraintError( + "method-comparison authority input lineage does not match support", + RES69ReasonCode.SUPPORT_MISMATCH.value, + ) + if authority.source_records != support.source_records: + raise StatisticalConstraintError( + "method-comparison authority source records do not match support", + RES69ReasonCode.SUPPORT_MISMATCH.value, + ) + if ( + authority.source_artifacts != support.source_artifacts + or authority.source_provenance != support.source_provenance + ): + raise StatisticalConstraintError( + "method-comparison authority provenance does not match support", + RES69ReasonCode.SUPPORT_MISMATCH.value, + ) + if authority.sign_convention != RES69_B_MINUS_A_SIGN_CONVENTION: + raise StatisticalConstraintError( + "method-comparison sign convention is not registered", + RES69ReasonCode.ANALYSIS_DESIGN_MISMATCH.value, + ) + if authority.hidden_transformation: + raise StatisticalConstraintError( + "hidden transformation is not admissible for simple BA V1", + RES69ReasonCode.ANALYSIS_DESIGN_MISMATCH.value, + ) + entries = {entry.canonical_entry_id: entry for entry in support.included_entries} + seen_entries: set[object] = set() + seen_athletes: set[object] = set() + validated = [] + for pair in authority.pairs: + method_a = entries.get(pair.method_a.entry_id) + method_b = entries.get(pair.method_b.entry_id) + if method_a is None or method_b is None: + raise StatisticalConstraintError( + "method-comparison pair is incomplete", + RES69ReasonCode.INCOMPLETE_PAIR.value, + ) + if ( + StatisticalObservationReference.from_entry(method_a) != pair.method_a + or StatisticalObservationReference.from_entry(method_b) != pair.method_b + ): + raise StatisticalConstraintError( + "method-comparison source observation hash does not match support", + RES69ReasonCode.SUPPORT_MISMATCH.value, + ) + a_value, a_unit = scalar_value(method_a) + b_value, b_unit = scalar_value(method_b) + actual_method_a_key = MethodComparisonMethodKeyV1.from_measurement_identity( + method_a.observation.identity, + a_unit, + ) + actual_method_b_key = MethodComparisonMethodKeyV1.from_measurement_identity( + method_b.observation.identity, + b_unit, + ) + if ( + actual_method_a_key != authority.method_a_key + or actual_method_b_key != authority.method_b_key + ): + raise StatisticalConstraintError( + "method-comparison stable method key does not match pair support", + RES69ReasonCode.SUPPORT_MISMATCH.value, + ) + if method_a.session_id != method_b.session_id: + raise StatisticalConstraintError( + "method A and method B must be observed in the same declared occasion", + RES69ReasonCode.ANALYSIS_DESIGN_MISMATCH.value, + ) + if pair.athlete_id in seen_athletes: + raise StatisticalConstraintError( + "repeated pair flattening is not allowed", + RES69ReasonCode.ANALYSIS_DESIGN_MISMATCH.value, + ) + if a_unit != authority.unit_a or b_unit != authority.unit_b or a_unit != b_unit: + raise StatisticalConstraintError( + "method-comparison arithmetic requires exact common units", + RES69ReasonCode.UNIT_MISMATCH.value, + ) + seen_entries.update((pair.method_a.entry_id, pair.method_b.entry_id)) + seen_athletes.add(pair.athlete_id) + validated.append((pair, method_a, method_b, a_value, b_value)) + if seen_entries != {entry.canonical_entry_id for entry in support.included_entries}: + raise StatisticalConstraintError( + "method comparison has an incomplete pair support", + RES69ReasonCode.INCOMPLETE_PAIR.value, + ) + return tuple(validated) + + +def _sample_sd(values: tuple[float, ...]) -> float: + if len(values) < 2: + raise ValueError("sample SD requires at least two values") + mean = math.fsum(values) / len(values) + return math.sqrt(math.fsum((value - mean) ** 2 for value in values) / (len(values) - 1)) + + +def calculate_bland_altman_summary( + support: StatisticalSupport, + authority: object, +) -> StatisticalResult | RefusalResult: + """Calculate only B-minus-A bias and sample SD of differences.""" + + claim = "calculate simple Bland-Altman V1 bias and SD difference" + try: + if not isinstance(authority, MethodComparisonDesignAuthority): + raise StatisticalConstraintError( + "source-bound MethodComparisonDesignAuthority is required", + RES69ReasonCode.METHOD_COMPARISON_AUTHORITY_REQUIRED.value, + ) + pairs = _validated_method_pairs(support, authority) + differences = tuple(item[4] - item[3] for item in pairs) + bias = math.fsum(differences) / len(differences) + parameters = _parameters( + ("pair_count", len(pairs)), + ("support_hash", support.canonical_support_hash), + ("authority_hash", authority.canonical_authority_hash), + ("sign_convention", RES69_B_MINUS_A_SIGN_CONVENTION.stable_id), + ) + estimates = [ + make_estimate( + estimand=RES69_BA_BIAS_ESTIMAND, + value=bias, + unit=authority.unit_a, + estimator=RES69_BA_ESTIMATOR, + parameters=parameters, + ) + ] + non_computable: tuple[StatisticalNonComputable, ...] = () + if len(differences) >= 2: + estimates.append( + make_estimate( + estimand=RES69_BA_SD_DIFFERENCE_ESTIMAND, + value=_sample_sd(differences), + unit=authority.unit_a, + estimator=RES69_BA_ESTIMATOR, + parameters=parameters, + ) + ) + else: + from dynamislm.longitudinal.statistics.models import StatisticalOperationDisposition + + non_computable = ( + StatisticalNonComputable( + RES69_METHOD_COMPARISON_OPERATION, + StatisticalOperationDisposition.REPRESENT_BUT_DO_NOT_COMPUTE, + "sample SD of method differences requires N >= 2 independent pairs", + ), + ) + return make_result( + support, + operation=RES69_METHOD_COMPARISON_OPERATION, + estimator=RES69_BA_ESTIMATOR, + estimates=tuple(estimates), + parameters=parameters, + authority_references=(authority.design_identity, authority.producing_method), + authority_hashes=(authority.canonical_authority_hash,), + non_computable=non_computable, + evidence_references=authority.evidence_references, + registry_version=authority.registry_version, + ) + except ( + AttributeError, + KeyError, + TypeError, + ValueError, + ZeroDivisionError, + OverflowError, + ) as exc: + return refusal_for_exception(claim, exc, support=support) + + +def request_classical_bland_altman_limits( + support: StatisticalSupport | None = None, + authority: object | None = None, +) -> RefusalResult: + """Return the registered-not-computable outcome for classical BA LoA.""" + + del authority + return refusal( + "calculate classical Bland-Altman limits of agreement", + RES69ReasonCode.CLASSICAL_LOA_DEFERRED, + support=support, + missing_information=( + "a registered RES-69 classical LoA calculator and coverage authority", + ), + safe_descriptions=( + "simple B-minus-A bias and SD difference remain the only BA V1 numerical outputs", + "coverage and interchangeability interpretations are not authorized", + ), + ) + + +def refuse_bland_altman_interpretation( + support: StatisticalSupport | None = None, + *, + claim: str = "claim acceptable agreement, 95% coverage, or interchangeability", +) -> RefusalResult: + """Block BA interpretation claims outside the narrow V1 output boundary.""" + + return refusal( + claim, + RES69ReasonCode.INTERPRETATION_NOT_AUTHORIZED, + support=support, + missing_information=( + "a separately registered agreement/coverage interpretation authority", + ), + safe_descriptions=( + "the descriptive BA bias and SD difference may be reported without an " + "acceptability claim", + ), + ) + + +# Readable aliases used by callers and the decision record. +calculate_simple_bland_altman = calculate_bland_altman_summary +request_ba_limits = request_classical_bland_altman_limits +calculate_classical_bland_altman_limits = request_classical_bland_altman_limits + + +__all__ = [ + "build_method_comparison_design_authority", + "build_method_comparison_design_evidence", + "calculate_bland_altman_summary", + "calculate_classical_bland_altman_limits", + "calculate_simple_bland_altman", + "refuse_bland_altman_interpretation", + "request_ba_limits", + "request_classical_bland_altman_limits", +] diff --git a/src/dynamislm/longitudinal/statistics/descriptive.py b/src/dynamislm/longitudinal/statistics/descriptive.py new file mode 100644 index 0000000..74ca780 --- /dev/null +++ b/src/dynamislm/longitudinal/statistics/descriptive.py @@ -0,0 +1,755 @@ +"""Deterministic descriptive longitudinal operations for RES-69.""" + +from __future__ import annotations + +import datetime as datetime_module +import math +import statistics + +from dynamislm.longitudinal.models import LongitudinalObservationEntry +from dynamislm.longitudinal.statistics.models import ( + MeasurementScaleSemanticKeyV1, + MeasurementScaleSemantics, + StatisticalNonComputable, + StatisticalOperationDisposition, + StatisticalResult, + StatisticalSupport, +) +from dynamislm.longitudinal.statistics.registry import ( + RES69_ABSOLUTE_CHANGE_ESTIMATOR, + RES69_ABSOLUTE_CHANGE_OPERATION, + RES69_CHANGE_ESTIMAND, + RES69_DIMENSIONLESS_UNIT, + RES69_INTERCEPT_ESTIMAND, + RES69_LOG_RATIO_ESTIMAND, + RES69_LOG_RATIO_ESTIMATOR, + RES69_LOG_RATIO_OPERATION, + RES69_LOG_RATIO_UNIT, + RES69_MAXIMUM_ESTIMAND, + RES69_MEAN_ESTIMAND, + RES69_MEDIAN_ESTIMAND, + RES69_MINIMUM_ESTIMAND, + RES69_OLS_ESTIMATOR, + RES69_OLS_SLOPE_OPERATION, + RES69_PERCENT_CHANGE_ESTIMAND, + RES69_PERCENT_UNIT, + RES69_RANGE_ESTIMAND, + RES69_REFERENCE_WINDOW_DEVIATION_OPERATION, + RES69_REFERENCE_Z_ESTIMAND, + RES69_REFERENCE_Z_ESTIMATOR, + RES69_RELATIVE_CHANGE_ESTIMAND, + RES69_RELATIVE_CHANGE_ESTIMATOR, + RES69_RELATIVE_CHANGE_OPERATION, + RES69_SAMPLE_SD_ESTIMAND, + RES69_SCALE_REGISTRY, + RES69_SECOND_UNIT, + RES69_SLOPE_ESTIMAND, + RES69_UNIT_DERIVATION_OPERATION, + RES69_WINDOW_DESCRIPTIVE_ESTIMATOR, + RES69_WINDOW_DESCRIPTIVES_OPERATION, + RES69_WITHIN_ATHLETE_SD_ESTIMATOR, + RES69_WITHIN_ATHLETE_SD_OPERATION, + MeasurementScaleRegistry, +) +from dynamislm.longitudinal.statistics.support import ( + StatisticalConstraintError, + exact_common_unit, + scalar_value, + validate_comparable_entries, + validate_statistical_support, +) +from dynamislm.longitudinal.statistics.validation import ( + make_estimate, + make_result, + refusal_for_exception, + resolve_scale_semantics, +) +from dynamislm.measurement.identity import MetadataEntry, RegistryReference, UnitReference +from dynamislm.provenance.models import EvidenceReference +from dynamislm.refusal.models import RefusalResult +from dynamislm.serialization import canonical_hash + + +def _parameters(*items: tuple[str, str | int | float]) -> tuple[MetadataEntry, ...]: + return tuple(MetadataEntry(key, value) for key, value in items) + + +def _evidence_references(support: StatisticalSupport) -> tuple[EvidenceReference, ...]: + references = [] + for evidence in support.comparability_evidence: + if evidence.result.rule_reference is not None: + references.append(EvidenceReference(evidence.result.rule_reference)) + return tuple(dict.fromkeys(references)) + + +def _validated_entries( + support: StatisticalSupport, + *, + minimum: int, +) -> tuple[tuple[LongitudinalObservationEntry, ...], UnitReference, tuple[object, ...]]: + validate_statistical_support(support) + entries = support.included_entries + if len(entries) < minimum: + raise StatisticalConstraintError( + f"at least {minimum} included observations are required", + "RES69_DATA_ADEQUACY_INSUFFICIENT", + ) + unit = _validated_common_unit(entries) + authorities = validate_comparable_entries(support, entries) + return entries, unit, authorities + + +def _validated_common_unit( + entries: tuple[LongitudinalObservationEntry, ...], +) -> UnitReference: + try: + units = tuple(scalar_value(entry)[1] for entry in entries) + except ValueError as exc: + raise StatisticalConstraintError( + "source result is not a finite scalar with an exact unit", + "RES69_DATA_ADEQUACY_INSUFFICIENT", + ) from exc + unit = units[0] + if any(item != unit for item in units[1:]): + raise StatisticalConstraintError( + "direct statistical arithmetic requires one exact common UnitReference", + "RES69_UNIT_MISMATCH", + missing_information=("registered deterministic unit conversion",), + ) + return unit + + +def _pair_entries( + support: StatisticalSupport, + *, + baseline_entry: LongitudinalObservationEntry | None = None, + followup_entry: LongitudinalObservationEntry | None = None, +) -> tuple[ + LongitudinalObservationEntry, LongitudinalObservationEntry, UnitReference, tuple[object, ...] +]: + if (baseline_entry is None) != (followup_entry is None): + raise StatisticalConstraintError( + "baseline and follow-up entries must be supplied together", + "RES69_SUPPORT_MISMATCH", + ) + if baseline_entry is None: + entries, unit, authorities = _validated_entries(support, minimum=2) + else: + assert followup_entry is not None + validate_statistical_support(support) + selected_by_id = {entry.canonical_entry_id: entry for entry in support.included_entries} + if ( + selected_by_id.get(baseline_entry.canonical_entry_id) != baseline_entry + or selected_by_id.get(followup_entry.canonical_entry_id) != followup_entry + ): + raise StatisticalConstraintError( + "baseline/follow-up entries must be exact included support entries", + "RES69_SUPPORT_MISMATCH", + ) + entries = (baseline_entry, followup_entry) + unit = _validated_common_unit(entries) + authorities = validate_comparable_entries(support, entries) + if len(entries) != 2: + raise StatisticalConstraintError( + "the operation requires exactly two included observations", + "RES69_ANALYSIS_DESIGN_MISMATCH", + ) + first, second = entries + if first.source_observation_id == second.source_observation_id: + raise StatisticalConstraintError( + "two-replicate or longitudinal pair contains one observation ID twice", + "RES69_SUPPORT_MISMATCH", + ) + if first.observed_at >= second.observed_at: + code = ( + "RES69_DUPLICATE_TIMESTAMP" + if first.observed_at == second.observed_at + else "RES69_ANALYSIS_DESIGN_MISMATCH" + ) + raise StatisticalConstraintError( + "baseline/first observation must precede follow-up/second observation", + code, + ) + if first.observation.context.athlete_id != second.observation.context.athlete_id: + raise StatisticalConstraintError( + "longitudinal change requires one athlete", + "RES69_ANALYSIS_DESIGN_MISMATCH", + ) + return first, second, unit, authorities + + +def _scale_for_pair( + support: StatisticalSupport, + registry: MeasurementScaleRegistry, +) -> tuple[MeasurementScaleSemantics, MeasurementScaleSemanticKeyV1, UnitReference]: + semantics, key, unit = resolve_scale_semantics(support, registry=registry) + return semantics, key, unit + + +def _check_ratio_domain( + baseline: float, + followup: float, + semantics: MeasurementScaleSemantics, +) -> None: + if semantics.signed_value_policy.value == "STRICTLY_POSITIVE": + if baseline <= 0 or followup <= 0: + raise StatisticalConstraintError( + "registered scale semantics require strictly positive ratio inputs", + "RES69_SCALE_OPERATION_NOT_AUTHORIZED", + ) + elif semantics.signed_value_policy.value == "NONNEGATIVE" and (baseline < 0 or followup < 0): + raise StatisticalConstraintError( + "registered scale semantics prohibit signed inputs", + "RES69_SCALE_OPERATION_NOT_AUTHORIZED", + ) + if semantics.denominator_policy.value == "STRICTLY_POSITIVE" and baseline <= 0: + raise StatisticalConstraintError( + "registered denominator policy requires a strictly positive baseline", + "RES69_SCALE_OPERATION_NOT_AUTHORIZED", + ) + if semantics.denominator_policy.value == "NONZERO" and baseline == 0: + raise StatisticalConstraintError( + "relative change has a zero denominator", + "RES69_SCALE_OPERATION_NOT_AUTHORIZED", + ) + + +def _relative_change_value(baseline: float, followup: float) -> float: + """Pure arithmetic helper; production authorization remains in the caller.""" + + return (followup - baseline) / baseline + + +def _log_ratio_value(baseline: float, followup: float) -> float: + """Pure log-ratio arithmetic helper; production authorization remains in the caller.""" + + return math.log(followup) - math.log(baseline) + + +def calculate_absolute_change( + support: StatisticalSupport, + *, + baseline_entry: LongitudinalObservationEntry | None = None, + followup_entry: LongitudinalObservationEntry | None = None, +) -> StatisticalResult | RefusalResult: + """Calculate follow-up minus baseline without interpretation.""" + + claim = "calculate absolute longitudinal change" + try: + baseline, followup, unit, authorities = _pair_entries( + support, + baseline_entry=baseline_entry, + followup_entry=followup_entry, + ) + baseline_value, _ = scalar_value(baseline) + followup_value, _ = scalar_value(followup) + parameters = _parameters( + ("baseline_entry_id", baseline.canonical_entry_id.qualified), + ("followup_entry_id", followup.canonical_entry_id.qualified), + ) + estimate = make_estimate( + estimand=RES69_CHANGE_ESTIMAND, + value=followup_value - baseline_value, + unit=unit, + estimator=RES69_ABSOLUTE_CHANGE_ESTIMATOR, + parameters=parameters, + ) + authority_refs = tuple(item for item in authorities if isinstance(item, RegistryReference)) + return make_result( + support, + operation=RES69_ABSOLUTE_CHANGE_OPERATION, + estimator=RES69_ABSOLUTE_CHANGE_ESTIMATOR, + estimates=(estimate,), + parameters=parameters, + authority_references=authority_refs, + evidence_references=_evidence_references(support), + ) + except ( + AttributeError, + KeyError, + TypeError, + ValueError, + ZeroDivisionError, + OverflowError, + ) as exc: + return refusal_for_exception(claim, exc, support=support) + + +def calculate_relative_change( + support: StatisticalSupport, + *, + registry: MeasurementScaleRegistry = RES69_SCALE_REGISTRY, + ratio_scale: object | None = None, + baseline_entry: LongitudinalObservationEntry | None = None, + followup_entry: LongitudinalObservationEntry | None = None, +) -> StatisticalResult | RefusalResult: + """Calculate one registered arithmetic ratio-change estimand. + + ``ratio_scale`` is accepted only as a compatibility surface and is never + consulted for authorization; the registry is the sole scale authority. + """ + + del ratio_scale + claim = "calculate arithmetic relative and percent longitudinal change" + try: + baseline, followup, _unit, authorities = _pair_entries( + support, + baseline_entry=baseline_entry, + followup_entry=followup_entry, + ) + semantics, key, _ = _scale_for_pair(support, registry) + if not semantics.relative_change_authorized: + raise StatisticalConstraintError( + "registered scale semantics do not authorize arithmetic relative change", + "RES69_SCALE_OPERATION_NOT_AUTHORIZED", + ) + baseline_value, _ = scalar_value(baseline) + followup_value, _ = scalar_value(followup) + _check_ratio_domain(baseline_value, followup_value, semantics) + relative = _relative_change_value(baseline_value, followup_value) + percent = 100.0 * relative + parameters = _parameters( + ("baseline_entry_id", baseline.canonical_entry_id.qualified), + ("followup_entry_id", followup.canonical_entry_id.qualified), + ("scale_key", key.stable_key), + ) + estimates = ( + make_estimate( + estimand=RES69_RELATIVE_CHANGE_ESTIMAND, + value=relative, + unit=RES69_DIMENSIONLESS_UNIT, + estimator=RES69_RELATIVE_CHANGE_ESTIMATOR, + parameters=parameters, + ), + make_estimate( + estimand=RES69_PERCENT_CHANGE_ESTIMAND, + value=percent, + unit=RES69_PERCENT_UNIT, + estimator=RES69_RELATIVE_CHANGE_ESTIMATOR, + parameters=parameters, + ), + ) + authority_refs = tuple( + item + for item in (*authorities, semantics.authority_reference) + if isinstance(item, RegistryReference) + ) + return make_result( + support, + operation=RES69_RELATIVE_CHANGE_OPERATION, + estimator=RES69_RELATIVE_CHANGE_ESTIMATOR, + estimates=estimates, + parameters=parameters, + authority_references=tuple(dict.fromkeys(authority_refs)), + evidence_references=(*_evidence_references(support), *semantics.evidence_references), + scale_semantic_key=key, + scale_semantics_authority=semantics.authority_reference, + scale_semantics_hash=canonical_hash(semantics), + registry_version=registry.registry_version, + ) + except ( + AttributeError, + KeyError, + TypeError, + ValueError, + ZeroDivisionError, + OverflowError, + ) as exc: + return refusal_for_exception(claim, exc, support=support) + + +# Percent rendering is the same registered arithmetic ratio-change authority, +# not a second incompatible calculation. +calculate_percent_change = calculate_relative_change + + +def calculate_log_ratio_change( + support: StatisticalSupport, + *, + registry: MeasurementScaleRegistry = RES69_SCALE_REGISTRY, + log_allowed: object | None = None, + baseline_entry: LongitudinalObservationEntry | None = None, + followup_entry: LongitudinalObservationEntry | None = None, +) -> StatisticalResult | RefusalResult: + """Calculate ln(follow-up / baseline) for registered positive support.""" + + del log_allowed + claim = "calculate logarithmic ratio change" + try: + baseline, followup, _unit, authorities = _pair_entries( + support, + baseline_entry=baseline_entry, + followup_entry=followup_entry, + ) + semantics, key, _ = _scale_for_pair(support, registry) + if not semantics.log_ratio_authorized: + raise StatisticalConstraintError( + "registered scale semantics do not authorize log ratio", + "RES69_SCALE_OPERATION_NOT_AUTHORIZED", + ) + baseline_value, _ = scalar_value(baseline) + followup_value, _ = scalar_value(followup) + if baseline_value <= 0 or followup_value <= 0: + raise StatisticalConstraintError( + "log ratio requires strictly positive baseline and follow-up values", + "RES69_NONPOSITIVE_LOG_INPUT", + ) + value = _log_ratio_value(baseline_value, followup_value) + parameters = _parameters( + ("baseline_entry_id", baseline.canonical_entry_id.qualified), + ("followup_entry_id", followup.canonical_entry_id.qualified), + ("scale_key", key.stable_key), + ) + estimate = make_estimate( + estimand=RES69_LOG_RATIO_ESTIMAND, + value=value, + unit=RES69_LOG_RATIO_UNIT, + estimator=RES69_LOG_RATIO_ESTIMATOR, + parameters=parameters, + ) + authority_refs = tuple( + item + for item in (*authorities, semantics.authority_reference) + if isinstance(item, RegistryReference) + ) + return make_result( + support, + operation=RES69_LOG_RATIO_OPERATION, + estimator=RES69_LOG_RATIO_ESTIMATOR, + estimates=(estimate,), + parameters=parameters, + authority_references=tuple(dict.fromkeys(authority_refs)), + evidence_references=(*_evidence_references(support), *semantics.evidence_references), + scale_semantic_key=key, + scale_semantics_authority=semantics.authority_reference, + scale_semantics_hash=canonical_hash(semantics), + registry_version=registry.registry_version, + ) + except ( + AttributeError, + KeyError, + TypeError, + ValueError, + ZeroDivisionError, + OverflowError, + ) as exc: + return refusal_for_exception(claim, exc, support=support) + + +def _sample_sd(values: tuple[float, ...], mean: float | None = None) -> float: + if len(values) < 2: + raise ValueError("sample SD requires at least two values") + average = mean if mean is not None else math.fsum(values) / len(values) + return math.sqrt(math.fsum((value - average) ** 2 for value in values) / (len(values) - 1)) + + +def calculate_window_descriptives( + support: StatisticalSupport, +) -> StatisticalResult | RefusalResult: + """Calculate mean, median, sample SD, min, max, and range for exact support.""" + + claim = "calculate exact-window descriptive statistics" + try: + entries, unit, authorities = _validated_entries(support, minimum=1) + values = tuple(scalar_value(entry)[0] for entry in entries) + mean = math.fsum(values) / len(values) + median = float(statistics.median(values)) + estimates = [ + make_estimate( + estimand=RES69_MEAN_ESTIMAND, + value=mean, + unit=unit, + estimator=RES69_WINDOW_DESCRIPTIVE_ESTIMATOR, + ), + make_estimate( + estimand=RES69_MEDIAN_ESTIMAND, + value=median, + unit=unit, + estimator=RES69_WINDOW_DESCRIPTIVE_ESTIMATOR, + ), + ] + non_computable: tuple[StatisticalNonComputable, ...] = () + if len(values) >= 2: + estimates.append( + make_estimate( + estimand=RES69_SAMPLE_SD_ESTIMAND, + value=_sample_sd(values, mean), + unit=unit, + estimator=RES69_WINDOW_DESCRIPTIVE_ESTIMATOR, + ) + ) + else: + non_computable = ( + StatisticalNonComputable( + RES69_WINDOW_DESCRIPTIVES_OPERATION, + StatisticalOperationDisposition.REPRESENT_BUT_DO_NOT_COMPUTE, + "sample SD requires n >= 2", + ), + ) + estimates.extend( + ( + make_estimate( + estimand=RES69_MINIMUM_ESTIMAND, + value=min(values), + unit=unit, + estimator=RES69_WINDOW_DESCRIPTIVE_ESTIMATOR, + ), + make_estimate( + estimand=RES69_MAXIMUM_ESTIMAND, + value=max(values), + unit=unit, + estimator=RES69_WINDOW_DESCRIPTIVE_ESTIMATOR, + ), + make_estimate( + estimand=RES69_RANGE_ESTIMAND, + value=max(values) - min(values), + unit=unit, + estimator=RES69_WINDOW_DESCRIPTIVE_ESTIMATOR, + ), + ) + ) + parameters = _parameters( + ("n", len(values)), ("support_hash", support.canonical_support_hash) + ) + authority_refs = tuple(item for item in authorities if isinstance(item, RegistryReference)) + return make_result( + support, + operation=RES69_WINDOW_DESCRIPTIVES_OPERATION, + estimator=RES69_WINDOW_DESCRIPTIVE_ESTIMATOR, + estimates=tuple(estimates), + parameters=parameters, + authority_references=authority_refs, + non_computable=non_computable, + evidence_references=_evidence_references(support), + ) + except ( + AttributeError, + KeyError, + TypeError, + ValueError, + ZeroDivisionError, + OverflowError, + ) as exc: + return refusal_for_exception(claim, exc, support=support) + + +def calculate_reference_window_deviation( + support: StatisticalSupport, +) -> StatisticalResult | RefusalResult: + """Standardize current value against an explicitly prior reference window.""" + + claim = "calculate reference-window standardized deviation" + try: + validate_statistical_support(support) + if support.current_entry_id is None or len(support.reference_entry_ids) < 2: + raise StatisticalConstraintError( + "reference deviation requires current plus at least two reference entries", + "RES69_DATA_ADEQUACY_INSUFFICIENT", + ) + by_id = {entry.canonical_entry_id: entry for entry in support.included_entries} + current = by_id.get(support.current_entry_id) + references = tuple(by_id[entry_id] for entry_id in support.reference_entry_ids) + if current is None: + raise StatisticalConstraintError( + "current entry is absent from support", + "RES69_SUPPORT_MISMATCH", + ) + if any(reference.observed_at >= current.observed_at for reference in references): + raise StatisticalConstraintError( + "reference observations must precede the current observation", + "RES69_ANALYSIS_DESIGN_MISMATCH", + ) + entries = (*references, current) + authorities = validate_comparable_entries(support, entries) + exact_common_unit(entries) + reference_values = tuple(scalar_value(entry)[0] for entry in references) + current_value = scalar_value(current)[0] + reference_mean = math.fsum(reference_values) / len(reference_values) + reference_sd = _sample_sd(reference_values, reference_mean) + if reference_sd <= 0: + raise StatisticalConstraintError( + "reference sample SD must be positive", + "RES69_ZERO_REFERENCE_SD", + ) + value = (current_value - reference_mean) / reference_sd + parameters = _parameters( + ("current_entry_id", current.canonical_entry_id.qualified), + ("reference_count", len(references)), + ("support_hash", support.canonical_support_hash), + ) + estimate = make_estimate( + estimand=RES69_REFERENCE_Z_ESTIMAND, + value=value, + unit=RES69_DIMENSIONLESS_UNIT, + estimator=RES69_REFERENCE_Z_ESTIMATOR, + parameters=parameters, + ) + authority_refs = tuple(item for item in authorities if isinstance(item, RegistryReference)) + return make_result( + support, + operation=RES69_REFERENCE_WINDOW_DEVIATION_OPERATION, + estimator=RES69_REFERENCE_Z_ESTIMATOR, + estimates=(estimate,), + parameters=parameters, + authority_references=authority_refs, + evidence_references=_evidence_references(support), + ) + except ( + AttributeError, + KeyError, + TypeError, + ValueError, + ZeroDivisionError, + OverflowError, + ) as exc: + return refusal_for_exception(claim, exc, support=support) + + +def calculate_descriptive_ols( + support: StatisticalSupport, +) -> StatisticalResult | RefusalResult: + """Calculate centered OLS intercept and slope using exact timestamps.""" + + claim = "calculate descriptive timestamp OLS" + try: + entries, unit, authorities = _validated_entries(support, minimum=2) + timestamps = tuple(entry.observed_at for entry in entries) + if len(set(timestamps)) != len(timestamps): + raise StatisticalConstraintError( + "OLS requires distinct timestamps", + "RES69_DUPLICATE_TIMESTAMP", + ) + earliest = min(timestamps) + x = tuple((at - earliest).total_seconds() for at in timestamps) + y = tuple(scalar_value(entry)[0] for entry in entries) + x_mean = math.fsum(x) / len(x) + y_mean = math.fsum(y) / len(y) + denominator = math.fsum((value - x_mean) ** 2 for value in x) + if denominator <= 0: + raise StatisticalConstraintError( + "OLS timestamp denominator must be positive", + "RES69_DUPLICATE_TIMESTAMP", + ) + numerator = math.fsum( + (x_value - x_mean) * (y_value - y_mean) for x_value, y_value in zip(x, y, strict=True) + ) + slope = numerator / denominator + intercept = y_mean - slope * x_mean + from dynamislm.longitudinal.statistics.models import DerivedUnitReference + + slope_unit = DerivedUnitReference( + numerator=unit, + denominator=RES69_SECOND_UNIT, + operation=RES69_UNIT_DERIVATION_OPERATION, + display_label=f"{unit.display_label}/s", + ) + parameters = _parameters( + ("n", len(entries)), + ("earliest_timestamp", earliest.astimezone(datetime_module.UTC).isoformat()), + ("support_hash", support.canonical_support_hash), + ) + estimates = ( + make_estimate( + estimand=RES69_INTERCEPT_ESTIMAND, + value=intercept, + unit=unit, + estimator=RES69_OLS_ESTIMATOR, + parameters=parameters, + ), + make_estimate( + estimand=RES69_SLOPE_ESTIMAND, + value=slope, + unit=slope_unit, + estimator=RES69_OLS_ESTIMATOR, + parameters=parameters, + ), + ) + authority_refs = tuple(item for item in authorities if isinstance(item, RegistryReference)) + return make_result( + support, + operation=RES69_OLS_SLOPE_OPERATION, + estimator=RES69_OLS_ESTIMATOR, + estimates=estimates, + parameters=parameters, + authority_references=authority_refs, + evidence_references=_evidence_references(support), + ) + except ( + AttributeError, + KeyError, + TypeError, + ValueError, + ZeroDivisionError, + OverflowError, + ) as exc: + return refusal_for_exception(claim, exc, support=support) + + +def calculate_descriptive_within_athlete_sd( + support: StatisticalSupport, +) -> StatisticalResult | RefusalResult: + """Calculate descriptive within-athlete sample SD, not reliability error SD.""" + + claim = "calculate descriptive within-athlete sample SD" + try: + entries, unit, authorities = _validated_entries(support, minimum=2) + athlete_ids = {entry.observation.context.athlete_id for entry in entries} + if len(athlete_ids) != 1: + raise StatisticalConstraintError( + "within-athlete SD requires one athlete", + "RES69_ANALYSIS_DESIGN_MISMATCH", + ) + values = tuple(scalar_value(entry)[0] for entry in entries) + mean = math.fsum(values) / len(values) + parameters = _parameters( + ("n", len(values)), ("support_hash", support.canonical_support_hash) + ) + estimate = make_estimate( + estimand=RES69_SAMPLE_SD_ESTIMAND, + value=_sample_sd(values, mean), + unit=unit, + estimator=RES69_WITHIN_ATHLETE_SD_ESTIMATOR, + parameters=parameters, + ) + authority_refs = tuple(item for item in authorities if isinstance(item, RegistryReference)) + return make_result( + support, + operation=RES69_WITHIN_ATHLETE_SD_OPERATION, + estimator=RES69_WITHIN_ATHLETE_SD_ESTIMATOR, + estimates=(estimate,), + parameters=parameters, + authority_references=authority_refs, + evidence_references=_evidence_references(support), + ) + except ( + AttributeError, + KeyError, + TypeError, + ValueError, + ZeroDivisionError, + OverflowError, + ) as exc: + return refusal_for_exception(claim, exc, support=support) + + +# Explicit descriptive names used by the decision record. +calculate_reference_z = calculate_reference_window_deviation +calculate_ols_slope = calculate_descriptive_ols +calculate_within_athlete_sd = calculate_descriptive_within_athlete_sd +calculate_log_ratio = calculate_log_ratio_change + + +__all__ = [ + "calculate_absolute_change", + "calculate_descriptive_ols", + "calculate_descriptive_within_athlete_sd", + "calculate_log_ratio", + "calculate_log_ratio_change", + "calculate_ols_slope", + "calculate_percent_change", + "calculate_reference_window_deviation", + "calculate_reference_z", + "calculate_relative_change", + "calculate_window_descriptives", + "calculate_within_athlete_sd", +] diff --git a/src/dynamislm/longitudinal/statistics/models.py b/src/dynamislm/longitudinal/statistics/models.py new file mode 100644 index 0000000..610482f --- /dev/null +++ b/src/dynamislm/longitudinal/statistics/models.py @@ -0,0 +1,2245 @@ +"""Immutable RES-69 statistical support, authority, and result contracts. + +The statistical layer deliberately composes the RES-62 observation and +multi-source contracts. It does not introduce another longitudinal record +model and it never stores a naked tuple of numbers as scientific authority. +""" + +from __future__ import annotations + +import datetime as datetime_module +import math +from dataclasses import dataclass, fields +from enum import StrEnum +from typing import Any, cast + +from dynamislm.comparability.models import ( + ComparabilityDecisionSource, + ComparabilityRequest, + ComparabilityResult, + ComparabilityState, +) +from dynamislm.longitudinal.models import ( + LongitudinalAthletePerformanceRecord, + LongitudinalObservationEntry, + MultiSourceAnalysisInput, + MultiSourceProcessingRun, + MultiSourceProvenanceGraph, +) +from dynamislm.measurement.identity import ( + InstanceIdentifier, + MeasurementIdentity, + MetadataEntry, + NormalizationSpec, + ProcessingIdentity, + RegistryReference, + SamplingCharacteristics, + ScientificIdentifier, + SemanticIdentity, + UnitReference, + VersionIdentity, + _require_enum, + _require_instance, + _require_optional_instance, + _require_text, + _require_tuple_items, + require_tuple, +) +from dynamislm.provenance.models import EvidenceReference +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_identifier( + value: object, + expected_type: str, + field_name: str, +) -> InstanceIdentifier: + _require_instance(value, InstanceIdentifier, field_name) + assert isinstance(value, InstanceIdentifier) + if value.instance_type != expected_type: + raise ValueError(f"{field_name} must identify a {expected_type}") + return value + + +def _require_scientific_identifier(value: object, field_name: str) -> ScientificIdentifier: + try: + return _stable_identifier(value, field_name) + except ValueError as exc: + raise ValueError( + f"{field_name} must be a ScientificIdentifier-compatible identity" + ) from exc + + +def _stable_identifier(value: object, field_name: str) -> ScientificIdentifier: + if isinstance(value, ScientificIdentifier): + return value + if isinstance(value, RegistryReference): + return value.identifier + if isinstance(value, UnitReference): + return value.identifier + if isinstance(value, MeasurementIdentity): + return value.identity_id + raise ValueError( + f"{field_name} must be a ScientificIdentifier, RegistryReference, UnitReference, " + "or MeasurementIdentity" + ) + + +def _require_bool(value: object, field_name: str) -> None: + if not isinstance(value, bool): + raise ValueError(f"{field_name} must be a boolean") + + +def _require_finite_number(value: object, field_name: str) -> float: + if isinstance(value, bool) or not isinstance(value, int | float): + raise ValueError(f"{field_name} must be numeric") + result = float(value) + if not math.isfinite(result): + raise ValueError(f"{field_name} must be finite") + return result + + +def _require_datetime(value: object, field_name: str) -> datetime_module.datetime: + if not isinstance(value, datetime_module.datetime): + raise ValueError(f"{field_name} must be a datetime") + if value.tzinfo is None or value.utcoffset() is None: + raise ValueError(f"{field_name} must include an explicit timezone") + return value + + +class StatisticalSupportWindowKind(StrEnum): + FULL_SUPPORT = "FULL_SUPPORT" + COUNT_WINDOW = "COUNT_WINDOW" + TIME_WINDOW = "TIME_WINDOW" + + +class MissingnessPolicy(StrEnum): + """V1 missingness policy; none of these policies imply imputation.""" + + COMPLETE_CASE_ONLY = "COMPLETE_CASE_ONLY" + EXPLICIT_EXCLUSION_ONLY = "EXPLICIT_EXCLUSION_ONLY" + NO_IMPUTATION_NO_ZERO_FILL = "NO_IMPUTATION_NO_ZERO_FILL" + + +class SupportExclusionReason(StrEnum): + OUTSIDE_WINDOW = "OUTSIDE_WINDOW" + NOT_SELECTED = "NOT_SELECTED" + INVALID_RESULT = "INVALID_RESULT" + MISSING_VALUE = "MISSING_VALUE" + INCOMPATIBLE_IDENTITY = "INCOMPATIBLE_IDENTITY" + DUPLICATE_OBSERVATION = "DUPLICATE_OBSERVATION" + + +class SupportSelectionStatus(StrEnum): + EXCLUDED = "EXCLUDED" + + +class MeasurementScaleKind(StrEnum): + RATIO = "RATIO" + INTERVAL = "INTERVAL" + ORDINAL = "ORDINAL" + NOMINAL = "NOMINAL" + UNKNOWN = "UNKNOWN" + + +class SignedValuePolicy(StrEnum): + SIGNED = "SIGNED" + NONNEGATIVE = "NONNEGATIVE" + STRICTLY_POSITIVE = "STRICTLY_POSITIVE" + + +class DenominatorPolicy(StrEnum): + NONZERO = "NONZERO" + STRICTLY_POSITIVE = "STRICTLY_POSITIVE" + + +class ScaleAuthorityOrigin(StrEnum): + PRODUCTION = "PRODUCTION" + SYNTHETIC_TEST = "SYNTHETIC_TEST" + + +class ReliabilityAssumptionDeclarationOrigin(StrEnum): + PRODUCTION = "PRODUCTION" + SYNTHETIC_TEST = "SYNTHETIC_TEST" + + +class ReliabilityQuestion(StrEnum): + REPEATABILITY = "REPEATABILITY" + REPRODUCIBILITY = "REPRODUCIBILITY" + + +class StableUnderlyingQuantityStatus(StrEnum): + SUPPORTED = "SUPPORTED" + UNSUPPORTED = "UNSUPPORTED" + UNRESOLVED = "UNRESOLVED" + + +class SystematicTrialEffectStatus(StrEnum): + NOT_ASSESSED = "NOT_ASSESSED" + NO_EVIDENCE_OF_SYSTEMATIC_EFFECT = "NO_EVIDENCE_OF_SYSTEMATIC_EFFECT" + SYSTEMATIC_EFFECT_PRESENT = "SYSTEMATIC_EFFECT_PRESENT" + UNRESOLVED = "UNRESOLVED" + + +class ReliabilityErrorScale(StrEnum): + RAW_ABSOLUTE = "RAW_ABSOLUTE" + RAW_RELATIVE = "RAW_RELATIVE" + LOG_MULTIPLICATIVE = "LOG_MULTIPLICATIVE" + + +class AuthorityStatus(StrEnum): + UNVERIFIED = "UNVERIFIED" + SOURCE_BOUND = "SOURCE_BOUND" + + +class RepeatedPairPolicy(StrEnum): + ONE_PAIR_PER_INDEPENDENT_SUBJECT = "ONE_PAIR_PER_INDEPENDENT_SUBJECT" + + +class StatisticalOperationDisposition(StrEnum): + IMPLEMENTED = "IMPLEMENTED" + REPRESENT_BUT_DO_NOT_COMPUTE = "REPRESENT_BUT_DO_NOT_COMPUTE" + DEFER = "DEFER" + REJECT = "REJECT" + + +class RES69ReasonCode(StrEnum): + """Operation-local granular refusal reasons; generic refusal classes stay unchanged.""" + + IDENTITY_UNRESOLVED = "RES69_IDENTITY_UNRESOLVED" + COMPARABILITY_UNESTABLISHED = "RES69_COMPARABILITY_UNESTABLISHED" + SCALE_SEMANTICS_UNREGISTERED = "RES69_SCALE_SEMANTICS_UNREGISTERED" + SCALE_OPERATION_NOT_AUTHORIZED = "RES69_SCALE_OPERATION_NOT_AUTHORIZED" + UNIT_MISMATCH = "RES69_UNIT_MISMATCH" + UNIT_CONVERSION_NOT_REGISTERED = "RES69_UNIT_CONVERSION_NOT_REGISTERED" + DATA_ADEQUACY_INSUFFICIENT = "RES69_DATA_ADEQUACY_INSUFFICIENT" + ANALYSIS_DESIGN_MISMATCH = "RES69_ANALYSIS_DESIGN_MISMATCH" + RELIABILITY_AUTHORITY_REQUIRED = "RES69_RELIABILITY_AUTHORITY_REQUIRED" + METHOD_COMPARISON_AUTHORITY_REQUIRED = "RES69_METHOD_COMPARISON_AUTHORITY_REQUIRED" + SUPPORT_MISMATCH = "RES69_SUPPORT_MISMATCH" + INCOMPLETE_PAIR = "RES69_INCOMPLETE_PAIR" + UNBALANCED_RELIABILITY_SUPPORT = "RES69_UNBALANCED_RELIABILITY_SUPPORT" + NONPOSITIVE_LOG_INPUT = "RES69_NONPOSITIVE_LOG_INPUT" + ZERO_REFERENCE_SD = "RES69_ZERO_REFERENCE_SD" + DUPLICATE_TIMESTAMP = "RES69_DUPLICATE_TIMESTAMP" + OPERATION_NOT_REGISTERED = "RES69_OPERATION_NOT_REGISTERED" + CLASSICAL_LOA_DEFERRED = "RES69_CLASSICAL_LOA_DEFERRED" + INTERPRETATION_NOT_AUTHORIZED = "RES69_INTERPRETATION_NOT_AUTHORIZED" + REGISTRY_INTEGRITY_FAILURE = "RES69_REGISTRY_INTEGRITY_FAILURE" + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class DerivedUnitReference: + """Deterministic composite unit without changing core UnitReference semantics.""" + + numerator: UnitReference + denominator: UnitReference + operation: RegistryReference + display_label: str + + def __post_init__(self) -> None: + _require_instance(self.numerator, UnitReference, "numerator") + _require_instance(self.denominator, UnitReference, "denominator") + _require_instance(self.operation, RegistryReference, "operation") + if self.operation.identifier.object_type != "unit-derivation": + raise ValueError("operation must identify a registered unit derivation") + _require_text(self.display_label, "display_label") + + @property + def stable_id(self) -> str: + return ( + f"{self.operation.stable_id}:" + f"{self.numerator.identifier.stable_id}/" + f"{self.denominator.identifier.stable_id}" + ) + + +type StatisticalUnitReference = UnitReference | DerivedUnitReference + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class MeasurementScaleSemanticKeyV1: + """Stable semantic dimensions used to look up scale authority. + + Instance, athlete, timestamp, session, artifact, acquisition, and + processing-run identities are intentionally absent from this key. + """ + + construct_id: ScientificIdentifier + test_family_id: ScientificIdentifier + measurand_id: ScientificIdentifier + metric_definition_id: ScientificIdentifier + unit_id: ScientificIdentifier + normalization_identity: NormalizationSpec | None + registered_operation_identity: ScientificIdentifier | None + estimator_identity: ScientificIdentifier | None + protocol_constraint: ScientificIdentifier | None = None + scale_relevant_method_parameters: tuple[MetadataEntry, ...] = () + + def __post_init__(self) -> None: + for field_name in ( + "construct_id", + "test_family_id", + "measurand_id", + "metric_definition_id", + "unit_id", + ): + value = _stable_identifier(getattr(self, field_name), field_name) + object.__setattr__(self, field_name, value) + for field_name in ( + "registered_operation_identity", + "estimator_identity", + "protocol_constraint", + ): + value = getattr(self, field_name) + if value is not None: + object.__setattr__(self, field_name, _stable_identifier(value, field_name)) + if self.unit_id.object_type != "unit": + raise ValueError("unit_id must identify a registered unit") + if self.normalization_identity is not None and not isinstance( + self.normalization_identity, + NormalizationSpec, + ): + raise ValueError("normalization_identity must be a NormalizationSpec") + _require_tuple_items( + self.scale_relevant_method_parameters, + MetadataEntry, + "scale_relevant_method_parameters", + ) + + @classmethod + def from_measurement_identity( + cls, + identity: MeasurementIdentity, + unit: UnitReference, + *, + scale_relevant_method_parameters: tuple[MetadataEntry, ...] | None = None, + protocol_constraint: RegistryReference | ScientificIdentifier | None = None, + ) -> MeasurementScaleSemanticKeyV1: + """Derive the key from identity semantics, never from identity_id.""" + + if not isinstance(identity, MeasurementIdentity): + raise ValueError("identity must be a MeasurementIdentity") + if not isinstance(unit, UnitReference): + raise ValueError("unit must be a UnitReference") + parameters = ( + () if scale_relevant_method_parameters is None else scale_relevant_method_parameters + ) + registered_operation = identity.processing.registered_operation + estimator = identity.processing.estimator + protocol = protocol_constraint + return cls( + construct_id=identity.semantic.construct.identifier, + test_family_id=identity.semantic.test_family.identifier, + measurand_id=identity.semantic.measurand.identifier, + metric_definition_id=identity.semantic.metric_definition.identifier, + unit_id=unit.identifier, + normalization_identity=identity.processing.normalization, + registered_operation_identity=( + registered_operation.identifier if registered_operation is not None else None + ), + estimator_identity=estimator.identifier if estimator is not None else None, + protocol_constraint=( + _stable_identifier(protocol, "protocol_constraint") + if protocol is not None + else None + ), + scale_relevant_method_parameters=parameters, + ) + + from_identity = from_measurement_identity + + @property + def stable_key(self) -> str: + return canonical_hash(self) + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class MeasurementScaleSemantics: + """Registered scale authority; a standalone instance is not authority.""" + + semantic_key: MeasurementScaleSemanticKeyV1 + scale_kind: MeasurementScaleKind + meaningful_zero: bool + signed_value_policy: SignedValuePolicy + denominator_policy: DenominatorPolicy + relative_change_authorized: bool + log_ratio_authorized: bool + raw_relative_error_authorized: bool + log_error_authorized: bool + authority_reference: RegistryReference + evidence_references: tuple[EvidenceReference, ...] + authority_origin: ScaleAuthorityOrigin + rationale: str + + def __post_init__(self) -> None: + _require_instance(self.semantic_key, MeasurementScaleSemanticKeyV1, "semantic_key") + _require_enum(self.scale_kind, MeasurementScaleKind, "scale_kind") + _require_bool(self.meaningful_zero, "meaningful_zero") + _require_enum(self.signed_value_policy, SignedValuePolicy, "signed_value_policy") + _require_enum(self.denominator_policy, DenominatorPolicy, "denominator_policy") + for field_name in ( + "relative_change_authorized", + "log_ratio_authorized", + "raw_relative_error_authorized", + "log_error_authorized", + ): + _require_bool(getattr(self, field_name), field_name) + if ( + self.relative_change_authorized or self.raw_relative_error_authorized + ) and self.scale_kind is not MeasurementScaleKind.RATIO: + raise ValueError("relative arithmetic requires registered ratio-scale semantics") + if self.log_ratio_authorized or self.log_error_authorized: + if self.scale_kind is not MeasurementScaleKind.RATIO: + raise ValueError("log arithmetic requires registered ratio-scale semantics") + if self.signed_value_policy is not SignedValuePolicy.STRICTLY_POSITIVE: + raise ValueError("log arithmetic requires a strictly-positive value policy") + _require_instance(self.authority_reference, RegistryReference, "authority_reference") + _require_tuple_items(self.evidence_references, EvidenceReference, "evidence_references") + if not self.evidence_references: + raise ValueError("scale semantics require evidence references") + _require_enum(self.authority_origin, ScaleAuthorityOrigin, "authority_origin") + _require_text(self.rationale, "rationale") + + @property + def scale_key(self) -> MeasurementScaleSemanticKeyV1: + return self.semantic_key + + @property + def relative_change_allowed(self) -> bool: + return self.relative_change_authorized + + @property + def log_ratio_allowed(self) -> bool: + return self.log_ratio_authorized + + @property + def raw_relative_error_allowed(self) -> bool: + return self.raw_relative_error_authorized + + @property + def log_error_allowed(self) -> bool: + return self.log_error_authorized + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class StatisticalSourceRecordReference: + record_id: InstanceIdentifier + record_hash: str + + def __post_init__(self) -> None: + _require_identifier(self.record_id, "longitudinal-athlete-record", "record_id") + _require_hash(self.record_hash, "record_hash") + + @classmethod + def from_record( + cls, record: LongitudinalAthletePerformanceRecord + ) -> StatisticalSourceRecordReference: + if not isinstance(record, LongitudinalAthletePerformanceRecord): + raise ValueError("record must be a LongitudinalAthletePerformanceRecord") + if record.record_id is None: + raise ValueError("record must have a canonical record ID") + return cls(record.record_id, record.canonical_record_hash) + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class StatisticalSourceArtifactReference: + artifact_id: InstanceIdentifier + content_digest: str + + def __post_init__(self) -> None: + _require_identifier(self.artifact_id, "artifact", "artifact_id") + _require_text(self.content_digest, "content_digest") + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class StatisticalProvenanceReference: + provenance_id: InstanceIdentifier + provenance_hash: str + + def __post_init__(self) -> None: + _require_identifier(self.provenance_id, "provenance", "provenance_id") + _require_hash(self.provenance_hash, "provenance_hash") + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class StatisticalObservationReference: + """Exact immutable reference to one RES-62 observation entry.""" + + entry_id: InstanceIdentifier + entry_hash: str + observation_id: InstanceIdentifier + observation_hash: str + athlete_id: InstanceIdentifier + session_id: InstanceIdentifier + trial_id: InstanceIdentifier | None + + def __post_init__(self) -> None: + _require_identifier(self.entry_id, "longitudinal-entry", "entry_id") + _require_hash(self.entry_hash, "entry_hash") + _require_identifier(self.observation_id, "observation", "observation_id") + _require_hash(self.observation_hash, "observation_hash") + _require_identifier(self.athlete_id, "athlete", "athlete_id") + _require_identifier(self.session_id, "session", "session_id") + _require_optional_instance(self.trial_id, InstanceIdentifier, "trial_id") + if self.trial_id is not None and self.trial_id.instance_type != "trial": + raise ValueError("trial_id must identify a trial") + + @classmethod + def from_entry(cls, entry: LongitudinalObservationEntry) -> StatisticalObservationReference: + if not isinstance(entry, LongitudinalObservationEntry): + raise ValueError("entry must be a LongitudinalObservationEntry") + return cls( + entry_id=entry.canonical_entry_id, + entry_hash=entry.canonical_entry_hash, + observation_id=entry.source_observation_id, + observation_hash=canonical_hash(entry.observation), + athlete_id=entry.observation.context.athlete_id, + session_id=entry.session_id, + trial_id=entry.observation.context.trial_id, + ) + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class SupportEntryExclusion: + reference: StatisticalObservationReference + reason: SupportExclusionReason + status: SupportSelectionStatus = SupportSelectionStatus.EXCLUDED + note: str | None = None + + def __post_init__(self) -> None: + _require_instance(self.reference, StatisticalObservationReference, "reference") + _require_enum(self.reason, SupportExclusionReason, "reason") + _require_enum(self.status, SupportSelectionStatus, "status") + if self.note is not None: + _require_text(self.note, "note") + + @classmethod + def from_entry( + cls, + entry: LongitudinalObservationEntry, + reason: SupportExclusionReason, + *, + note: str | None = None, + ) -> SupportEntryExclusion: + return cls(StatisticalObservationReference.from_entry(entry), reason, note=note) + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class StatisticalWindow: + kind: StatisticalSupportWindowKind + start: datetime_module.datetime | None = None + end: datetime_module.datetime | None = None + start_inclusive: bool = True + end_inclusive: bool = True + count: int | None = None + + def __post_init__(self) -> None: + _require_enum(self.kind, StatisticalSupportWindowKind, "kind") + _require_bool(self.start_inclusive, "start_inclusive") + _require_bool(self.end_inclusive, "end_inclusive") + if self.start is not None: + _require_datetime(self.start, "start") + if self.end is not None: + _require_datetime(self.end, "end") + if self.start is not None and self.end is not None and self.start > self.end: + raise ValueError("window start must not exceed window end") + if self.kind is StatisticalSupportWindowKind.FULL_SUPPORT: + if self.start is not None or self.end is not None or self.count is not None: + raise ValueError("FULL_SUPPORT must not carry bounds or count") + elif self.kind is StatisticalSupportWindowKind.COUNT_WINDOW: + if self.count is None or isinstance(self.count, bool) or self.count < 1: + raise ValueError("COUNT_WINDOW requires a positive count") + if self.start is not None or self.end is not None: + raise ValueError("COUNT_WINDOW must not carry time bounds") + elif self.kind is StatisticalSupportWindowKind.TIME_WINDOW: + if self.start is None or self.end is None: + raise ValueError("TIME_WINDOW requires start and end") + if self.count is not None: + raise ValueError("TIME_WINDOW must not carry a count") + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class StatisticalComparabilityEvidence: + request: ComparabilityRequest + result: ComparabilityResult + + def __post_init__(self) -> None: + _require_instance(self.request, ComparabilityRequest, "request") + _require_instance(self.result, ComparabilityResult, "result") + _require_identifier( + self.request.request_id, + "comparability-request", + "request.request_id", + ) + _require_identifier( + self.request.left_observation_id, + "observation", + "request.left_observation_id", + ) + _require_identifier( + self.request.right_observation_id, + "observation", + "request.right_observation_id", + ) + if self.result.request_id != self.request.request_id: + raise ValueError("comparability evidence result must match its request") + if self.result.state not in ( + ComparabilityState.COMPARABLE, + ComparabilityState.COMPARABLE_WITH_CONDITIONS, + ): + raise ValueError("statistical support requires an affirmative comparability result") + if self.result.rule_reference is None: + raise ValueError("comparability evidence must identify its deterministic rule") + if self.result.decided_by is not ComparabilityDecisionSource.DETERMINISTIC_RULE: + raise ValueError("comparability evidence must be deterministic-rule authority") + + @property + def pair_ids(self) -> tuple[InstanceIdentifier, InstanceIdentifier]: + return self.request.left_observation_id, self.request.right_observation_id + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class StatisticalSupport: + """Exact RES-62-backed support for one or more deterministic operations.""" + + analysis_inputs: tuple[MultiSourceAnalysisInput, ...] + included_entries: tuple[LongitudinalObservationEntry, ...] + excluded_entries: tuple[SupportEntryExclusion, ...] + missingness_policy: MissingnessPolicy + window: StatisticalWindow + source_records: tuple[StatisticalSourceRecordReference, ...] + comparability_evidence: tuple[StatisticalComparabilityEvidence, ...] = () + current_entry_id: InstanceIdentifier | None = None + reference_entry_ids: tuple[InstanceIdentifier, ...] = () + support_id: InstanceIdentifier | None = None + support_hash: str | None = None + + def __post_init__(self) -> None: + require_tuple(self.analysis_inputs, "analysis_inputs") + if not self.analysis_inputs: + raise ValueError("statistical support requires at least one RES-62 analysis input") + if any(not isinstance(item, MultiSourceAnalysisInput) for item in self.analysis_inputs): + raise ValueError("analysis_inputs must contain MultiSourceAnalysisInput values") + expected_inputs = tuple( + sorted( + self.analysis_inputs, + key=lambda item: (item.athlete.athlete_id.qualified, item.input_id.qualified), + ) + ) + input_ids = tuple(item.input_id.qualified for item in expected_inputs) + if len(input_ids) != len(set(input_ids)): + raise ValueError("analysis_inputs must not contain duplicate exact input IDs") + if self.analysis_inputs != expected_inputs: + raise ValueError("analysis_inputs must use canonical athlete/input ordering") + require_tuple(self.included_entries, "included_entries") + if not self.included_entries: + raise ValueError("statistical support requires at least one included entry") + if any( + not isinstance(item, LongitudinalObservationEntry) for item in self.included_entries + ): + raise ValueError("included_entries must contain LongitudinalObservationEntry values") + require_tuple(self.excluded_entries, "excluded_entries") + if any(not isinstance(item, SupportEntryExclusion) for item in self.excluded_entries): + raise ValueError("excluded_entries must contain SupportEntryExclusion values") + expected_exclusions = tuple( + sorted(self.excluded_entries, key=lambda item: item.reference.entry_id.qualified) + ) + if self.excluded_entries != expected_exclusions: + raise ValueError("excluded_entries must use canonical entry ordering") + _require_enum(self.missingness_policy, MissingnessPolicy, "missingness_policy") + _require_instance(self.window, StatisticalWindow, "window") + require_tuple(self.source_records, "source_records") + if any( + not isinstance(item, StatisticalSourceRecordReference) for item in self.source_records + ): + raise ValueError("source_records must contain StatisticalSourceRecordReference values") + require_tuple(self.comparability_evidence, "comparability_evidence") + if any( + not isinstance(item, StatisticalComparabilityEvidence) + for item in self.comparability_evidence + ): + raise ValueError( + "comparability_evidence must contain StatisticalComparabilityEvidence values" + ) + _require_optional_instance(self.current_entry_id, InstanceIdentifier, "current_entry_id") + if ( + self.current_entry_id is not None + and self.current_entry_id.instance_type != "longitudinal-entry" + ): + raise ValueError("current_entry_id must identify a longitudinal entry") + require_tuple(self.reference_entry_ids, "reference_entry_ids") + for entry_id in self.reference_entry_ids: + _require_identifier(entry_id, "longitudinal-entry", "reference_entry_ids item") + _require_optional_instance(self.support_id, InstanceIdentifier, "support_id") + if self.support_id is not None and self.support_id.instance_type != "statistical-support": + raise ValueError("support_id must identify statistical support") + if self.support_hash is not None: + _require_hash(self.support_hash, "support_hash") + + input_entries: dict[str, LongitudinalObservationEntry] = {} + input_athletes: dict[str, str] = {} + for analysis_input in self.analysis_inputs: + for entry in analysis_input.entries: + key = entry.canonical_entry_id.qualified + if key in input_entries and input_entries[key] != entry: + raise ValueError("analysis inputs contain conflicting entry content") + input_entries[key] = entry + input_athletes[key] = analysis_input.athlete.athlete_id.qualified + included_ids = tuple(entry.canonical_entry_id.qualified for entry in self.included_entries) + if len(set(included_ids)) != len(included_ids): + raise ValueError("included_entries must not contain duplicate entry IDs") + expected_order = tuple( + sorted( + self.included_entries, + key=lambda entry: ( + entry.observed_at.astimezone(datetime_module.UTC).isoformat( + timespec="microseconds" + ), + entry.source_observation_id.qualified, + entry.canonical_entry_hash, + ), + ) + ) + if self.included_entries != expected_order: + raise ValueError("included_entries must use canonical temporal ordering") + for entry in self.included_entries: + key = entry.canonical_entry_id.qualified + if key not in input_entries or input_entries[key] != entry: + raise ValueError("included entry is not an exact entry of an analysis input") + excluded_ids = tuple(item.reference.entry_id.qualified for item in self.excluded_entries) + if len(set(excluded_ids)) != len(excluded_ids): + raise ValueError("excluded_entries must not contain duplicate entry IDs") + for item in self.excluded_entries: + key = item.reference.entry_id.qualified + matched_entry = input_entries.get(key) + if ( + matched_entry is None + or StatisticalObservationReference.from_entry(matched_entry) != item.reference + ): + raise ValueError("excluded entry reference does not match an analysis input") + if set(included_ids) & set(excluded_ids): + raise ValueError("an entry cannot be both included and excluded") + if set(included_ids) | set(excluded_ids) != set(input_entries): + raise ValueError( + "support must explicitly account for every analysis-input entry; " + "hidden filtering is forbidden" + ) + reference_set = set(self.reference_entry_ids) + if len(reference_set) != len(self.reference_entry_ids): + raise ValueError("reference_entry_ids must be unique") + if self.current_entry_id is not None: + if self.current_entry_id not in { + entry.canonical_entry_id for entry in self.included_entries + }: + raise ValueError("current_entry_id must identify an included entry") + if self.current_entry_id in reference_set: + raise ValueError("current entry must be excluded from reference entries") + if any( + entry_id not in {entry.canonical_entry_id for entry in self.included_entries} + for entry_id in self.reference_entry_ids + ): + raise ValueError("reference entries must be included in support") + reference_entries = tuple( + entry for entry in self.included_entries if entry.canonical_entry_id in reference_set + ) + expected_reference_ids = tuple(entry.canonical_entry_id for entry in reference_entries) + if self.reference_entry_ids != expected_reference_ids: + raise ValueError("reference_entry_ids must use canonical temporal ordering") + source_record_ids = tuple(item.record_id.qualified for item in self.source_records) + if len(set(source_record_ids)) != len(source_record_ids): + raise ValueError("source_records must be unique") + if self.source_records != tuple( + sorted(self.source_records, key=lambda item: item.record_id.qualified) + ): + raise ValueError("source_records must use canonical record ordering") + if self.window.kind is StatisticalSupportWindowKind.COUNT_WINDOW: + assert self.window.count is not None + if self.window.count != len(self.included_entries): + raise ValueError("COUNT_WINDOW count must equal the selected entry count") + if self.window.kind is StatisticalSupportWindowKind.TIME_WINDOW: + assert self.window.start is not None and self.window.end is not None + for entry in self.included_entries: + at = entry.observed_at + left = ( + at >= self.window.start + if self.window.start_inclusive + else at > self.window.start + ) + right = at <= self.window.end if self.window.end_inclusive else at < self.window.end + if not left or not right: + raise ValueError("included entry falls outside its TIME_WINDOW bounds") + if self.analysis_inputs and any( + analysis_input.athlete.athlete_id.qualified not in input_athletes.values() + for analysis_input in self.analysis_inputs + ): + raise ValueError("analysis input athlete identity is not represented") + + expected_hash = _statistical_support_hash(self) + expected_id = InstanceIdentifier( + "statistical-support", + expected_hash.removeprefix(_SHA256_PREFIX), + ) + if self.support_hash is None: + object.__setattr__(self, "support_hash", expected_hash) + elif self.support_hash != expected_hash: + raise ValueError("support_hash does not match exact support content") + if self.support_id is None: + object.__setattr__(self, "support_id", expected_id) + elif self.support_id != expected_id: + raise ValueError("support_id does not match exact support content") + + @property + def input_ids(self) -> tuple[InstanceIdentifier, ...]: + return tuple(item.input_id for item in self.analysis_inputs) + + @property + def input_hashes(self) -> tuple[str, ...]: + return tuple(item.input_hash for item in self.analysis_inputs) + + @property + def input_id(self) -> InstanceIdentifier: + if len(self.analysis_inputs) != 1: + raise ValueError("support contains multiple analysis inputs") + return self.analysis_inputs[0].input_id + + @property + def analysis_input(self) -> MultiSourceAnalysisInput: + if len(self.analysis_inputs) != 1: + raise ValueError("support contains multiple analysis inputs") + return self.analysis_inputs[0] + + @property + def canonical_support_id(self) -> InstanceIdentifier: + assert self.support_id is not None + return self.support_id + + @property + def canonical_support_hash(self) -> str: + assert self.support_hash is not None + return self.support_hash + + @property + def source_observation_ids(self) -> tuple[InstanceIdentifier, ...]: + return tuple(entry.source_observation_id for entry in self.included_entries) + + @property + def source_entry_ids(self) -> tuple[InstanceIdentifier, ...]: + return tuple(entry.canonical_entry_id for entry in self.included_entries) + + @property + def source_entry_hashes(self) -> tuple[str, ...]: + return tuple(entry.canonical_entry_hash for entry in self.included_entries) + + @property + def source_observation_hashes(self) -> tuple[str, ...]: + return tuple(canonical_hash(entry.observation) for entry in self.included_entries) + + @property + def entries(self) -> tuple[LongitudinalObservationEntry, ...]: + return self.included_entries + + @property + def selected_observation_ids(self) -> tuple[InstanceIdentifier, ...]: + return self.source_observation_ids + + @property + def source_record_ids(self) -> tuple[InstanceIdentifier, ...]: + return tuple(item.record_id for item in self.source_records) + + @property + def source_record_hashes(self) -> tuple[str, ...]: + return tuple(item.record_hash for item in self.source_records) + + @property + def excluded_observation_ids(self) -> tuple[InstanceIdentifier, ...]: + return tuple(item.reference.observation_id for item in self.excluded_entries) + + @property + def excluded_entry_hashes(self) -> tuple[str, ...]: + return tuple(item.reference.entry_hash for item in self.excluded_entries) + + @property + def athlete_ids(self) -> tuple[InstanceIdentifier, ...]: + seen: dict[str, InstanceIdentifier] = {} + for entry in self.included_entries: + seen.setdefault( + entry.observation.context.athlete_id.qualified, entry.observation.context.athlete_id + ) + return tuple(seen.values()) + + @property + def source_artifacts(self) -> tuple[StatisticalSourceArtifactReference, ...]: + by_id: dict[str, StatisticalSourceArtifactReference] = {} + for entry in (*self.included_entries, *(item for item in self._excluded_entry_objects())): + for artifact in entry.observation.provenance.source_artifacts: + reference = StatisticalSourceArtifactReference( + artifact.artifact_id, + artifact.content_digest, + ) + previous = by_id.get(reference.artifact_id.qualified) + if previous is not None and previous != reference: + raise ValueError("source artifact identity is conflicting in support") + by_id[reference.artifact_id.qualified] = reference + return tuple(by_id[key] for key in sorted(by_id)) + + @property + def source_provenance(self) -> tuple[StatisticalProvenanceReference, ...]: + by_id: dict[str, StatisticalProvenanceReference] = {} + for entry in (*self.included_entries, *(item for item in self._excluded_entry_objects())): + provenance = entry.observation.provenance + reference = StatisticalProvenanceReference( + provenance.provenance_id, + canonical_hash(provenance), + ) + by_id[reference.provenance_id.qualified] = reference + return tuple(by_id[key] for key in sorted(by_id)) + + def _excluded_entry_objects(self) -> tuple[LongitudinalObservationEntry, ...]: + by_id = { + entry.canonical_entry_id.qualified: entry + for analysis_input in self.analysis_inputs + for entry in analysis_input.entries + } + return tuple(by_id[item.reference.entry_id.qualified] for item in self.excluded_entries) + + +def _statistical_support_hash(support: StatisticalSupport) -> str: + return canonical_hash( + { + "analysis_inputs": support.analysis_inputs, + "included_entries": support.included_entries, + "excluded_entries": support.excluded_entries, + "missingness_policy": support.missingness_policy, + "window": support.window, + "source_records": support.source_records, + "comparability_evidence": support.comparability_evidence, + "current_entry_id": support.current_entry_id, + "reference_entry_ids": support.reference_entry_ids, + } + ) + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class ReliabilityReplicatePair: + pair_id: InstanceIdentifier + athlete_id: InstanceIdentifier + occasion_id: InstanceIdentifier + first: StatisticalObservationReference + second: StatisticalObservationReference + + def __post_init__(self) -> None: + _require_identifier(self.pair_id, "reliability-pair", "pair_id") + _require_identifier(self.athlete_id, "athlete", "athlete_id") + _require_identifier(self.occasion_id, "occasion", "occasion_id") + _require_instance(self.first, StatisticalObservationReference, "first") + _require_instance(self.second, StatisticalObservationReference, "second") + if self.first.athlete_id != self.athlete_id or self.second.athlete_id != self.athlete_id: + raise ValueError("replicate pair athlete identity must match both observations") + if self.first.observation_id == self.second.observation_id: + raise ValueError("replicate pair requires two distinct observations") + expected_id = InstanceIdentifier( + "reliability-pair", + canonical_hash( + { + "athlete_id": self.athlete_id, + "occasion_id": self.occasion_id, + "first": self.first, + "second": self.second, + } + ).removeprefix(_SHA256_PREFIX), + ) + if self.pair_id != expected_id: + raise ValueError("pair_id does not match immutable replicate ordering and content") + + @classmethod + def from_entries( + cls, + first: LongitudinalObservationEntry, + second: LongitudinalObservationEntry, + *, + occasion_id: InstanceIdentifier | None = None, + ) -> ReliabilityReplicatePair: + first_ref = StatisticalObservationReference.from_entry(first) + second_ref = StatisticalObservationReference.from_entry(second) + if first_ref.athlete_id != second_ref.athlete_id: + raise ValueError("replicate pair entries must belong to one athlete") + occasion = occasion_id or InstanceIdentifier("occasion", first_ref.session_id.value) + _require_identifier(occasion, "occasion", "occasion_id") + pair_id = InstanceIdentifier( + "reliability-pair", + canonical_hash( + { + "athlete_id": first_ref.athlete_id, + "occasion_id": occasion, + "first": first_ref, + "second": second_ref, + } + ).removeprefix(_SHA256_PREFIX), + ) + return cls(pair_id, first_ref.athlete_id, occasion, first_ref, second_ref) + + @property + def first_entry_id(self) -> InstanceIdentifier: + return self.first.entry_id + + @property + def second_entry_id(self) -> InstanceIdentifier: + return self.second.entry_id + + @property + def first_observation_id(self) -> InstanceIdentifier: + return self.first.observation_id + + @property + def second_observation_id(self) -> InstanceIdentifier: + return self.second.observation_id + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class MethodComparisonPair: + pair_id: InstanceIdentifier + athlete_id: InstanceIdentifier + occasion_id: InstanceIdentifier + method_a: StatisticalObservationReference + method_b: StatisticalObservationReference + + def __post_init__(self) -> None: + _require_identifier(self.pair_id, "method-comparison-pair", "pair_id") + _require_identifier(self.athlete_id, "athlete", "athlete_id") + _require_identifier(self.occasion_id, "occasion", "occasion_id") + _require_instance(self.method_a, StatisticalObservationReference, "method_a") + _require_instance(self.method_b, StatisticalObservationReference, "method_b") + if ( + self.method_a.athlete_id != self.athlete_id + or self.method_b.athlete_id != self.athlete_id + ): + raise ValueError("method comparison pair athlete identity must match both observations") + if self.method_a.observation_id == self.method_b.observation_id: + raise ValueError("method comparison pair requires two distinct observations") + expected_id = InstanceIdentifier( + "method-comparison-pair", + canonical_hash( + { + "athlete_id": self.athlete_id, + "occasion_id": self.occasion_id, + "method_a": self.method_a, + "method_b": self.method_b, + } + ).removeprefix(_SHA256_PREFIX), + ) + if self.pair_id != expected_id: + raise ValueError("pair_id does not match immutable method pairing and content") + + @classmethod + def from_entries( + cls, + method_a: LongitudinalObservationEntry, + method_b: LongitudinalObservationEntry, + *, + occasion_id: InstanceIdentifier | None = None, + ) -> MethodComparisonPair: + first = StatisticalObservationReference.from_entry(method_a) + second = StatisticalObservationReference.from_entry(method_b) + if first.athlete_id != second.athlete_id: + raise ValueError("method comparison entries must belong to one athlete") + occasion = occasion_id or InstanceIdentifier("occasion", first.session_id.value) + _require_identifier(occasion, "occasion", "occasion_id") + pair_id = InstanceIdentifier( + "method-comparison-pair", + canonical_hash( + { + "athlete_id": first.athlete_id, + "occasion_id": occasion, + "method_a": first, + "method_b": second, + } + ).removeprefix(_SHA256_PREFIX), + ) + return cls(pair_id, first.athlete_id, occasion, first, second) + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class SystematicTrialEffectAssessment: + status: SystematicTrialEffectStatus + evidence_references: tuple[EvidenceReference, ...] + description: str + + def __post_init__(self) -> None: + _require_enum(self.status, SystematicTrialEffectStatus, "status") + _require_tuple_items(self.evidence_references, EvidenceReference, "evidence_references") + _require_text(self.description, "description") + + +def _reliability_assumption_declaration_hash( + declaration: ReliabilityAssumptionDeclarationV1, +) -> str: + return canonical_hash( + { + "declaration_reference": declaration.declaration_reference, + "study_identity": declaration.study_identity, + "protocol_reference": declaration.protocol_reference, + "stable_underlying_quantity": declaration.stable_underlying_quantity, + "systematic_trial_effect": declaration.systematic_trial_effect, + "error_scale": declaration.error_scale, + "evidence_references": declaration.evidence_references, + "producing_method": declaration.producing_method, + "registry_version": declaration.registry_version, + "authority_origin": declaration.authority_origin, + } + ) + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class ReliabilityAssumptionDeclarationV1: + """Immutable scientific authority declared by an owning study/protocol registry.""" + + declaration_reference: RegistryReference + study_identity: RegistryReference + protocol_reference: RegistryReference + stable_underlying_quantity: StableUnderlyingQuantityStatus + systematic_trial_effect: SystematicTrialEffectAssessment + error_scale: ReliabilityErrorScale + evidence_references: tuple[EvidenceReference, ...] + producing_method: RegistryReference + registry_version: str + authority_origin: ReliabilityAssumptionDeclarationOrigin + declaration_hash: str | None = None + + def __post_init__(self) -> None: + for field_name in ( + "declaration_reference", + "study_identity", + "protocol_reference", + "producing_method", + ): + _require_instance(getattr(self, field_name), RegistryReference, field_name) + if ( + self.declaration_reference.identifier.object_type + != "reliability-assumption-declaration" + ): + raise ValueError( + "declaration_reference must identify a reliability-assumption-declaration" + ) + _require_enum( + self.stable_underlying_quantity, + StableUnderlyingQuantityStatus, + "stable_underlying_quantity", + ) + _require_instance( + self.systematic_trial_effect, + SystematicTrialEffectAssessment, + "systematic_trial_effect", + ) + _require_enum(self.error_scale, ReliabilityErrorScale, "error_scale") + _require_tuple_items(self.evidence_references, EvidenceReference, "evidence_references") + if not self.evidence_references: + raise ValueError("reliability assumption declaration requires evidence references") + _require_text(self.registry_version, "registry_version") + _require_enum( + self.authority_origin, + ReliabilityAssumptionDeclarationOrigin, + "authority_origin", + ) + if self.declaration_hash is not None: + _require_hash(self.declaration_hash, "declaration_hash") + expected_hash = _reliability_assumption_declaration_hash(self) + if self.declaration_hash is None: + object.__setattr__(self, "declaration_hash", expected_hash) + elif self.declaration_hash != expected_hash: + raise ValueError("reliability assumption declaration hash is invalid") + + @property + def canonical_declaration_hash(self) -> str: + assert self.declaration_hash is not None + return self.declaration_hash + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class ReliabilityAssumptionSourceEvidence: + """Exact source/protocol bindings for a registered reliability declaration.""" + + support: StatisticalSupport + source_records: tuple[LongitudinalAthletePerformanceRecord, ...] + study_identity: RegistryReference + protocol_reference: RegistryReference + declaration_reference: RegistryReference + declaration_hash: str + evidence_references: tuple[EvidenceReference, ...] + producing_method: RegistryReference + registry_version: str + + def __post_init__(self) -> None: + _require_instance(self.support, StatisticalSupport, "support") + _require_tuple_items( + self.source_records, LongitudinalAthletePerformanceRecord, "source_records" + ) + if not self.source_records: + raise ValueError("reliability assumption evidence requires source records") + _require_instance(self.study_identity, RegistryReference, "study_identity") + _require_instance(self.protocol_reference, RegistryReference, "protocol_reference") + _require_instance(self.declaration_reference, RegistryReference, "declaration_reference") + if ( + self.declaration_reference.identifier.object_type + != "reliability-assumption-declaration" + ): + raise ValueError( + "declaration_reference must identify a reliability-assumption-declaration" + ) + _require_hash(self.declaration_hash, "declaration_hash") + _require_tuple_items(self.evidence_references, EvidenceReference, "evidence_references") + if not self.evidence_references: + raise ValueError("reliability assumption evidence requires evidence references") + _require_instance(self.producing_method, RegistryReference, "producing_method") + _require_text(self.registry_version, "registry_version") + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class ReliabilityAssumptionAssessment: + """Normalized source-bound authority for reliability assumptions.""" + + support_id: InstanceIdentifier + support_hash: str + analysis_input_ids: tuple[InstanceIdentifier, ...] + analysis_input_hashes: tuple[str, ...] + source_records: tuple[StatisticalSourceRecordReference, ...] + source_artifacts: tuple[StatisticalSourceArtifactReference, ...] + source_provenance: tuple[StatisticalProvenanceReference, ...] + study_identity: RegistryReference + protocol_reference: RegistryReference + declaration_reference: RegistryReference + declaration_hash: str + evidence_references: tuple[EvidenceReference, ...] + stable_underlying_quantity: StableUnderlyingQuantityStatus + systematic_trial_effect: SystematicTrialEffectAssessment + error_scale: ReliabilityErrorScale + producing_method: RegistryReference + registry_version: str + source_evidence: ReliabilityAssumptionSourceEvidence + source_evidence_hash: str + authority_status: AuthorityStatus = AuthorityStatus.UNVERIFIED + authority_hash: str | None = None + authority_token: str | None = None + + def __post_init__(self) -> None: + _require_identifier(self.support_id, "statistical-support", "support_id") + _require_hash(self.support_hash, "support_hash") + require_tuple(self.analysis_input_ids, "analysis_input_ids") + require_tuple(self.analysis_input_hashes, "analysis_input_hashes") + if len(self.analysis_input_ids) != len(self.analysis_input_hashes): + raise ValueError("analysis input IDs and hashes must have equal length") + for item in self.analysis_input_ids: + _require_identifier(item, "multi-source-analysis-input", "analysis_input_ids item") + for input_hash in self.analysis_input_hashes: + _require_hash(input_hash, "analysis_input_hashes item") + _require_tuple_items( + self.source_records, StatisticalSourceRecordReference, "source_records" + ) + _require_tuple_items( + self.source_artifacts, StatisticalSourceArtifactReference, "source_artifacts" + ) + _require_tuple_items( + self.source_provenance, StatisticalProvenanceReference, "source_provenance" + ) + _require_instance(self.study_identity, RegistryReference, "study_identity") + _require_instance(self.protocol_reference, RegistryReference, "protocol_reference") + _require_instance(self.declaration_reference, RegistryReference, "declaration_reference") + if ( + self.declaration_reference.identifier.object_type + != "reliability-assumption-declaration" + ): + raise ValueError( + "declaration_reference must identify a reliability-assumption-declaration" + ) + _require_hash(self.declaration_hash, "declaration_hash") + _require_tuple_items(self.evidence_references, EvidenceReference, "evidence_references") + _require_enum( + self.stable_underlying_quantity, + StableUnderlyingQuantityStatus, + "stable_underlying_quantity", + ) + _require_instance( + self.systematic_trial_effect, + SystematicTrialEffectAssessment, + "systematic_trial_effect", + ) + _require_enum(self.error_scale, ReliabilityErrorScale, "error_scale") + _require_instance(self.producing_method, RegistryReference, "producing_method") + _require_text(self.registry_version, "registry_version") + _require_instance( + self.source_evidence, + ReliabilityAssumptionSourceEvidence, + "source_evidence", + ) + _require_hash(self.source_evidence_hash, "source_evidence_hash") + _require_enum(self.authority_status, AuthorityStatus, "authority_status") + + source_support = self.source_evidence.support + if self.support_id != source_support.canonical_support_id: + raise ValueError("assumption assessment support ID does not match source evidence") + if self.support_hash != source_support.canonical_support_hash: + raise ValueError("assumption assessment support hash does not match source evidence") + if self.analysis_input_ids != source_support.input_ids: + raise ValueError("assumption assessment input IDs do not match source evidence") + if self.analysis_input_hashes != source_support.input_hashes: + raise ValueError("assumption assessment input hashes do not match source evidence") + expected_records = tuple( + sorted( + ( + StatisticalSourceRecordReference.from_record(record) + for record in self.source_evidence.source_records + ), + key=lambda item: item.record_id.qualified, + ) + ) + if self.source_records != expected_records: + raise ValueError("assumption assessment records do not match source evidence") + if self.source_artifacts != source_support.source_artifacts: + raise ValueError("assumption assessment artifacts do not match source evidence") + if self.source_provenance != source_support.source_provenance: + raise ValueError("assumption assessment provenance does not match source evidence") + for field_name in ( + "study_identity", + "protocol_reference", + "declaration_reference", + "declaration_hash", + "evidence_references", + "producing_method", + "registry_version", + ): + if getattr(self, field_name) != getattr(self.source_evidence, field_name): + raise ValueError( + f"assumption assessment {field_name} does not match source evidence" + ) + if self.source_evidence_hash != canonical_hash(self.source_evidence): + raise ValueError("assumption assessment source evidence hash is invalid") + expected_hash = _authority_hash(self) + if self.authority_hash is None: + object.__setattr__(self, "authority_hash", expected_hash) + elif self.authority_hash != expected_hash: + raise ValueError( + "reliability assumption assessment hash does not match immutable content" + ) + if self.authority_status is AuthorityStatus.SOURCE_BOUND: + if self.authority_token != _authority_token(expected_hash): + raise ValueError("source-bound reliability assumption assessment proof is invalid") + elif self.authority_token is not None: + raise ValueError( + "unverified reliability assumption assessment must not carry an authority token" + ) + + @property + def is_source_bound(self) -> bool: + return ( + self.authority_status is AuthorityStatus.SOURCE_BOUND + and self.authority_token == _authority_token(self.canonical_authority_hash) + ) + + @property + def canonical_authority_hash(self) -> str: + assert self.authority_hash is not None + return self.authority_hash + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class ReliabilityDesignEvidence: + """Source/protocol evidence from which reliability authority is normalized.""" + + support: StatisticalSupport + source_records: tuple[LongitudinalAthletePerformanceRecord, ...] + study_identity: RegistryReference + replicate_pairs: tuple[ReliabilityReplicatePair, ...] + replicate_ordering: RegistryReference + target_measurement_identity_ids: tuple[ScientificIdentifier, ...] + target_construct: RegistryReference + target_test_family: RegistryReference + target_measurand: RegistryReference + target_metric_definition: RegistryReference + target_unit: UnitReference + protocol_reference: RegistryReference + method_identity: RegistryReference | None + device_identity: RegistryReference | None + rater_identity: RegistryReference | None + evidence_references: tuple[EvidenceReference, ...] + repeatability_question: ReliabilityQuestion + assumption_assessment: ReliabilityAssumptionAssessment + missingness_policy: MissingnessPolicy + balanced_design: bool + producing_method: RegistryReference + registry_version: str + design_identity: RegistryReference | None = None + + def __post_init__(self) -> None: + _require_instance(self.support, StatisticalSupport, "support") + _require_tuple_items( + self.source_records, LongitudinalAthletePerformanceRecord, "source_records" + ) + if not self.source_records: + raise ValueError("reliability design evidence requires source records") + _require_instance( + self.assumption_assessment, + ReliabilityAssumptionAssessment, + "assumption_assessment", + ) + _require_instance(self.study_identity, RegistryReference, "study_identity") + require_tuple(self.replicate_pairs, "replicate_pairs") + if any(not isinstance(item, ReliabilityReplicatePair) for item in self.replicate_pairs): + raise ValueError("replicate_pairs must contain ReliabilityReplicatePair values") + if not self.replicate_pairs: + raise ValueError("reliability design evidence requires replicate pairs") + _require_instance(self.replicate_ordering, RegistryReference, "replicate_ordering") + require_tuple(self.target_measurement_identity_ids, "target_measurement_identity_ids") + object.__setattr__( + self, + "target_measurement_identity_ids", + tuple( + _require_scientific_identifier(item, "target_measurement_identity_ids item") + for item in self.target_measurement_identity_ids + ), + ) + if not self.target_measurement_identity_ids: + raise ValueError("target measurement identity IDs must not be empty") + for field_name in ( + "target_construct", + "target_test_family", + "target_measurand", + "target_metric_definition", + "protocol_reference", + "producing_method", + ): + _require_instance(getattr(self, field_name), RegistryReference, field_name) + _require_instance(self.target_unit, UnitReference, "target_unit") + _require_optional_instance(self.method_identity, RegistryReference, "method_identity") + _require_optional_instance(self.device_identity, RegistryReference, "device_identity") + _require_optional_instance(self.rater_identity, RegistryReference, "rater_identity") + _require_tuple_items(self.evidence_references, EvidenceReference, "evidence_references") + if not self.evidence_references: + raise ValueError("reliability design evidence requires evidence references") + _require_enum(self.repeatability_question, ReliabilityQuestion, "repeatability_question") + _require_enum(self.missingness_policy, MissingnessPolicy, "missingness_policy") + _require_bool(self.balanced_design, "balanced_design") + _require_text(self.registry_version, "registry_version") + _require_optional_instance(self.design_identity, RegistryReference, "design_identity") + + @property + def stable_underlying_quantity(self) -> StableUnderlyingQuantityStatus: + return self.assumption_assessment.stable_underlying_quantity + + @property + def systematic_trial_effect(self) -> SystematicTrialEffectAssessment: + return self.assumption_assessment.systematic_trial_effect + + @property + def error_scale(self) -> ReliabilityErrorScale: + return self.assumption_assessment.error_scale + + +def _authority_payload(value: object) -> dict[str, object]: + excluded = {"authority_hash", "authority_token", "authority_status"} + if not hasattr(value, "__dataclass_fields__"): + raise ValueError("authority payload requires a dataclass") + return { + field.name: getattr(value, field.name) + for field in fields(cast(Any, value)) + if field.name not in excluded + } + + +def _authority_hash(value: object) -> str: + return canonical_hash(_authority_payload(value)) + + +def _authority_token(value: str) -> str: + return canonical_hash({"authority_hash": value, "purpose": "RES69_SOURCE_BOUND_AUTHORITY_V1"}) + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class ReliabilityDesignAuthority: + """Normalized reliability authority; direct construction is unverified.""" + + support_id: InstanceIdentifier + support_hash: str + analysis_input_ids: tuple[InstanceIdentifier, ...] + analysis_input_hashes: tuple[str, ...] + source_records: tuple[StatisticalSourceRecordReference, ...] + source_artifacts: tuple[StatisticalSourceArtifactReference, ...] + source_provenance: tuple[StatisticalProvenanceReference, ...] + study_identity: RegistryReference + replicate_pairs: tuple[ReliabilityReplicatePair, ...] + replicate_ordering: RegistryReference + target_measurement_identity_ids: tuple[ScientificIdentifier, ...] + target_construct: RegistryReference + target_test_family: RegistryReference + target_measurand: RegistryReference + target_metric_definition: RegistryReference + target_unit: UnitReference + protocol_reference: RegistryReference + method_identity: RegistryReference | None + device_identity: RegistryReference | None + rater_identity: RegistryReference | None + evidence_references: tuple[EvidenceReference, ...] + repeatability_question: ReliabilityQuestion + assumption_assessment: ReliabilityAssumptionAssessment + missingness_policy: MissingnessPolicy + balanced_design: bool + producing_method: RegistryReference + registry_version: str + source_evidence_hash: str + authority_status: AuthorityStatus = AuthorityStatus.UNVERIFIED + authority_hash: str | None = None + authority_token: str | None = None + design_identity: RegistryReference | None = None + + def __post_init__(self) -> None: + _require_identifier(self.support_id, "statistical-support", "support_id") + _require_hash(self.support_hash, "support_hash") + require_tuple(self.analysis_input_ids, "analysis_input_ids") + require_tuple(self.analysis_input_hashes, "analysis_input_hashes") + if len(self.analysis_input_ids) != len(self.analysis_input_hashes): + raise ValueError("analysis input IDs and hashes must have equal length") + for item in self.analysis_input_ids: + _require_identifier(item, "multi-source-analysis-input", "analysis_input_ids item") + for input_hash in self.analysis_input_hashes: + _require_hash(input_hash, "analysis_input_hashes item") + _require_tuple_items( + self.source_records, StatisticalSourceRecordReference, "source_records" + ) + _require_tuple_items( + self.source_artifacts, StatisticalSourceArtifactReference, "source_artifacts" + ) + _require_tuple_items( + self.source_provenance, StatisticalProvenanceReference, "source_provenance" + ) + _require_instance(self.study_identity, RegistryReference, "study_identity") + _require_tuple_items(self.replicate_pairs, ReliabilityReplicatePair, "replicate_pairs") + _require_instance(self.replicate_ordering, RegistryReference, "replicate_ordering") + object.__setattr__( + self, + "target_measurement_identity_ids", + tuple( + _require_scientific_identifier(item, "target_measurement_identity_ids item") + for item in self.target_measurement_identity_ids + ), + ) + for field_name in ( + "target_construct", + "target_test_family", + "target_measurand", + "target_metric_definition", + "protocol_reference", + "producing_method", + ): + _require_instance(getattr(self, field_name), RegistryReference, field_name) + _require_instance(self.target_unit, UnitReference, "target_unit") + _require_optional_instance(self.method_identity, RegistryReference, "method_identity") + _require_optional_instance(self.device_identity, RegistryReference, "device_identity") + _require_optional_instance(self.rater_identity, RegistryReference, "rater_identity") + _require_tuple_items(self.evidence_references, EvidenceReference, "evidence_references") + _require_enum(self.repeatability_question, ReliabilityQuestion, "repeatability_question") + _require_instance( + self.assumption_assessment, + ReliabilityAssumptionAssessment, + "assumption_assessment", + ) + _require_enum(self.missingness_policy, MissingnessPolicy, "missingness_policy") + _require_bool(self.balanced_design, "balanced_design") + _require_text(self.registry_version, "registry_version") + _require_hash(self.source_evidence_hash, "source_evidence_hash") + _require_enum(self.authority_status, AuthorityStatus, "authority_status") + _require_optional_instance(self.design_identity, RegistryReference, "design_identity") + if self.assumption_assessment.support_id != self.support_id: + raise ValueError("reliability authority assumption support ID does not match authority") + if self.assumption_assessment.support_hash != self.support_hash: + raise ValueError( + "reliability authority assumption support hash does not match authority" + ) + expected_hash = _authority_hash(self) + if self.authority_hash is None: + object.__setattr__(self, "authority_hash", expected_hash) + elif self.authority_hash != expected_hash: + raise ValueError("reliability authority hash does not match immutable content") + if self.authority_status is AuthorityStatus.SOURCE_BOUND: + expected_token = _authority_token(expected_hash) + if self.authority_token != expected_token: + raise ValueError("source-bound reliability authority proof is invalid") + elif self.authority_token is not None: + raise ValueError("unverified reliability authority must not carry an authority token") + + @property + def is_source_bound(self) -> bool: + return ( + self.authority_status is AuthorityStatus.SOURCE_BOUND + and self.authority_token == _authority_token(self.canonical_authority_hash) + ) + + @property + def canonical_authority_hash(self) -> str: + assert self.authority_hash is not None + return self.authority_hash + + @property + def design_or_study_identity(self) -> RegistryReference: + return self.design_identity or self.study_identity + + @property + def stable_underlying_quantity(self) -> StableUnderlyingQuantityStatus: + return self.assumption_assessment.stable_underlying_quantity + + @property + def systematic_trial_effect(self) -> SystematicTrialEffectAssessment: + return self.assumption_assessment.systematic_trial_effect + + @property + def error_scale(self) -> ReliabilityErrorScale: + return self.assumption_assessment.error_scale + + @property + def athlete_ids(self) -> tuple[InstanceIdentifier, ...]: + seen: dict[str, InstanceIdentifier] = {} + for pair in self.replicate_pairs: + seen.setdefault(pair.athlete_id.qualified, pair.athlete_id) + return tuple(seen.values()) + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class MethodComparisonMethodKeyV1: + """Stable method semantics without observation-instance identity. + + The key deliberately omits measurement, observation, result, artifact, + acquisition-instance, processing-run, athlete, session, and timestamp + identities. It retains the identity dimensions that can materially + change the method used to produce a result. + """ + + semantic: SemanticIdentity + processing: ProcessingIdentity + version: VersionIdentity + unit: UnitReference + device_identity: RegistryReference | None + sensor_channel: str | None + sampling: SamplingCharacteristics | None + calibration_reference: RegistryReference | None + acquisition_hardware_firmware: RegistryReference | None + + def __post_init__(self) -> None: + _require_instance(self.semantic, SemanticIdentity, "semantic") + _require_instance(self.processing, ProcessingIdentity, "processing") + _require_instance(self.version, VersionIdentity, "version") + _require_instance(self.unit, UnitReference, "unit") + _require_optional_instance(self.device_identity, RegistryReference, "device_identity") + _require_optional_instance(self.sampling, SamplingCharacteristics, "sampling") + _require_optional_instance( + self.calibration_reference, + RegistryReference, + "calibration_reference", + ) + _require_optional_instance( + self.acquisition_hardware_firmware, + RegistryReference, + "acquisition_hardware_firmware", + ) + if self.sensor_channel is not None: + _require_text(self.sensor_channel, "sensor_channel") + + @classmethod + def from_measurement_identity( + cls, + identity: MeasurementIdentity, + unit: UnitReference, + ) -> MethodComparisonMethodKeyV1: + if not isinstance(identity, MeasurementIdentity): + raise ValueError("identity must be a MeasurementIdentity") + if not isinstance(unit, UnitReference): + raise ValueError("unit must be a UnitReference") + acquisition = identity.acquisition + return cls( + semantic=identity.semantic, + processing=identity.processing, + version=identity.version, + unit=unit, + device_identity=acquisition.device, + sensor_channel=acquisition.sensor_channel, + sampling=acquisition.sampling, + calibration_reference=acquisition.calibration_reference, + acquisition_hardware_firmware=acquisition.hardware_firmware, + ) + + @property + def stable_key(self) -> str: + return canonical_hash(self) + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class MethodComparisonDesignEvidence: + """Source/protocol evidence from which method-comparison authority is built.""" + + support: StatisticalSupport + source_records: tuple[LongitudinalAthletePerformanceRecord, ...] + design_identity: RegistryReference + pairs: tuple[MethodComparisonPair, ...] + method_a_key: MethodComparisonMethodKeyV1 + method_b_key: MethodComparisonMethodKeyV1 + target_construct: RegistryReference + target_measurand: RegistryReference + metric_a_definition: RegistryReference + metric_b_definition: RegistryReference + unit_a: UnitReference + unit_b: UnitReference + occasion_context_policy: RegistryReference + sign_convention: RegistryReference + hidden_transformation: bool + repeated_pair_policy: RepeatedPairPolicy + evidence_references: tuple[EvidenceReference, ...] + producing_method: RegistryReference + registry_version: str + + def __post_init__(self) -> None: + _require_instance(self.support, StatisticalSupport, "support") + _require_tuple_items( + self.source_records, LongitudinalAthletePerformanceRecord, "source_records" + ) + if not self.source_records: + raise ValueError("method comparison evidence requires source records") + _require_instance(self.design_identity, RegistryReference, "design_identity") + _require_tuple_items(self.pairs, MethodComparisonPair, "pairs") + if not self.pairs: + raise ValueError("method comparison evidence requires exact subject pairs") + _require_instance(self.method_a_key, MethodComparisonMethodKeyV1, "method_a_key") + _require_instance(self.method_b_key, MethodComparisonMethodKeyV1, "method_b_key") + if self.method_a_key == self.method_b_key: + raise ValueError("method comparison requires distinct stable method keys") + for field_name in ( + "target_construct", + "target_measurand", + "metric_a_definition", + "metric_b_definition", + "occasion_context_policy", + "sign_convention", + "producing_method", + ): + _require_instance(getattr(self, field_name), RegistryReference, field_name) + _require_instance(self.unit_a, UnitReference, "unit_a") + _require_instance(self.unit_b, UnitReference, "unit_b") + _require_bool(self.hidden_transformation, "hidden_transformation") + _require_enum(self.repeated_pair_policy, RepeatedPairPolicy, "repeated_pair_policy") + _require_tuple_items(self.evidence_references, EvidenceReference, "evidence_references") + if not self.evidence_references: + raise ValueError("method comparison evidence requires evidence references") + _require_text(self.registry_version, "registry_version") + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class MethodComparisonDesignAuthority: + """Normalized method-comparison authority; direct construction is unverified.""" + + support_id: InstanceIdentifier + support_hash: str + analysis_input_ids: tuple[InstanceIdentifier, ...] + analysis_input_hashes: tuple[str, ...] + source_records: tuple[StatisticalSourceRecordReference, ...] + source_artifacts: tuple[StatisticalSourceArtifactReference, ...] + source_provenance: tuple[StatisticalProvenanceReference, ...] + design_identity: RegistryReference + pairs: tuple[MethodComparisonPair, ...] + method_a_key: MethodComparisonMethodKeyV1 + method_b_key: MethodComparisonMethodKeyV1 + target_construct: RegistryReference + target_measurand: RegistryReference + metric_a_definition: RegistryReference + metric_b_definition: RegistryReference + unit_a: UnitReference + unit_b: UnitReference + occasion_context_policy: RegistryReference + sign_convention: RegistryReference + hidden_transformation: bool + repeated_pair_policy: RepeatedPairPolicy + evidence_references: tuple[EvidenceReference, ...] + producing_method: RegistryReference + registry_version: str + source_evidence_hash: str + authority_status: AuthorityStatus = AuthorityStatus.UNVERIFIED + authority_hash: str | None = None + authority_token: str | None = None + + def __post_init__(self) -> None: + _require_identifier(self.support_id, "statistical-support", "support_id") + _require_hash(self.support_hash, "support_hash") + require_tuple(self.analysis_input_ids, "analysis_input_ids") + require_tuple(self.analysis_input_hashes, "analysis_input_hashes") + if len(self.analysis_input_ids) != len(self.analysis_input_hashes): + raise ValueError("analysis input IDs and hashes must have equal length") + for item in self.analysis_input_ids: + _require_identifier(item, "multi-source-analysis-input", "analysis_input_ids item") + for input_hash in self.analysis_input_hashes: + _require_hash(input_hash, "analysis_input_hashes item") + _require_tuple_items( + self.source_records, StatisticalSourceRecordReference, "source_records" + ) + _require_tuple_items( + self.source_artifacts, StatisticalSourceArtifactReference, "source_artifacts" + ) + _require_tuple_items( + self.source_provenance, StatisticalProvenanceReference, "source_provenance" + ) + _require_instance(self.design_identity, RegistryReference, "design_identity") + _require_tuple_items(self.pairs, MethodComparisonPair, "pairs") + _require_instance(self.method_a_key, MethodComparisonMethodKeyV1, "method_a_key") + _require_instance(self.method_b_key, MethodComparisonMethodKeyV1, "method_b_key") + if self.method_a_key == self.method_b_key: + raise ValueError("method comparison requires distinct stable method keys") + for field_name in ( + "target_construct", + "target_measurand", + "metric_a_definition", + "metric_b_definition", + "occasion_context_policy", + "sign_convention", + "producing_method", + ): + _require_instance(getattr(self, field_name), RegistryReference, field_name) + _require_instance(self.unit_a, UnitReference, "unit_a") + _require_instance(self.unit_b, UnitReference, "unit_b") + _require_bool(self.hidden_transformation, "hidden_transformation") + _require_enum(self.repeated_pair_policy, RepeatedPairPolicy, "repeated_pair_policy") + _require_tuple_items(self.evidence_references, EvidenceReference, "evidence_references") + _require_text(self.registry_version, "registry_version") + _require_hash(self.source_evidence_hash, "source_evidence_hash") + _require_enum(self.authority_status, AuthorityStatus, "authority_status") + expected_hash = _authority_hash(self) + if self.authority_hash is None: + object.__setattr__(self, "authority_hash", expected_hash) + elif self.authority_hash != expected_hash: + raise ValueError("method-comparison authority hash does not match immutable content") + if self.authority_status is AuthorityStatus.SOURCE_BOUND: + if self.authority_token != _authority_token(expected_hash): + raise ValueError("source-bound method-comparison authority proof is invalid") + elif self.authority_token is not None: + raise ValueError( + "unverified method-comparison authority must not carry an authority token" + ) + + @property + def is_source_bound(self) -> bool: + return ( + self.authority_status is AuthorityStatus.SOURCE_BOUND + and self.authority_token == _authority_token(self.canonical_authority_hash) + ) + + @property + def canonical_authority_hash(self) -> str: + assert self.authority_hash is not None + return self.authority_hash + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class StatisticalNonComputable: + operation: RegistryReference + disposition: StatisticalOperationDisposition + reason: str + + def __post_init__(self) -> None: + _require_instance(self.operation, RegistryReference, "operation") + if self.operation.identifier.object_type != "registered-operation": + raise ValueError("operation must identify a registered operation") + _require_enum(self.disposition, StatisticalOperationDisposition, "disposition") + _require_text(self.reason, "reason") + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class StatisticalEstimate: + estimate_id: InstanceIdentifier + estimand: RegistryReference + value: float + unit: StatisticalUnitReference + estimator: RegistryReference + parameters: tuple[MetadataEntry, ...] = () + + def __post_init__(self) -> None: + _require_identifier(self.estimate_id, "statistical-estimate", "estimate_id") + _require_instance(self.estimand, RegistryReference, "estimand") + if self.estimand.identifier.object_type != "estimand": + raise ValueError("estimand must identify a registered estimand") + value = _require_finite_number(self.value, "value") + object.__setattr__(self, "value", value) + if not isinstance(self.unit, UnitReference | DerivedUnitReference): + raise ValueError("unit must be a UnitReference or DerivedUnitReference") + _require_instance(self.estimator, RegistryReference, "estimator") + if self.estimator.identifier.object_type != "estimator": + raise ValueError("estimator must identify a registered estimator") + _require_tuple_items(self.parameters, MetadataEntry, "parameters") + + @property + def unit_reference(self) -> StatisticalUnitReference: + return self.unit + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class StatisticalAnalysisRun: + analysis_inputs: tuple[MultiSourceAnalysisInput, ...] + support_id: InstanceIdentifier + support_hash: str + operation: RegistryReference + estimator: RegistryReference + parameters: tuple[MetadataEntry, ...] + software_version: str + registry_version: str + processing_runs: tuple[MultiSourceProcessingRun, ...] + provenance_graphs: tuple[MultiSourceProvenanceGraph, ...] + source_records: tuple[StatisticalSourceRecordReference, ...] + evidence_references: tuple[EvidenceReference, ...] + analysis_run_id: InstanceIdentifier | None = None + support: StatisticalSupport | None = None + scale_semantic_key: MeasurementScaleSemanticKeyV1 | None = None + scale_semantics_authority: RegistryReference | None = None + scale_semantics_hash: str | None = None + authority_hashes: tuple[str, ...] = () + + def __post_init__(self) -> None: + require_tuple(self.analysis_inputs, "analysis_inputs") + if not self.analysis_inputs: + raise ValueError("analysis run requires analysis inputs") + if any(not isinstance(item, MultiSourceAnalysisInput) for item in self.analysis_inputs): + raise ValueError("analysis_inputs must contain MultiSourceAnalysisInput values") + _require_identifier(self.support_id, "statistical-support", "support_id") + _require_hash(self.support_hash, "support_hash") + _require_instance(self.operation, RegistryReference, "operation") + if self.operation.identifier.object_type != "registered-operation": + raise ValueError("operation must identify a registered operation") + _require_instance(self.estimator, RegistryReference, "estimator") + if self.estimator.identifier.object_type != "estimator": + raise ValueError("estimator must identify a registered estimator") + _require_tuple_items(self.parameters, MetadataEntry, "parameters") + _require_text(self.software_version, "software_version") + _require_text(self.registry_version, "registry_version") + require_tuple(self.processing_runs, "processing_runs") + require_tuple(self.provenance_graphs, "provenance_graphs") + if len(self.processing_runs) != len(self.analysis_inputs): + raise ValueError("one RES-62 processing run is required per analysis input") + if len(self.provenance_graphs) != len(self.analysis_inputs): + raise ValueError("one RES-62 provenance graph is required per analysis input") + _require_tuple_items(self.processing_runs, MultiSourceProcessingRun, "processing_runs") + _require_tuple_items( + self.provenance_graphs, MultiSourceProvenanceGraph, "provenance_graphs" + ) + _require_tuple_items( + self.source_records, StatisticalSourceRecordReference, "source_records" + ) + _require_tuple_items(self.evidence_references, EvidenceReference, "evidence_references") + _require_optional_instance(self.support, StatisticalSupport, "support") + if self.support is not None: + if self.support.analysis_inputs != self.analysis_inputs: + raise ValueError("analysis run support inputs must match analysis_inputs") + if self.support.source_records != self.source_records: + raise ValueError("analysis run support records must match source_records") + if self.support.canonical_support_id != self.support_id: + raise ValueError("analysis run support ID must match support_id") + if self.support.canonical_support_hash != self.support_hash: + raise ValueError("analysis run support hash must match support_hash") + _require_optional_instance( + self.scale_semantic_key, + MeasurementScaleSemanticKeyV1, + "scale_semantic_key", + ) + _require_optional_instance( + self.scale_semantics_authority, + RegistryReference, + "scale_semantics_authority", + ) + if self.scale_semantics_hash is not None: + _require_hash(self.scale_semantics_hash, "scale_semantics_hash") + if self.scale_semantics_authority is None or self.scale_semantic_key is None: + raise ValueError( + "scale semantics hash requires its semantic key and authority reference" + ) + if self.scale_semantics_authority is not None and self.scale_semantic_key is None: + raise ValueError("scale semantics authority requires its semantic key") + if self.scale_semantics_authority is not None and self.scale_semantics_hash is None: + raise ValueError("scale semantics authority requires its canonical authority hash") + require_tuple(self.authority_hashes, "authority_hashes") + for authority_hash in self.authority_hashes: + _require_hash(authority_hash, "authority_hashes item") + for analysis_input, processing_run, graph in zip( + self.analysis_inputs, + self.processing_runs, + self.provenance_graphs, + strict=True, + ): + if processing_run.method != self.operation: + raise ValueError("processing run method must equal the statistical operation") + if processing_run.analysis_input_id != analysis_input.input_id: + raise ValueError("processing run must reference its analysis input") + if processing_run.analysis_input_hash != analysis_input.input_hash: + raise ValueError("processing run input hash must match its analysis input") + from dynamislm.longitudinal.lineage import validate_multi_source_provenance_graph + + validate_multi_source_provenance_graph(graph, analysis_input, processing_run) + expected_hash = _analysis_run_hash(self) + expected_id = InstanceIdentifier( + "statistical-analysis-run", + expected_hash.removeprefix(_SHA256_PREFIX), + ) + if self.analysis_run_id is None: + object.__setattr__(self, "analysis_run_id", expected_id) + elif self.analysis_run_id != expected_id: + raise ValueError("analysis_run_id does not match immutable analysis-run content") + + @property + def run_id(self) -> InstanceIdentifier: + assert self.analysis_run_id is not None + return self.analysis_run_id + + @property + def analysis_run_hash(self) -> str: + return _analysis_run_hash(self) + + @property + def source_observation_ids(self) -> tuple[InstanceIdentifier, ...]: + return tuple( + observation_id + for analysis_input in self.analysis_inputs + for observation_id in analysis_input.source_observation_ids + ) + + @property + def source_entry_hashes(self) -> tuple[str, ...]: + return tuple( + entry_hash + for analysis_input in self.analysis_inputs + for entry_hash in analysis_input.selected_entry_hashes + ) + + @property + def support_snapshot(self) -> StatisticalSupport | None: + return self.support + + @property + def source_record_ids(self) -> tuple[InstanceIdentifier, ...]: + return tuple(item.record_id for item in self.source_records) + + @property + def source_record_hashes(self) -> tuple[str, ...]: + return tuple(item.record_hash for item in self.source_records) + + @property + def processing_run(self) -> MultiSourceProcessingRun: + if len(self.processing_runs) != 1: + raise ValueError("analysis run contains multiple RES-62 processing runs") + return self.processing_runs[0] + + @property + def provenance_graph(self) -> MultiSourceProvenanceGraph: + if len(self.provenance_graphs) != 1: + raise ValueError("analysis run contains multiple provenance graphs") + return self.provenance_graphs[0] + + +def _analysis_run_hash(run: StatisticalAnalysisRun) -> str: + return canonical_hash( + { + "analysis_inputs": run.analysis_inputs, + "support_id": run.support_id, + "support_hash": run.support_hash, + "operation": run.operation, + "estimator": run.estimator, + "parameters": run.parameters, + "software_version": run.software_version, + "registry_version": run.registry_version, + "processing_runs": run.processing_runs, + "provenance_graphs": run.provenance_graphs, + "source_records": run.source_records, + "evidence_references": run.evidence_references, + "support": run.support, + "scale_semantic_key": run.scale_semantic_key, + "scale_semantics_authority": run.scale_semantics_authority, + "scale_semantics_hash": run.scale_semantics_hash, + "authority_hashes": run.authority_hashes, + } + ) + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class StatisticalResult: + analysis_run: StatisticalAnalysisRun + result_kind: RegistryReference + estimates: tuple[StatisticalEstimate, ...] + non_computable: tuple[StatisticalNonComputable, ...] = () + authority_references: tuple[RegistryReference, ...] = () + result_id: InstanceIdentifier | None = None + output_hash: str | None = None + + def __post_init__(self) -> None: + _require_instance(self.analysis_run, StatisticalAnalysisRun, "analysis_run") + _require_instance(self.result_kind, RegistryReference, "result_kind") + if self.result_kind.identifier.object_type != "registered-operation": + raise ValueError("result_kind must identify a registered operation") + _require_tuple_items(self.estimates, StatisticalEstimate, "estimates") + if not self.estimates: + raise ValueError("statistical result requires at least one estimate") + estimate_ids = tuple(item.estimate_id.qualified for item in self.estimates) + if len(set(estimate_ids)) != len(estimate_ids): + raise ValueError("statistical estimate IDs must be unique") + _require_tuple_items(self.non_computable, StatisticalNonComputable, "non_computable") + _require_tuple_items(self.authority_references, RegistryReference, "authority_references") + _require_optional_instance(self.result_id, InstanceIdentifier, "result_id") + if self.result_id is not None and self.result_id.instance_type != "statistical-result": + raise ValueError("result_id must identify a statistical result") + if self.output_hash is not None: + _require_hash(self.output_hash, "output_hash") + expected_hash = _statistical_result_hash(self) + expected_id = InstanceIdentifier( + "statistical-result", + expected_hash.removeprefix(_SHA256_PREFIX), + ) + if self.output_hash is None: + object.__setattr__(self, "output_hash", expected_hash) + elif self.output_hash != expected_hash: + raise ValueError("output_hash does not match immutable statistical result") + if self.result_id is None: + object.__setattr__(self, "result_id", expected_id) + elif self.result_id != expected_id: + raise ValueError("result_id does not match immutable statistical result") + + @property + def canonical_result_id(self) -> InstanceIdentifier: + assert self.result_id is not None + return self.result_id + + @property + def canonical_output_hash(self) -> str: + assert self.output_hash is not None + return self.output_hash + + @property + def support(self) -> StatisticalSupport | None: + return self.analysis_run.support + + @property + def scale_semantic_key(self) -> MeasurementScaleSemanticKeyV1 | None: + return self.analysis_run.scale_semantic_key + + @property + def scale_semantics_authority(self) -> RegistryReference | None: + return self.analysis_run.scale_semantics_authority + + @property + def scale_semantics_hash(self) -> str | None: + return self.analysis_run.scale_semantics_hash + + @property + def authority_hashes(self) -> tuple[str, ...]: + return self.analysis_run.authority_hashes + + def estimate(self, key: str) -> StatisticalEstimate: + for estimate in self.estimates: + if estimate.estimand.identifier.key == key or estimate.estimate_id.value == key: + return estimate + raise KeyError(key) + + @property + def operation(self) -> RegistryReference: + return self.result_kind + + def get_estimate(self, key: str) -> StatisticalEstimate | None: + try: + return self.estimate(key) + except KeyError: + return None + + +def _statistical_result_hash(result: StatisticalResult) -> str: + return canonical_hash( + { + "analysis_run": result.analysis_run, + "result_kind": result.result_kind, + "estimates": result.estimates, + "non_computable": result.non_computable, + "authority_references": result.authority_references, + } + ) + + +__all__ = [ + "AuthorityStatus", + "DenominatorPolicy", + "DerivedUnitReference", + "MeasurementScaleKind", + "MeasurementScaleSemanticKeyV1", + "MeasurementScaleSemantics", + "MethodComparisonDesignAuthority", + "MethodComparisonDesignEvidence", + "MethodComparisonMethodKeyV1", + "MethodComparisonPair", + "MissingnessPolicy", + "RES69ReasonCode", + "ReliabilityAssumptionAssessment", + "ReliabilityAssumptionDeclarationOrigin", + "ReliabilityAssumptionDeclarationV1", + "ReliabilityAssumptionSourceEvidence", + "ReliabilityDesignAuthority", + "ReliabilityDesignEvidence", + "ReliabilityErrorScale", + "ReliabilityQuestion", + "ReliabilityReplicatePair", + "RepeatedPairPolicy", + "ScaleAuthorityOrigin", + "SignedValuePolicy", + "StableUnderlyingQuantityStatus", + "StatisticalAnalysisRun", + "StatisticalComparabilityEvidence", + "StatisticalEstimate", + "StatisticalNonComputable", + "StatisticalObservationReference", + "StatisticalOperationDisposition", + "StatisticalProvenanceReference", + "StatisticalResult", + "StatisticalSourceArtifactReference", + "StatisticalSourceRecordReference", + "StatisticalSupport", + "StatisticalSupportWindowKind", + "StatisticalUnitReference", + "StatisticalWindow", + "SupportEntryExclusion", + "SupportExclusionReason", + "SupportSelectionStatus", + "SystematicTrialEffectAssessment", + "SystematicTrialEffectStatus", +] diff --git a/src/dynamislm/longitudinal/statistics/registry.py b/src/dynamislm/longitudinal/statistics/registry.py new file mode 100644 index 0000000..7f6e097 --- /dev/null +++ b/src/dynamislm/longitudinal/statistics/registry.py @@ -0,0 +1,710 @@ +"""Registered RES-69 operation and scale-semantic identities. + +This module stays family-neutral. Family-specific scale decisions remain in +the scientific registries that own those identities; V1 accepts only exact +semantic keys supplied by this registry or explicitly marked synthetic test +registrations. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from dynamislm.longitudinal.statistics.models import ( + MeasurementScaleSemanticKeyV1, + MeasurementScaleSemantics, + ReliabilityAssumptionDeclarationOrigin, + ReliabilityAssumptionDeclarationV1, + ScaleAuthorityOrigin, + StatisticalOperationDisposition, +) +from dynamislm.measurement.identity import RegistryReference, ScientificIdentifier, UnitReference +from dynamislm.serialization import register_serializable_type + +RES69_REGISTRY_VERSION = "1.0.0" +RES69_SOFTWARE_VERSION = "dynamislm-res69-1.0.0" + + +def _reference(object_type: str, key: str, label: str) -> RegistryReference: + return RegistryReference( + ScientificIdentifier("dynamislm", object_type, key, RES69_REGISTRY_VERSION), + label, + ) + + +def _unit(key: str, label: str) -> UnitReference: + return UnitReference( + ScientificIdentifier("dynamislm", "unit", key, RES69_REGISTRY_VERSION), + label, + ) + + +RES69_DIMENSIONLESS_UNIT = _unit("dimensionless", "1") +RES69_PERCENT_UNIT = _unit("percent", "%") +RES69_LOG_RATIO_UNIT = _unit("natural-log-ratio", "ln ratio") +RES69_SECOND_UNIT = _unit("second", "s") +RES69_UNIT_DERIVATION_OPERATION = _reference( + "unit-derivation", + "numerator-per-second-v1", + "RES-69 numerator unit per elapsed second", +) + +RES69_EXACT_SEMANTIC_COMPARABILITY_RULE = _reference( + "comparability-rule", + "exact-registered-semantic-support-v1", + "RES-69 exact registered semantic comparability support", +) +RES69_SCALE_SEMANTICS_AUTHORITY = _reference( + "scale-semantics", + "measurement-scale-semantic-key-v1", + "RES-69 measurement-scale semantic authority V1", +) + +RES69_ABSOLUTE_CHANGE_OPERATION = _reference( + "registered-operation", "longitudinal-absolute-change-v1", "RES-69 absolute change" +) +RES69_RELATIVE_CHANGE_OPERATION = _reference( + "registered-operation", "longitudinal-relative-change-v1", "RES-69 arithmetic relative change" +) +RES69_LOG_RATIO_OPERATION = _reference( + "registered-operation", "longitudinal-log-ratio-v1", "RES-69 logarithmic ratio change" +) +RES69_WINDOW_DESCRIPTIVES_OPERATION = _reference( + "registered-operation", "longitudinal-window-descriptives-v1", "RES-69 window descriptives" +) +RES69_REFERENCE_WINDOW_DEVIATION_OPERATION = _reference( + "registered-operation", + "longitudinal-reference-window-deviation-v1", + "RES-69 reference-window standardized deviation", +) +RES69_OLS_SLOPE_OPERATION = _reference( + "registered-operation", "longitudinal-descriptive-ols-v1", "RES-69 descriptive timestamp OLS" +) +RES69_WITHIN_ATHLETE_SD_OPERATION = _reference( + "registered-operation", + "longitudinal-within-athlete-sample-sd-v1", + "RES-69 descriptive within-athlete sample SD", +) +RES69_TWO_REPLICATE_RANDOM_ERROR_OPERATION = _reference( + "registered-operation", + "two-replicate-within-subject-random-error-v1", + "RES-69 two-replicate random error", +) +RES69_RELIABILITY_DESIGN_OPERATION = _reference( + "registered-operation", "reliability-design-authority-v1", "RES-69 reliability design authority" +) +RES69_RELIABILITY_ASSUMPTION_ASSESSMENT_OPERATION = _reference( + "registered-operation", + "reliability-assumption-assessment-v1", + "RES-69 reliability assumption assessment", +) +RES69_RAW_RELATIVE_ERROR_OPERATION = _reference( + "registered-operation", + "two-replicate-pooled-raw-relative-error-v1", + "RES-69 two-replicate pooled raw relative error", +) +RES69_LOG_SCALE_ERROR_OPERATION = _reference( + "registered-operation", + "two-replicate-log-multiplicative-error-v1", + "RES-69 two-replicate log-multiplicative error", +) +RES69_METHOD_COMPARISON_OPERATION = _reference( + "registered-operation", "method-comparison-ba-summary-v1", "RES-69 method-comparison summary" +) +RES69_METHOD_COMPARISON_DESIGN_OPERATION = _reference( + "registered-operation", + "method-comparison-design-authority-v1", + "RES-69 method-comparison design authority", +) +RES69_CLASSICAL_BA_LIMITS_OPERATION = _reference( + "registered-operation", + "classical-bland-altman-limits-v1", + "RES-69 classical Bland-Altman limits (represented only)", +) +CLASSICAL_BA_LIMITS = RES69_CLASSICAL_BA_LIMITS_OPERATION + +RES69_CHANGE_ESTIMAND = _reference("estimand", "change", "absolute change") +RES69_RELATIVE_CHANGE_ESTIMAND = _reference( + "estimand", "relative-change", "arithmetic relative change" +) +RES69_PERCENT_CHANGE_ESTIMAND = _reference("estimand", "percent-change", "percent change") +RES69_LOG_RATIO_ESTIMAND = _reference("estimand", "log-ratio", "natural log ratio") +RES69_MEAN_ESTIMAND = _reference("estimand", "mean", "arithmetic mean") +RES69_MEDIAN_ESTIMAND = _reference("estimand", "median", "median") +RES69_SAMPLE_SD_ESTIMAND = _reference("estimand", "sample-sd", "sample standard deviation") +RES69_MINIMUM_ESTIMAND = _reference("estimand", "minimum", "minimum") +RES69_MAXIMUM_ESTIMAND = _reference("estimand", "maximum", "maximum") +RES69_RANGE_ESTIMAND = _reference("estimand", "range", "range") +RES69_REFERENCE_Z_ESTIMAND = _reference( + "estimand", "reference-window-z", "reference-window standardized deviation" +) +RES69_INTERCEPT_ESTIMAND = _reference("estimand", "ols-intercept", "descriptive OLS intercept") +RES69_SLOPE_ESTIMAND = _reference("estimand", "ols-slope", "descriptive OLS slope") +RES69_MEAN_TRIAL_SHIFT_ESTIMAND = _reference( + "estimand", "mean-trial-shift", "mean replicate trial shift" +) +RES69_SD_DIFFERENCE_ESTIMAND = _reference( + "estimand", "sd-difference", "sample SD of replicate differences" +) +RES69_RANDOM_ERROR_SD_ESTIMAND = _reference( + "estimand", "random-error-sd", "two-replicate within-subject random error SD" +) +RES69_RAW_REFERENCE_MEAN_ESTIMAND = _reference( + "estimand", "raw-pooled-grand-mean", "pooled raw two-replicate grand mean" +) +RES69_RAW_RELATIVE_ERROR_PERCENT_ESTIMAND = _reference( + "estimand", "raw-relative-error-percent", "raw relative error percent" +) +RES69_MEAN_LOG_SHIFT_ESTIMAND = _reference("estimand", "mean-log-shift", "mean log replicate shift") +RES69_SD_LOG_DIFFERENCE_ESTIMAND = _reference( + "estimand", "sd-log-difference", "sample SD of log replicate differences" +) +RES69_LOG_TYPICAL_ERROR_ESTIMAND = _reference( + "estimand", "log-typical-error", "log-scale typical error" +) +RES69_MULTIPLICATIVE_FACTOR_ESTIMAND = _reference( + "estimand", "multiplicative-factor", "multiplicative typical-error factor" +) +RES69_LOWER_FACTOR_ESTIMAND = _reference("estimand", "lower-factor", "lower multiplicative factor") +RES69_UPPER_FACTOR_ESTIMAND = _reference("estimand", "upper-factor", "upper multiplicative factor") +RES69_LOWER_PERCENT_ESTIMAND = _reference( + "estimand", "lower-percent", "lower multiplicative percent" +) +RES69_UPPER_PERCENT_ESTIMAND = _reference( + "estimand", "upper-percent", "upper multiplicative percent" +) +RES69_BA_BIAS_ESTIMAND = _reference("estimand", "ba-bias", "B minus A Bland-Altman bias") +RES69_BA_SD_DIFFERENCE_ESTIMAND = _reference( + "estimand", "ba-sd-difference", "B minus A Bland-Altman SD difference" +) + +RES69_ABSOLUTE_CHANGE_ESTIMATOR = _reference( + "estimator", "absolute-change-v1", "follow-up minus baseline" +) +RES69_RELATIVE_CHANGE_ESTIMATOR = _reference( + "estimator", "arithmetic-relative-change-v1", "arithmetic relative change" +) +RES69_LOG_RATIO_ESTIMATOR = _reference("estimator", "log-ratio-v1", "natural log ratio") +RES69_WINDOW_DESCRIPTIVE_ESTIMATOR = _reference( + "estimator", "window-descriptives-v1", "window descriptive estimators" +) +RES69_REFERENCE_Z_ESTIMATOR = _reference( + "estimator", "reference-window-z-v1", "current minus reference mean over reference sample SD" +) +RES69_OLS_ESTIMATOR = _reference("estimator", "descriptive-ols-v1", "centered timestamp OLS") +RES69_WITHIN_ATHLETE_SD_ESTIMATOR = _reference( + "estimator", "within-athlete-sample-sd-v1", "descriptive within-athlete sample SD" +) +TWO_REPLICATE_WITHIN_SUBJECT_RANDOM_ERROR_SD_V1 = _reference( + "estimator", + "two-replicate-within-subject-random-error-sd-v1", + "two-replicate within-subject random error SD V1", +) +TWO_REPLICATE_POOLED_RAW_GRAND_MEAN_V1 = _reference( + "estimator", + "two-replicate-pooled-raw-grand-mean-v1", + "two-replicate pooled raw grand mean V1", +) +RES69_LOG_MULTIPLICATIVE_ERROR_ESTIMATOR = _reference( + "estimator", + "two-replicate-log-multiplicative-error-v1", + "two-replicate log multiplicative error V1", +) +RES69_BA_ESTIMATOR = _reference( + "estimator", "simple-bland-altman-summary-v1", "B minus A mean and sample SD" +) + +SEM_FROM_REGISTERED_ICC = _reference( + "registered-operation", "sem-from-registered-icc", "SEM from registered ICC (represented only)" +) +SEM_FROM_ICC = SEM_FROM_REGISTERED_ICC +GENERIC_SEM = _reference("registered-operation", "generic-sem", "generic SEM (rejected)") +MDC_SDC = _reference("registered-operation", "mdc-sdc", "MDC/SDC (represented only)") +ICC_VARIANTS = _reference("registered-operation", "icc-variants", "ICC variants (represented only)") +LOG_BA = _reference("registered-operation", "log-ba", "log-scale Bland-Altman (deferred)") +REPEATED_MEASURES_BA = _reference( + "registered-operation", "repeated-measures-ba", "repeated-measures Bland-Altman (deferred)" +) +CONFIDENCE_INTERVALS = _reference( + "registered-operation", "confidence-intervals", "confidence intervals (deferred)" +) +COVARIANCE_PROPAGATION = _reference( + "registered-operation", "covariance-propagation", "covariance propagation (deferred)" +) +REPEATED_MEASURES_CORRELATION = _reference( + "registered-operation", + "repeated-measures-correlation", + "repeated-measures correlation (deferred)", +) +MIXED_EFFECTS = _reference( + "registered-operation", "mixed-effects", "mixed-effects models (deferred)" +) +GENERIC_MEANINGFUL_CHANGE = _reference( + "registered-operation", "generic-meaningful-change", "generic meaningful change (rejected)" +) +READINESS_FATIGUE_INJURY_INTERPRETATION = _reference( + "registered-operation", + "readiness-fatigue-injury-interpretation", + "readiness/fatigue/injury interpretation (rejected)", +) + +RES69_B_MINUS_A_SIGN_CONVENTION = _reference( + "sign-convention", "b-minus-a", "method comparison difference B minus A" +) +RES69_METHOD_COMPARISON_OCCASION_POLICY = _reference( + "protocol-constraint", + "same-occasion-independent-subject-pairs", + "same occasion independent pairs", +) +RES69_METHOD_COMPARISON_PAIRING_POLICY = _reference( + "pairing-policy", "one-pair-per-independent-subject", "one exact pair per independent subject" +) +RES69_REPLICATE_ORDERING = _reference( + "replicate-ordering", "trial-one-then-trial-two", "registered replicate one then replicate two" +) + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class RegisteredStatisticalOperation: + reference: RegistryReference + disposition: StatisticalOperationDisposition + estimator: RegistryReference | None + note: str + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class ScaleRegistryAuditEntry: + key: MeasurementScaleSemanticKeyV1 | None + metric_stable_id: str + status: str + rationale: str + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class MeasurementScaleRegistry: + entries: tuple[MeasurementScaleSemantics, ...] = () + registry_version: str = RES69_REGISTRY_VERSION + + def __post_init__(self) -> None: + if not isinstance(self.entries, tuple): + raise ValueError("scale registry entries must be an immutable tuple") + if any(not isinstance(item, MeasurementScaleSemantics) for item in self.entries): + raise ValueError("scale registry entries must contain MeasurementScaleSemantics values") + keys = [item.semantic_key for item in self.entries] + if len(set(keys)) != len(keys): + raise ValueError( + "RES69_REGISTRY_INTEGRITY_FAILURE: scale registry cannot contain " + "duplicate semantic keys" + ) + if not isinstance(self.registry_version, str) or not self.registry_version.strip(): + raise ValueError("registry_version must not be empty") + + def resolve(self, key: MeasurementScaleSemanticKeyV1) -> MeasurementScaleSemantics | None: + if not isinstance(key, MeasurementScaleSemanticKeyV1): + raise ValueError("scale lookup requires a MeasurementScaleSemanticKeyV1") + matches = tuple(item for item in self.entries if item.semantic_key == key) + if len(matches) > 1: + raise ValueError("RES69_REGISTRY_INTEGRITY_FAILURE: conflicting scale authority") + return matches[0] if matches else None + + def with_synthetic_entry(self, entry: MeasurementScaleSemantics) -> MeasurementScaleRegistry: + if not isinstance(entry, MeasurementScaleSemantics): + raise ValueError("entry must be MeasurementScaleSemantics") + if entry.authority_origin is not ScaleAuthorityOrigin.SYNTHETIC_TEST: + raise ValueError("only explicitly synthetic test scale authority may be added locally") + return MeasurementScaleRegistry((*self.entries, entry), self.registry_version) + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class ReliabilityAssumptionDeclarationRegistry: + """Immutable registry of study/protocol-owned reliability declarations.""" + + entries: tuple[ReliabilityAssumptionDeclarationV1, ...] = () + registry_version: str = RES69_REGISTRY_VERSION + + def __post_init__(self) -> None: + if not isinstance(self.entries, tuple): + raise ValueError("reliability declaration registry entries must be an immutable tuple") + if any(not isinstance(item, ReliabilityAssumptionDeclarationV1) for item in self.entries): + raise ValueError( + "reliability declaration registry entries must contain typed declarations" + ) + references = [item.declaration_reference.stable_id for item in self.entries] + if len(set(references)) != len(references): + raise ValueError( + "RES69_REGISTRY_INTEGRITY_FAILURE: reliability declaration registry " + "cannot contain duplicate references" + ) + if not isinstance(self.registry_version, str) or not self.registry_version.strip(): + raise ValueError("registry_version must not be empty") + if any(item.registry_version != self.registry_version for item in self.entries): + raise ValueError("reliability declaration registry and declaration versions must match") + + def resolve( + self, + reference: RegistryReference, + ) -> ReliabilityAssumptionDeclarationV1 | None: + if not isinstance(reference, RegistryReference): + raise ValueError("reliability declaration lookup requires a RegistryReference") + matches = tuple( + item + for item in self.entries + if item.declaration_reference.stable_id == reference.stable_id + ) + if len(matches) > 1: + raise ValueError( + "RES69_REGISTRY_INTEGRITY_FAILURE: conflicting reliability declaration authority" + ) + return matches[0] if matches else None + + +# No family-specific scale authority is imported into the generic package. +# RES-69 accepts exact production entries only after the owning scientific +# registry supplies them; synthetic test entries are explicit and local. +RES69_SCALE_REGISTRY = MeasurementScaleRegistry() +REGISTERED_SCALE_KEYS: tuple[MeasurementScaleSemanticKeyV1, ...] = () +UNREGISTERED_SCALE_KEYS = ( + "RES34-RES68_PUBLIC_SCALAR_METRIC_IDENTITIES", + "RES34-RES68_PROVIDER_REPORTED_SCALAR_METRICS", + "RES34-RES68_DERIVED_SCALAR_METRIC_VARIANTS", + "RES34-RES68_NORMALIZED_SCALAR_METRIC_VARIANTS", +) +WHY_EACH_REGISTERED_KEY_IS_AUTHORIZED: tuple[str, ...] = () +SCALE_REGISTRY_AUDIT = tuple( + ScaleRegistryAuditEntry(None, item, "UNREGISTERED", "No frozen RES-69 scale authority entry.") + for item in UNREGISTERED_SCALE_KEYS +) + +# No current RES-34..68 or RES-62 authority explicitly establishes a complete +# reliability-assumption declaration for a production study/protocol. Keep +# the canonical production registry empty until an owning authority supplies +# one; synthetic declarations remain test-only and are never added here. +RES69_RELIABILITY_ASSUMPTION_DECLARATION_REGISTRY = ReliabilityAssumptionDeclarationRegistry() +PRODUCTION_RELIABILITY_ASSUMPTION_DECLARATIONS = sum( + item.authority_origin is ReliabilityAssumptionDeclarationOrigin.PRODUCTION + for item in RES69_RELIABILITY_ASSUMPTION_DECLARATION_REGISTRY.entries +) + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class StatisticalOperationRegistry: + entries: tuple[RegisteredStatisticalOperation, ...] + + def __post_init__(self) -> None: + if not isinstance(self.entries, tuple): + raise ValueError("operation registry entries must be an immutable tuple") + if any(not isinstance(item, RegisteredStatisticalOperation) for item in self.entries): + raise ValueError("operation registry entries must be typed") + refs = [item.reference.stable_id for item in self.entries] + if len(set(refs)) != len(refs): + raise ValueError("operation registry cannot contain duplicate references") + + def get(self, reference: RegistryReference) -> RegisteredStatisticalOperation | None: + return next( + (item for item in self.entries if item.reference.stable_id == reference.stable_id), + None, + ) + + +RES69_OPERATION_REGISTRY = StatisticalOperationRegistry( + entries=( + RegisteredStatisticalOperation( + RES69_ABSOLUTE_CHANGE_OPERATION, + StatisticalOperationDisposition.IMPLEMENTED, + RES69_ABSOLUTE_CHANGE_ESTIMATOR, + "two-observation follow-up minus baseline", + ), + RegisteredStatisticalOperation( + RES69_RELATIVE_CHANGE_OPERATION, + StatisticalOperationDisposition.IMPLEMENTED, + RES69_RELATIVE_CHANGE_ESTIMATOR, + "ratio and percent share one arithmetic ratio-change estimand", + ), + RegisteredStatisticalOperation( + RES69_LOG_RATIO_OPERATION, + StatisticalOperationDisposition.IMPLEMENTED, + RES69_LOG_RATIO_ESTIMATOR, + "natural log ratio for registered positive ratio-scale support", + ), + RegisteredStatisticalOperation( + RES69_WINDOW_DESCRIPTIVES_OPERATION, + StatisticalOperationDisposition.IMPLEMENTED, + RES69_WINDOW_DESCRIPTIVE_ESTIMATOR, + "mean, median, sample SD, min, max, and range", + ), + RegisteredStatisticalOperation( + RES69_REFERENCE_WINDOW_DEVIATION_OPERATION, + StatisticalOperationDisposition.IMPLEMENTED, + RES69_REFERENCE_Z_ESTIMATOR, + "current value standardized against an explicitly prior reference window", + ), + RegisteredStatisticalOperation( + RES69_OLS_SLOPE_OPERATION, + StatisticalOperationDisposition.IMPLEMENTED, + RES69_OLS_ESTIMATOR, + "descriptive timestamp OLS slope and intercept", + ), + RegisteredStatisticalOperation( + RES69_WITHIN_ATHLETE_SD_OPERATION, + StatisticalOperationDisposition.IMPLEMENTED, + RES69_WITHIN_ATHLETE_SD_ESTIMATOR, + "descriptive within-athlete sample SD", + ), + RegisteredStatisticalOperation( + RES69_TWO_REPLICATE_RANDOM_ERROR_OPERATION, + StatisticalOperationDisposition.IMPLEMENTED, + TWO_REPLICATE_WITHIN_SUBJECT_RANDOM_ERROR_SD_V1, + "mean shift, SD difference, and SD difference divided by sqrt(2)", + ), + RegisteredStatisticalOperation( + RES69_RELIABILITY_DESIGN_OPERATION, + StatisticalOperationDisposition.IMPLEMENTED, + None, + "source/protocol-bound reliability design normalization", + ), + RegisteredStatisticalOperation( + RES69_RELIABILITY_ASSUMPTION_ASSESSMENT_OPERATION, + StatisticalOperationDisposition.IMPLEMENTED, + None, + "source/protocol-bound reliability assumption normalization", + ), + RegisteredStatisticalOperation( + RES69_RAW_RELATIVE_ERROR_OPERATION, + StatisticalOperationDisposition.IMPLEMENTED, + TWO_REPLICATE_POOLED_RAW_GRAND_MEAN_V1, + "raw relative error with derived pooled 2N grand mean", + ), + RegisteredStatisticalOperation( + RES69_LOG_SCALE_ERROR_OPERATION, + StatisticalOperationDisposition.IMPLEMENTED, + RES69_LOG_MULTIPLICATIVE_ERROR_ESTIMATOR, + "log-scale typical error with multiplicative factor interval", + ), + RegisteredStatisticalOperation( + RES69_METHOD_COMPARISON_OPERATION, + StatisticalOperationDisposition.IMPLEMENTED, + RES69_BA_ESTIMATOR, + "narrow B minus A Bland-Altman summary", + ), + RegisteredStatisticalOperation( + RES69_METHOD_COMPARISON_DESIGN_OPERATION, + StatisticalOperationDisposition.IMPLEMENTED, + None, + "source/protocol-bound method-comparison design normalization", + ), + RegisteredStatisticalOperation( + RES69_CLASSICAL_BA_LIMITS_OPERATION, + StatisticalOperationDisposition.REPRESENT_BUT_DO_NOT_COMPUTE, + None, + "classical limits of agreement are represented but not computed in V1", + ), + RegisteredStatisticalOperation( + SEM_FROM_REGISTERED_ICC, + StatisticalOperationDisposition.REPRESENT_BUT_DO_NOT_COMPUTE, + None, + "registered ICC input is not a V1 numeric authority", + ), + RegisteredStatisticalOperation( + GENERIC_SEM, + StatisticalOperationDisposition.REJECT, + None, + "generic SEM has no registered estimand or design", + ), + RegisteredStatisticalOperation( + MDC_SDC, + StatisticalOperationDisposition.REPRESENT_BUT_DO_NOT_COMPUTE, + None, + "MDC/SDC remains deferred", + ), + RegisteredStatisticalOperation( + ICC_VARIANTS, + StatisticalOperationDisposition.REPRESENT_BUT_DO_NOT_COMPUTE, + None, + "ICC variants remain deferred", + ), + RegisteredStatisticalOperation( + LOG_BA, + StatisticalOperationDisposition.DEFER, + None, + "log Bland-Altman is deferred", + ), + RegisteredStatisticalOperation( + REPEATED_MEASURES_BA, + StatisticalOperationDisposition.DEFER, + None, + "repeated-measures Bland-Altman is deferred", + ), + RegisteredStatisticalOperation( + CONFIDENCE_INTERVALS, + StatisticalOperationDisposition.DEFER, + None, + "confidence intervals are deferred", + ), + RegisteredStatisticalOperation( + COVARIANCE_PROPAGATION, + StatisticalOperationDisposition.DEFER, + None, + "covariance propagation is deferred", + ), + RegisteredStatisticalOperation( + REPEATED_MEASURES_CORRELATION, + StatisticalOperationDisposition.DEFER, + None, + "repeated-measures correlation is deferred", + ), + RegisteredStatisticalOperation( + MIXED_EFFECTS, + StatisticalOperationDisposition.DEFER, + None, + "mixed-effects models are deferred", + ), + RegisteredStatisticalOperation( + GENERIC_MEANINGFUL_CHANGE, + StatisticalOperationDisposition.REJECT, + None, + "generic meaningful change is not an authority", + ), + RegisteredStatisticalOperation( + READINESS_FATIGUE_INJURY_INTERPRETATION, + StatisticalOperationDisposition.REJECT, + None, + "readiness/fatigue/injury interpretation is outside RES-69", + ), + ) +) + +STATISTICAL_OPERATION_REGISTRY = RES69_OPERATION_REGISTRY +REPRESENT_BUT_DO_NOT_COMPUTE = StatisticalOperationDisposition.REPRESENT_BUT_DO_NOT_COMPUTE + + +def scale_semantics_for_identity( + identity: object, + unit: UnitReference, + *, + registry: MeasurementScaleRegistry = RES69_SCALE_REGISTRY, +) -> MeasurementScaleSemantics | None: + """Resolve exact semantic scale authority; caller flags are not accepted.""" + + from dynamislm.measurement.identity import MeasurementIdentity + + if not isinstance(identity, MeasurementIdentity): + raise ValueError("identity must be a MeasurementIdentity") + if registry is not RES69_SCALE_REGISTRY: + raise ValueError("scale authority must resolve from the canonical production registry") + key = MeasurementScaleSemanticKeyV1.from_measurement_identity(identity, unit) + semantics = registry.resolve(key) + if semantics is not None and semantics.authority_origin is not ScaleAuthorityOrigin.PRODUCTION: + raise ValueError("synthetic scale authority cannot authorize production calculations") + return semantics + + +def audit_scale_registry() -> tuple[ScaleRegistryAuditEntry, ...]: + """Return the deterministic production metric scale-registration audit.""" + + return SCALE_REGISTRY_AUDIT + + +__all__ = [ + "CLASSICAL_BA_LIMITS", + "CONFIDENCE_INTERVALS", + "COVARIANCE_PROPAGATION", + "GENERIC_MEANINGFUL_CHANGE", + "GENERIC_SEM", + "ICC_VARIANTS", + "LOG_BA", + "MDC_SDC", + "MIXED_EFFECTS", + "PRODUCTION_RELIABILITY_ASSUMPTION_DECLARATIONS", + "READINESS_FATIGUE_INJURY_INTERPRETATION", + "REGISTERED_SCALE_KEYS", + "REPEATED_MEASURES_BA", + "REPEATED_MEASURES_CORRELATION", + "REPRESENT_BUT_DO_NOT_COMPUTE", + "RES69_ABSOLUTE_CHANGE_ESTIMATOR", + "RES69_ABSOLUTE_CHANGE_OPERATION", + "RES69_BA_BIAS_ESTIMAND", + "RES69_BA_ESTIMATOR", + "RES69_BA_SD_DIFFERENCE_ESTIMAND", + "RES69_B_MINUS_A_SIGN_CONVENTION", + "RES69_CHANGE_ESTIMAND", + "RES69_CLASSICAL_BA_LIMITS_OPERATION", + "RES69_DIMENSIONLESS_UNIT", + "RES69_EXACT_SEMANTIC_COMPARABILITY_RULE", + "RES69_INTERCEPT_ESTIMAND", + "RES69_LOG_MULTIPLICATIVE_ERROR_ESTIMATOR", + "RES69_LOG_RATIO_ESTIMAND", + "RES69_LOG_RATIO_ESTIMATOR", + "RES69_LOG_RATIO_OPERATION", + "RES69_LOG_RATIO_UNIT", + "RES69_LOG_SCALE_ERROR_OPERATION", + "RES69_LOG_TYPICAL_ERROR_ESTIMAND", + "RES69_LOWER_FACTOR_ESTIMAND", + "RES69_LOWER_PERCENT_ESTIMAND", + "RES69_MAXIMUM_ESTIMAND", + "RES69_MEAN_ESTIMAND", + "RES69_MEAN_LOG_SHIFT_ESTIMAND", + "RES69_MEAN_TRIAL_SHIFT_ESTIMAND", + "RES69_MEDIAN_ESTIMAND", + "RES69_METHOD_COMPARISON_DESIGN_OPERATION", + "RES69_METHOD_COMPARISON_OCCASION_POLICY", + "RES69_METHOD_COMPARISON_OPERATION", + "RES69_METHOD_COMPARISON_PAIRING_POLICY", + "RES69_MINIMUM_ESTIMAND", + "RES69_MULTIPLICATIVE_FACTOR_ESTIMAND", + "RES69_OLS_ESTIMATOR", + "RES69_OLS_SLOPE_OPERATION", + "RES69_OPERATION_REGISTRY", + "RES69_PERCENT_CHANGE_ESTIMAND", + "RES69_PERCENT_UNIT", + "RES69_RANDOM_ERROR_SD_ESTIMAND", + "RES69_RAW_REFERENCE_MEAN_ESTIMAND", + "RES69_RAW_RELATIVE_ERROR_OPERATION", + "RES69_RAW_RELATIVE_ERROR_PERCENT_ESTIMAND", + "RES69_REFERENCE_WINDOW_DEVIATION_OPERATION", + "RES69_REFERENCE_Z_ESTIMAND", + "RES69_REFERENCE_Z_ESTIMATOR", + "RES69_REGISTRY_VERSION", + "RES69_RELATIVE_CHANGE_ESTIMAND", + "RES69_RELATIVE_CHANGE_ESTIMATOR", + "RES69_RELATIVE_CHANGE_OPERATION", + "RES69_RELIABILITY_ASSUMPTION_ASSESSMENT_OPERATION", + "RES69_RELIABILITY_ASSUMPTION_DECLARATION_REGISTRY", + "RES69_RELIABILITY_DESIGN_OPERATION", + "RES69_REPLICATE_ORDERING", + "RES69_SAMPLE_SD_ESTIMAND", + "RES69_SCALE_REGISTRY", + "RES69_SCALE_SEMANTICS_AUTHORITY", + "RES69_SD_DIFFERENCE_ESTIMAND", + "RES69_SD_LOG_DIFFERENCE_ESTIMAND", + "RES69_SECOND_UNIT", + "RES69_SLOPE_ESTIMAND", + "RES69_SOFTWARE_VERSION", + "RES69_TWO_REPLICATE_RANDOM_ERROR_OPERATION", + "RES69_UNIT_DERIVATION_OPERATION", + "RES69_UPPER_FACTOR_ESTIMAND", + "RES69_UPPER_PERCENT_ESTIMAND", + "RES69_WINDOW_DESCRIPTIVES_OPERATION", + "RES69_WINDOW_DESCRIPTIVE_ESTIMATOR", + "RES69_WITHIN_ATHLETE_SD_ESTIMATOR", + "RES69_WITHIN_ATHLETE_SD_OPERATION", + "SCALE_REGISTRY_AUDIT", + "SEM_FROM_ICC", + "SEM_FROM_REGISTERED_ICC", + "STATISTICAL_OPERATION_REGISTRY", + "TWO_REPLICATE_POOLED_RAW_GRAND_MEAN_V1", + "TWO_REPLICATE_WITHIN_SUBJECT_RANDOM_ERROR_SD_V1", + "UNREGISTERED_SCALE_KEYS", + "WHY_EACH_REGISTERED_KEY_IS_AUTHORIZED", + "MeasurementScaleRegistry", + "RegisteredStatisticalOperation", + "ReliabilityAssumptionDeclarationRegistry", + "ScaleRegistryAuditEntry", + "StatisticalOperationRegistry", + "audit_scale_registry", + "scale_semantics_for_identity", +] diff --git a/src/dynamislm/longitudinal/statistics/reliability.py b/src/dynamislm/longitudinal/statistics/reliability.py new file mode 100644 index 0000000..c0b169c --- /dev/null +++ b/src/dynamislm/longitudinal/statistics/reliability.py @@ -0,0 +1,1305 @@ +"""Source-bound reliability and repeatability calculations for RES-69.""" + +from __future__ import annotations + +import math +from dataclasses import replace + +from dynamislm.longitudinal.models import ( + LongitudinalAthletePerformanceRecord, + LongitudinalObservationEntry, +) +from dynamislm.longitudinal.statistics.models import ( + AuthorityStatus, + MissingnessPolicy, + ReliabilityAssumptionAssessment, + ReliabilityAssumptionDeclarationOrigin, + ReliabilityAssumptionDeclarationV1, + ReliabilityAssumptionSourceEvidence, + ReliabilityDesignAuthority, + ReliabilityDesignEvidence, + ReliabilityErrorScale, + ReliabilityQuestion, + ReliabilityReplicatePair, + RES69ReasonCode, + StableUnderlyingQuantityStatus, + StatisticalObservationReference, + StatisticalResult, + StatisticalSourceRecordReference, + StatisticalSupport, + SystematicTrialEffectAssessment, +) +from dynamislm.longitudinal.statistics.registry import ( + GENERIC_SEM, + MDC_SDC, + RES69_DIMENSIONLESS_UNIT, + RES69_LOG_MULTIPLICATIVE_ERROR_ESTIMATOR, + RES69_LOG_RATIO_UNIT, + RES69_LOG_SCALE_ERROR_OPERATION, + RES69_LOG_TYPICAL_ERROR_ESTIMAND, + RES69_LOWER_FACTOR_ESTIMAND, + RES69_LOWER_PERCENT_ESTIMAND, + RES69_MEAN_LOG_SHIFT_ESTIMAND, + RES69_MEAN_TRIAL_SHIFT_ESTIMAND, + RES69_MULTIPLICATIVE_FACTOR_ESTIMAND, + RES69_PERCENT_UNIT, + RES69_RANDOM_ERROR_SD_ESTIMAND, + RES69_RAW_REFERENCE_MEAN_ESTIMAND, + RES69_RAW_RELATIVE_ERROR_OPERATION, + RES69_RAW_RELATIVE_ERROR_PERCENT_ESTIMAND, + RES69_REGISTRY_VERSION, + RES69_RELIABILITY_ASSUMPTION_ASSESSMENT_OPERATION, + RES69_RELIABILITY_ASSUMPTION_DECLARATION_REGISTRY, + RES69_RELIABILITY_DESIGN_OPERATION, + RES69_REPLICATE_ORDERING, + RES69_SCALE_REGISTRY, + RES69_SD_DIFFERENCE_ESTIMAND, + RES69_SD_LOG_DIFFERENCE_ESTIMAND, + RES69_TWO_REPLICATE_RANDOM_ERROR_OPERATION, + RES69_UPPER_FACTOR_ESTIMAND, + RES69_UPPER_PERCENT_ESTIMAND, + SEM_FROM_REGISTERED_ICC, + TWO_REPLICATE_POOLED_RAW_GRAND_MEAN_V1, + TWO_REPLICATE_WITHIN_SUBJECT_RANDOM_ERROR_SD_V1, + MeasurementScaleRegistry, + ReliabilityAssumptionDeclarationRegistry, +) +from dynamislm.longitudinal.statistics.support import ( + StatisticalConstraintError, + exact_common_unit, + measurement_target_signature, + scalar_value, + validate_statistical_support, +) +from dynamislm.longitudinal.statistics.validation import ( + make_estimate, + make_result, + refusal, + refusal_for_exception, + resolve_scale_semantics, + validate_reliability_authority, +) +from dynamislm.measurement.identity import ( + InstanceIdentifier, + MetadataEntry, + RegistryReference, + ScientificIdentifier, + UnitReference, +) +from dynamislm.provenance.models import EvidenceReference +from dynamislm.refusal.models import RefusalResult +from dynamislm.serialization import canonical_hash + +_NOT_SUPPLIED = object() + + +def _parameters(*items: tuple[str, str | int | float]) -> tuple[MetadataEntry, ...]: + return tuple(MetadataEntry(key, value) for key, value in items) + + +def _source_evidence_references( + authority: ReliabilityDesignAuthority, +) -> tuple[EvidenceReference, ...]: + return authority.evidence_references + + +def _resolve_production_reliability_assumption_declaration( + declaration_reference: RegistryReference, + *, + registry: ReliabilityAssumptionDeclarationRegistry = ( + RES69_RELIABILITY_ASSUMPTION_DECLARATION_REGISTRY + ), +) -> ReliabilityAssumptionDeclarationV1: + if registry is not RES69_RELIABILITY_ASSUMPTION_DECLARATION_REGISTRY: + raise StatisticalConstraintError( + "public reliability assumption authority must resolve from the canonical " + "production declaration registry", + RES69ReasonCode.RELIABILITY_AUTHORITY_REQUIRED.value, + missing_information=("canonical RES-69 production reliability declaration",), + ) + try: + declaration = registry.resolve(declaration_reference) + except ValueError as exc: + raise StatisticalConstraintError( + "reliability declaration registry contains conflicting authority", + RES69ReasonCode.REGISTRY_INTEGRITY_FAILURE.value, + ) from exc + if declaration is None: + raise StatisticalConstraintError( + "reliability assumption declaration is not registered in production authority", + RES69ReasonCode.RELIABILITY_AUTHORITY_REQUIRED.value, + missing_information=("registered production reliability assumption declaration",), + ) + if declaration.authority_origin is not ReliabilityAssumptionDeclarationOrigin.PRODUCTION: + raise StatisticalConstraintError( + "synthetic reliability assumption declarations cannot authorize production claims", + RES69ReasonCode.RELIABILITY_AUTHORITY_REQUIRED.value, + missing_information=("production reliability assumption declaration",), + ) + if declaration.registry_version != RES69_REGISTRY_VERSION: + raise StatisticalConstraintError( + "reliability assumption declaration registry version is not the " + "registered RES-69 version", + RES69ReasonCode.RELIABILITY_AUTHORITY_REQUIRED.value, + ) + return declaration + + +def build_reliability_assumption_source_evidence( + *, + support: StatisticalSupport, + source_records: tuple[LongitudinalAthletePerformanceRecord, ...], + study_identity: RegistryReference, + protocol_reference: RegistryReference, + declaration_reference: RegistryReference, + evidence_references: tuple[EvidenceReference, ...], + producing_method: RegistryReference, + registry_version: str = RES69_REGISTRY_VERSION, + registry: ReliabilityAssumptionDeclarationRegistry = ( + RES69_RELIABILITY_ASSUMPTION_DECLARATION_REGISTRY + ), +) -> ReliabilityAssumptionSourceEvidence: + """Bind exact source evidence to a registered production declaration.""" + + declaration = _resolve_production_reliability_assumption_declaration( + declaration_reference, + registry=registry, + ) + if declaration.study_identity != study_identity: + raise StatisticalConstraintError( + "reliability declaration study identity does not match source evidence", + RES69ReasonCode.SUPPORT_MISMATCH.value, + ) + if declaration.protocol_reference != protocol_reference: + raise StatisticalConstraintError( + "reliability declaration protocol identity does not match source evidence", + RES69ReasonCode.IDENTITY_UNRESOLVED.value, + ) + if declaration.evidence_references != evidence_references: + raise StatisticalConstraintError( + "reliability declaration evidence references do not match source evidence", + RES69ReasonCode.SUPPORT_MISMATCH.value, + ) + if declaration.producing_method != producing_method: + raise StatisticalConstraintError( + "reliability declaration producing method does not match source evidence", + RES69ReasonCode.RELIABILITY_AUTHORITY_REQUIRED.value, + ) + if declaration.registry_version != registry_version: + raise StatisticalConstraintError( + "reliability declaration registry version does not match source evidence", + RES69ReasonCode.RELIABILITY_AUTHORITY_REQUIRED.value, + ) + return ReliabilityAssumptionSourceEvidence( + support=support, + source_records=source_records, + study_identity=study_identity, + protocol_reference=protocol_reference, + declaration_reference=declaration.declaration_reference, + declaration_hash=declaration.canonical_declaration_hash, + evidence_references=evidence_references, + producing_method=producing_method, + registry_version=registry_version, + ) + + +def _validate_reliability_assumption_source_evidence( + evidence: ReliabilityAssumptionSourceEvidence, +) -> ReliabilityAssumptionDeclarationV1: + support = evidence.support + validate_statistical_support(support) + if evidence.producing_method != RES69_RELIABILITY_ASSUMPTION_ASSESSMENT_OPERATION: + raise StatisticalConstraintError( + "reliability assumptions must use the registered assessment operation", + RES69ReasonCode.RELIABILITY_AUTHORITY_REQUIRED.value, + ) + declaration = _resolve_production_reliability_assumption_declaration( + evidence.declaration_reference + ) + if evidence.declaration_hash != declaration.canonical_declaration_hash: + raise StatisticalConstraintError( + "reliability source evidence declaration hash is invalid", + RES69ReasonCode.RELIABILITY_AUTHORITY_REQUIRED.value, + ) + if evidence.study_identity != declaration.study_identity: + raise StatisticalConstraintError( + "reliability declaration study identity does not match source evidence", + RES69ReasonCode.SUPPORT_MISMATCH.value, + ) + if evidence.protocol_reference != declaration.protocol_reference: + raise StatisticalConstraintError( + "reliability declaration protocol identity does not match source evidence", + RES69ReasonCode.IDENTITY_UNRESOLVED.value, + ) + if evidence.evidence_references != declaration.evidence_references: + raise StatisticalConstraintError( + "reliability declaration evidence references do not match source evidence", + RES69ReasonCode.SUPPORT_MISMATCH.value, + ) + if evidence.producing_method != declaration.producing_method: + raise StatisticalConstraintError( + "reliability declaration producing method does not match source evidence", + RES69ReasonCode.RELIABILITY_AUTHORITY_REQUIRED.value, + ) + if evidence.registry_version != declaration.registry_version: + raise StatisticalConstraintError( + "reliability declaration registry version does not match source evidence", + RES69ReasonCode.RELIABILITY_AUTHORITY_REQUIRED.value, + ) + if evidence.registry_version != RES69_REGISTRY_VERSION: + raise StatisticalConstraintError( + "reliability assumption registry version is not the registered RES-69 version", + RES69ReasonCode.RELIABILITY_AUTHORITY_REQUIRED.value, + ) + expected_records = tuple( + sorted( + ( + StatisticalSourceRecordReference.from_record(record) + for record in evidence.source_records + ), + key=lambda item: item.record_id.qualified, + ) + ) + if expected_records != support.source_records: + raise StatisticalConstraintError( + "reliability assumption source records do not match support", + RES69ReasonCode.SUPPORT_MISMATCH.value, + ) + target_protocols: set[str] = set() + for entry in support.included_entries: + protocol = entry.observation.identity.semantic.protocol + if protocol is None: + raise StatisticalConstraintError( + "reliability assumption support contains an entry without a protocol identity", + RES69ReasonCode.IDENTITY_UNRESOLVED.value, + ) + target_protocols.add(protocol.stable_id) + if ( + len(target_protocols) != 1 + or next(iter(target_protocols)) != evidence.protocol_reference.stable_id + ): + raise StatisticalConstraintError( + "reliability assumption protocol does not match exact support", + RES69ReasonCode.IDENTITY_UNRESOLVED.value, + ) + return declaration + + +def _build_reliability_assumption_assessment_from_declaration( + evidence: ReliabilityAssumptionSourceEvidence, + declaration: ReliabilityAssumptionDeclarationV1, +) -> ReliabilityAssumptionAssessment: + support = evidence.support + assessment = ReliabilityAssumptionAssessment( + support_id=support.canonical_support_id, + support_hash=support.canonical_support_hash, + analysis_input_ids=support.input_ids, + analysis_input_hashes=support.input_hashes, + source_records=tuple( + sorted( + ( + StatisticalSourceRecordReference.from_record(record) + for record in evidence.source_records + ), + key=lambda item: item.record_id.qualified, + ) + ), + source_artifacts=support.source_artifacts, + source_provenance=support.source_provenance, + study_identity=evidence.study_identity, + protocol_reference=evidence.protocol_reference, + declaration_reference=evidence.declaration_reference, + declaration_hash=evidence.declaration_hash, + evidence_references=evidence.evidence_references, + stable_underlying_quantity=declaration.stable_underlying_quantity, + systematic_trial_effect=declaration.systematic_trial_effect, + error_scale=declaration.error_scale, + producing_method=evidence.producing_method, + registry_version=evidence.registry_version, + source_evidence=evidence, + source_evidence_hash=canonical_hash(evidence), + authority_status=AuthorityStatus.UNVERIFIED, + ) + return assessment + + +def build_reliability_assumption_assessment( + evidence: ReliabilityAssumptionSourceEvidence, +) -> ReliabilityAssumptionAssessment: + """Normalize exact source evidence through canonical production authority.""" + + if not isinstance(evidence, ReliabilityAssumptionSourceEvidence): + raise ValueError("evidence must be a ReliabilityAssumptionSourceEvidence") + declaration = _validate_reliability_assumption_source_evidence(evidence) + assessment = _build_reliability_assumption_assessment_from_declaration( + evidence, + declaration, + ) + return replace( + assessment, + authority_status=AuthorityStatus.SOURCE_BOUND, + authority_token=canonical_hash( + { + "authority_hash": assessment.canonical_authority_hash, + "purpose": "RES69_SOURCE_BOUND_AUTHORITY_V1", + } + ), + ) + + +def _build_synthetic_reliability_assumption_assessment( + *, + support: StatisticalSupport, + source_records: tuple[LongitudinalAthletePerformanceRecord, ...], + study_identity: RegistryReference, + protocol_reference: RegistryReference, + evidence_references: tuple[EvidenceReference, ...], + stable_underlying_quantity: StableUnderlyingQuantityStatus, + systematic_trial_effect: SystematicTrialEffectAssessment, + error_scale: ReliabilityErrorScale, + producing_method: RegistryReference, + registry_version: str = RES69_REGISTRY_VERSION, +) -> ReliabilityAssumptionAssessment: + """Private synthetic declaration path for arithmetic/unit tests only.""" + + declaration_reference = RegistryReference( + ScientificIdentifier( + "synthetic-res69", + "reliability-assumption-declaration", + canonical_hash( + { + "support_hash": support.canonical_support_hash, + "study_identity": study_identity, + "protocol_reference": protocol_reference, + "error_scale": error_scale, + } + ).removeprefix("sha256:"), + registry_version, + ), + "Synthetic RES-69 reliability assumption declaration", + ) + declaration = ReliabilityAssumptionDeclarationV1( + declaration_reference=declaration_reference, + study_identity=study_identity, + protocol_reference=protocol_reference, + stable_underlying_quantity=stable_underlying_quantity, + systematic_trial_effect=systematic_trial_effect, + error_scale=error_scale, + evidence_references=evidence_references, + producing_method=producing_method, + registry_version=registry_version, + authority_origin=ReliabilityAssumptionDeclarationOrigin.SYNTHETIC_TEST, + ) + evidence = ReliabilityAssumptionSourceEvidence( + support=support, + source_records=source_records, + study_identity=study_identity, + protocol_reference=protocol_reference, + declaration_reference=declaration.declaration_reference, + declaration_hash=declaration.canonical_declaration_hash, + evidence_references=evidence_references, + producing_method=producing_method, + registry_version=registry_version, + ) + assessment = _build_reliability_assumption_assessment_from_declaration( + evidence, + declaration, + ) + return replace( + assessment, + authority_status=AuthorityStatus.SOURCE_BOUND, + authority_token=canonical_hash( + { + "authority_hash": assessment.canonical_authority_hash, + "purpose": "RES69_SOURCE_BOUND_AUTHORITY_V1", + } + ), + ) + + +def build_reliability_design_evidence( + *, + support: StatisticalSupport, + source_records: tuple[LongitudinalAthletePerformanceRecord, ...], + study_identity: RegistryReference, + replicate_pairs: tuple[ReliabilityReplicatePair, ...], + target_measurement_identity_ids: tuple[ScientificIdentifier, ...], + target_construct: RegistryReference, + target_test_family: RegistryReference, + target_measurand: RegistryReference, + target_metric_definition: RegistryReference, + target_unit: UnitReference, + protocol_reference: RegistryReference, + evidence_references: tuple[EvidenceReference, ...], + repeatability_question: ReliabilityQuestion, + assumption_assessment: ReliabilityAssumptionAssessment, + missingness_policy: MissingnessPolicy, + balanced_design: bool, + producing_method: RegistryReference, + method_identity: RegistryReference | None = None, + device_identity: RegistryReference | None = None, + rater_identity: RegistryReference | None = None, + design_identity: RegistryReference | None = None, + replicate_ordering: RegistryReference = RES69_REPLICATE_ORDERING, + registry_version: str = RES69_REGISTRY_VERSION, +) -> ReliabilityDesignEvidence: + """Create a typed evidence record; authority is minted only by its builder.""" + + if not isinstance(assumption_assessment, ReliabilityAssumptionAssessment): + raise ValueError("assumption_assessment must be a ReliabilityAssumptionAssessment") + if not isinstance(missingness_policy, MissingnessPolicy): + raise ValueError("missingness_policy must be a MissingnessPolicy") + return ReliabilityDesignEvidence( + support=support, + source_records=source_records, + study_identity=study_identity, + replicate_pairs=replicate_pairs, + replicate_ordering=replicate_ordering, + target_measurement_identity_ids=target_measurement_identity_ids, + target_construct=target_construct, + target_test_family=target_test_family, + target_measurand=target_measurand, + target_metric_definition=target_metric_definition, + target_unit=target_unit, + protocol_reference=protocol_reference, + method_identity=method_identity, + device_identity=device_identity, + rater_identity=rater_identity, + evidence_references=evidence_references, + repeatability_question=repeatability_question, + assumption_assessment=assumption_assessment, + missingness_policy=missingness_policy, + balanced_design=balanced_design, + producing_method=producing_method, + registry_version=registry_version, + design_identity=design_identity, + ) + + +def _validate_reliability_evidence(evidence: ReliabilityDesignEvidence) -> None: + support = evidence.support + validate_statistical_support(support) + if evidence.producing_method != RES69_RELIABILITY_DESIGN_OPERATION: + raise StatisticalConstraintError( + "reliability design evidence must use the registered design-normalization operation", + RES69ReasonCode.RELIABILITY_AUTHORITY_REQUIRED.value, + ) + if evidence.registry_version != RES69_REGISTRY_VERSION: + raise StatisticalConstraintError( + "reliability design registry version is not the registered RES-69 version", + RES69ReasonCode.RELIABILITY_AUTHORITY_REQUIRED.value, + ) + assessment = evidence.assumption_assessment + if not assessment.is_source_bound: + raise StatisticalConstraintError( + "source-bound ReliabilityAssumptionAssessment is required", + RES69ReasonCode.RELIABILITY_AUTHORITY_REQUIRED.value, + ) + declaration = _validate_reliability_assumption_source_evidence(assessment.source_evidence) + if ( + assessment.declaration_reference != declaration.declaration_reference + or assessment.declaration_hash != declaration.canonical_declaration_hash + or assessment.study_identity != declaration.study_identity + or assessment.protocol_reference != declaration.protocol_reference + or assessment.evidence_references != declaration.evidence_references + or assessment.stable_underlying_quantity != declaration.stable_underlying_quantity + or assessment.systematic_trial_effect != declaration.systematic_trial_effect + or assessment.error_scale != declaration.error_scale + or assessment.producing_method != declaration.producing_method + or assessment.registry_version != declaration.registry_version + ): + raise StatisticalConstraintError( + "reliability assumption assessment does not match registered declaration", + RES69ReasonCode.RELIABILITY_AUTHORITY_REQUIRED.value, + ) + if ( + assessment.support_id != support.canonical_support_id + or assessment.support_hash != support.canonical_support_hash + ): + raise StatisticalConstraintError( + "reliability assumption assessment does not match exact support", + RES69ReasonCode.SUPPORT_MISMATCH.value, + ) + if assessment.protocol_reference != evidence.protocol_reference: + raise StatisticalConstraintError( + "reliability design protocol does not match the source-bound assumption assessment", + RES69ReasonCode.IDENTITY_UNRESOLVED.value, + ) + if assessment.study_identity != evidence.study_identity: + raise StatisticalConstraintError( + "reliability design study identity does not match the source-bound " + "assumption assessment", + RES69ReasonCode.SUPPORT_MISMATCH.value, + ) + expected_records = tuple( + sorted( + ( + StatisticalSourceRecordReference.from_record(record) + for record in evidence.source_records + ), + key=lambda item: item.record_id.qualified, + ) + ) + if expected_records != support.source_records: + raise StatisticalConstraintError( + "reliability source records do not match support record references", + RES69ReasonCode.SUPPORT_MISMATCH.value, + ) + entry_by_id = {entry.canonical_entry_id: entry for entry in support.included_entries} + if not evidence.replicate_pairs: + raise StatisticalConstraintError( + "reliability authority requires exact replicate pairs", + RES69ReasonCode.INCOMPLETE_PAIR.value, + ) + pair_entry_ids: list[InstanceIdentifier] = [] + pair_athletes: list[InstanceIdentifier] = [] + identity_ids: set[ScientificIdentifier] = set() + for pair in evidence.replicate_pairs: + first = entry_by_id.get(pair.first_entry_id) + second = entry_by_id.get(pair.second_entry_id) + if first is None or second is None: + raise StatisticalConstraintError( + "reliability pair references an entry outside exact support", + RES69ReasonCode.INCOMPLETE_PAIR.value, + ) + if ( + StatisticalObservationReference.from_entry(first) != pair.first + or StatisticalObservationReference.from_entry(second) != pair.second + ): + raise StatisticalConstraintError( + "reliability pair observation or entry hash does not match support", + RES69ReasonCode.SUPPORT_MISMATCH.value, + ) + pair_entry_ids.extend((pair.first_entry_id, pair.second_entry_id)) + pair_athletes.append(pair.athlete_id) + identity_ids.update( + (first.observation.identity.identity_id, second.observation.identity.identity_id) + ) + if pair.first.athlete_id != pair.second.athlete_id: + raise StatisticalConstraintError( + "reliability pair has mismatched athlete identities", + RES69ReasonCode.ANALYSIS_DESIGN_MISMATCH.value, + ) + if len(set(pair_entry_ids)) != len(pair_entry_ids) or set(pair_entry_ids) != { + entry.canonical_entry_id for entry in support.included_entries + }: + raise StatisticalConstraintError( + "reliability support must contain exactly the paired observations", + RES69ReasonCode.INCOMPLETE_PAIR.value, + ) + if len(set(pair_athletes)) != len(pair_athletes): + raise StatisticalConstraintError( + "V1 reliability requires one exact pair per independent subject", + RES69ReasonCode.UNBALANCED_RELIABILITY_SUPPORT.value, + ) + if set(evidence.target_measurement_identity_ids) != identity_ids: + raise StatisticalConstraintError( + "target measurement identity IDs do not match paired observations", + RES69ReasonCode.SUPPORT_MISMATCH.value, + ) + if any( + measurement_target_signature(entry) + != measurement_target_signature(support.included_entries[0]) + for entry in support.included_entries[1:] + ): + raise StatisticalConstraintError( + "reliability observations do not share one target construct/measurand/metric", + RES69ReasonCode.IDENTITY_UNRESOLVED.value, + ) + target = support.included_entries[0].observation.identity.semantic + if ( + target.construct.stable_id != evidence.target_construct.stable_id + or target.test_family.stable_id != evidence.target_test_family.stable_id + or target.measurand.stable_id != evidence.target_measurand.stable_id + or target.metric_definition.stable_id != evidence.target_metric_definition.stable_id + ): + raise StatisticalConstraintError( + "reliability target semantic references do not match observations", + RES69ReasonCode.IDENTITY_UNRESOLVED.value, + ) + for entry in support.included_entries: + identity = entry.observation.identity + if evidence.method_identity is not None: + actual_method = ( + identity.processing.registered_operation or identity.version.processing_method + ) + if actual_method.stable_id != evidence.method_identity.stable_id: + raise StatisticalConstraintError( + "reliability method identity does not match source observations", + RES69ReasonCode.SUPPORT_MISMATCH.value, + ) + if evidence.device_identity is not None: + if ( + identity.acquisition.device is None + or identity.acquisition.device.stable_id != evidence.device_identity.stable_id + ): + raise StatisticalConstraintError( + "reliability device identity does not match source observations", + RES69ReasonCode.SUPPORT_MISMATCH.value, + ) + unit = exact_common_unit(support.included_entries) + if unit != evidence.target_unit: + raise StatisticalConstraintError( + "reliability target unit does not match exact source unit", + RES69ReasonCode.UNIT_MISMATCH.value, + ) + if support.missingness_policy != evidence.missingness_policy: + raise StatisticalConstraintError( + "reliability evidence missingness policy does not match support", + RES69ReasonCode.SUPPORT_MISMATCH.value, + ) + if evidence.replicate_ordering != RES69_REPLICATE_ORDERING: + raise StatisticalConstraintError( + "replicate ordering is not the registered trial-one then trial-two ordering", + RES69ReasonCode.ANALYSIS_DESIGN_MISMATCH.value, + ) + protocol = target.protocol + if protocol is None or protocol.stable_id != evidence.protocol_reference.stable_id: + raise StatisticalConstraintError( + "reliability protocol evidence does not match the target identity", + RES69ReasonCode.IDENTITY_UNRESOLVED.value, + ) + if evidence.balanced_design: + counts: dict[InstanceIdentifier, int] = {} + for entry in support.included_entries: + athlete_id = entry.observation.context.athlete_id + counts[athlete_id] = counts.get(athlete_id, 0) + 1 + if any(count != 2 for count in counts.values()): + raise StatisticalConstraintError( + "balanced reliability authority requires exactly two observations per subject", + RES69ReasonCode.UNBALANCED_RELIABILITY_SUPPORT.value, + ) + + +def _build_reliability_design_authority_from_evidence( + evidence: ReliabilityDesignEvidence, +) -> ReliabilityDesignAuthority: + """Internal normalizer used after production or test-only validation.""" + + support = evidence.support + source_records = tuple( + sorted( + ( + StatisticalSourceRecordReference.from_record(record) + for record in evidence.source_records + ), + key=lambda item: item.record_id.qualified, + ) + ) + authority = ReliabilityDesignAuthority( + support_id=support.canonical_support_id, + support_hash=support.canonical_support_hash, + analysis_input_ids=support.input_ids, + analysis_input_hashes=support.input_hashes, + source_records=source_records, + source_artifacts=support.source_artifacts, + source_provenance=support.source_provenance, + study_identity=evidence.study_identity, + replicate_pairs=tuple( + sorted(evidence.replicate_pairs, key=lambda item: item.pair_id.qualified) + ), + replicate_ordering=evidence.replicate_ordering, + target_measurement_identity_ids=tuple( + sorted(evidence.target_measurement_identity_ids, key=lambda item: item.stable_id) + ), + target_construct=evidence.target_construct, + target_test_family=evidence.target_test_family, + target_measurand=evidence.target_measurand, + target_metric_definition=evidence.target_metric_definition, + target_unit=evidence.target_unit, + protocol_reference=evidence.protocol_reference, + method_identity=evidence.method_identity, + device_identity=evidence.device_identity, + rater_identity=evidence.rater_identity, + evidence_references=evidence.evidence_references, + repeatability_question=evidence.repeatability_question, + assumption_assessment=evidence.assumption_assessment, + missingness_policy=evidence.missingness_policy, + balanced_design=evidence.balanced_design, + producing_method=evidence.producing_method, + registry_version=evidence.registry_version, + source_evidence_hash=canonical_hash(evidence), + authority_status=AuthorityStatus.UNVERIFIED, + design_identity=evidence.design_identity or evidence.study_identity, + ) + return replace( + authority, + authority_status=AuthorityStatus.SOURCE_BOUND, + authority_token=canonical_hash( + { + "authority_hash": authority.canonical_authority_hash, + "purpose": "RES69_SOURCE_BOUND_AUTHORITY_V1", + } + ), + ) + + +def build_reliability_design_authority( + evidence: ReliabilityDesignEvidence, +) -> ReliabilityDesignAuthority: + """Normalize source/protocol evidence into canonical production authority.""" + + if not isinstance(evidence, ReliabilityDesignEvidence): + raise ValueError("evidence must be a ReliabilityDesignEvidence") + _validate_reliability_evidence(evidence) + return _build_reliability_design_authority_from_evidence(evidence) + + +def _validated_pairs( + support: StatisticalSupport, + authority: ReliabilityDesignAuthority, +) -> tuple[ + tuple[ + ReliabilityReplicatePair, + LongitudinalObservationEntry, + LongitudinalObservationEntry, + float, + float, + ], + ..., +]: + validate_reliability_authority(authority, support) + validate_statistical_support(support) + if ( + authority.analysis_input_ids != support.input_ids + or authority.analysis_input_hashes != support.input_hashes + ): + raise StatisticalConstraintError( + "reliability authority analysis-input lineage does not match support", + RES69ReasonCode.SUPPORT_MISMATCH.value, + ) + if authority.source_records != support.source_records: + raise StatisticalConstraintError( + "reliability authority source records do not match support", + RES69ReasonCode.SUPPORT_MISMATCH.value, + ) + if ( + authority.source_artifacts != support.source_artifacts + or authority.source_provenance != support.source_provenance + ): + raise StatisticalConstraintError( + "reliability authority provenance does not match support", + RES69ReasonCode.SUPPORT_MISMATCH.value, + ) + if authority.replicate_ordering != RES69_REPLICATE_ORDERING: + raise StatisticalConstraintError( + "reliability replicate ordering is not registered", + RES69ReasonCode.ANALYSIS_DESIGN_MISMATCH.value, + ) + entries = {entry.canonical_entry_id: entry for entry in support.included_entries} + paired_ids: set[InstanceIdentifier] = set() + paired_athletes: set[InstanceIdentifier] = set() + values = [] + validated = [] + for pair in authority.replicate_pairs: + first = entries.get(pair.first_entry_id) + second = entries.get(pair.second_entry_id) + if first is None or second is None: + raise StatisticalConstraintError( + "reliability support has an incomplete exact pair", + RES69ReasonCode.INCOMPLETE_PAIR.value, + ) + if ( + StatisticalObservationReference.from_entry(first) != pair.first + or StatisticalObservationReference.from_entry(second) != pair.second + ): + raise StatisticalConstraintError( + "reliability pair hash does not match support", + RES69ReasonCode.SUPPORT_MISMATCH.value, + ) + if pair.athlete_id in paired_athletes: + raise StatisticalConstraintError( + "reliability support repeats an independent subject", + RES69ReasonCode.UNBALANCED_RELIABILITY_SUPPORT.value, + ) + first_value, first_unit = scalar_value(first) + second_value, second_unit = scalar_value(second) + if first_unit != second_unit or first_unit != authority.target_unit: + raise StatisticalConstraintError( + "reliability arithmetic requires one exact common source unit", + RES69ReasonCode.UNIT_MISMATCH.value, + ) + difference = second_value - first_value + paired_ids.update((pair.first_entry_id, pair.second_entry_id)) + paired_athletes.add(pair.athlete_id) + values.append(difference) + validated.append((pair, first, second, first_value, second_value)) + if paired_ids != {entry.canonical_entry_id for entry in support.included_entries}: + raise StatisticalConstraintError( + "reliability support contains an unpaired or missing observation", + RES69ReasonCode.INCOMPLETE_PAIR.value, + ) + if len(validated) < 2: + raise StatisticalConstraintError( + "two-replicate random-error SD requires N >= 2 complete subject pairs", + RES69ReasonCode.DATA_ADEQUACY_INSUFFICIENT.value, + ) + if not authority.balanced_design: + raise StatisticalConstraintError( + "V1 two-replicate reliability requires a balanced design", + RES69ReasonCode.UNBALANCED_RELIABILITY_SUPPORT.value, + ) + if authority.stable_underlying_quantity is not StableUnderlyingQuantityStatus.SUPPORTED: + raise StatisticalConstraintError( + "stable-underlying-quantity assessment is not supported by source authority", + RES69ReasonCode.RELIABILITY_AUTHORITY_REQUIRED.value, + ) + return tuple(validated) + + +def _sample_sd(values: tuple[float, ...]) -> float: + if len(values) < 2: + raise ValueError("sample SD requires at least two values") + mean = math.fsum(values) / len(values) + return math.sqrt(math.fsum((value - mean) ** 2 for value in values) / (len(values) - 1)) + + +def _two_replicate_random_error_values( + first_values: tuple[float, ...], + second_values: tuple[float, ...], +) -> tuple[float, float, float]: + """Pure two-replicate arithmetic; authority is validated by callers.""" + + if len(first_values) != len(second_values) or len(first_values) < 2: + raise ValueError("two-replicate random error requires at least two paired values") + differences = tuple( + second - first for first, second in zip(first_values, second_values, strict=True) + ) + mean_shift = math.fsum(differences) / len(differences) + sd_difference = _sample_sd(differences) + return mean_shift, sd_difference, sd_difference / math.sqrt(2.0) + + +def _raw_relative_error_values( + first_values: tuple[float, ...], + second_values: tuple[float, ...], +) -> tuple[float, float, float]: + """Pure arithmetic helper; source scale authority remains in the caller.""" + + if len(first_values) != len(second_values) or len(first_values) < 2: + raise ValueError("raw relative error requires at least two paired values") + raw_values = tuple( + value for pair in zip(first_values, second_values, strict=True) for value in pair + ) + raw_reference_mean = math.fsum(raw_values) / len(raw_values) + if raw_reference_mean <= 0: + raise ValueError("pooled raw grand mean must be strictly positive") + differences = tuple( + second - first for first, second in zip(first_values, second_values, strict=True) + ) + random_error_sd = _sample_sd(differences) / math.sqrt(2.0) + return raw_reference_mean, random_error_sd, 100.0 * random_error_sd / raw_reference_mean + + +def _log_scale_typical_error_values( + first_values: tuple[float, ...], + second_values: tuple[float, ...], +) -> tuple[float, float, float, float, float, float, float, float]: + """Pure log-error arithmetic helper; source scale authority remains in the caller.""" + + if len(first_values) != len(second_values) or len(first_values) < 2: + raise ValueError("log typical error requires at least two paired values") + if any(value <= 0 for value in (*first_values, *second_values)): + raise ValueError("log typical error requires strictly positive values") + logs = tuple( + math.log(second) - math.log(first) + for first, second in zip(first_values, second_values, strict=True) + ) + mean_log_shift = math.fsum(logs) / len(logs) + sd_log_difference = _sample_sd(logs) + te_log = sd_log_difference / math.sqrt(2.0) + factor = math.exp(te_log) + lower_factor = math.exp(-te_log) + upper_factor = math.exp(te_log) + lower_percent = 100.0 * (1.0 - lower_factor) + upper_percent = 100.0 * (upper_factor - 1.0) + return ( + mean_log_shift, + sd_log_difference, + te_log, + factor, + lower_factor, + upper_factor, + lower_percent, + upper_percent, + ) + + +def _reliability_authority_references( + authority: ReliabilityDesignAuthority, +) -> tuple[RegistryReference, ...]: + return (authority.design_or_study_identity, authority.producing_method) + + +def calculate_two_replicate_random_error( + support: StatisticalSupport, + authority: object, +) -> StatisticalResult | RefusalResult: + """Calculate mean trial shift, SD difference, and SD difference/sqrt(2).""" + + claim = "calculate two-replicate within-subject random error SD" + try: + if not isinstance(authority, ReliabilityDesignAuthority): + raise StatisticalConstraintError( + "source-bound ReliabilityDesignAuthority is required", + RES69ReasonCode.RELIABILITY_AUTHORITY_REQUIRED.value, + ) + if authority.error_scale is ReliabilityErrorScale.LOG_MULTIPLICATIVE: + raise StatisticalConstraintError( + "log-multiplicative designs require the registered log-scale estimator", + RES69ReasonCode.ANALYSIS_DESIGN_MISMATCH.value, + ) + pairs = _validated_pairs(support, authority) + mean_shift, sd_difference, random_error_sd = _two_replicate_random_error_values( + tuple(item[3] for item in pairs), + tuple(item[4] for item in pairs), + ) + parameters = _parameters( + ("pair_count", len(pairs)), + ("support_hash", support.canonical_support_hash), + ("authority_hash", authority.canonical_authority_hash), + ("estimator_identity", TWO_REPLICATE_WITHIN_SUBJECT_RANDOM_ERROR_SD_V1.stable_id), + ) + estimates = ( + make_estimate( + estimand=RES69_MEAN_TRIAL_SHIFT_ESTIMAND, + value=mean_shift, + unit=authority.target_unit, + estimator=TWO_REPLICATE_WITHIN_SUBJECT_RANDOM_ERROR_SD_V1, + parameters=parameters, + ), + make_estimate( + estimand=RES69_SD_DIFFERENCE_ESTIMAND, + value=sd_difference, + unit=authority.target_unit, + estimator=TWO_REPLICATE_WITHIN_SUBJECT_RANDOM_ERROR_SD_V1, + parameters=parameters, + ), + make_estimate( + estimand=RES69_RANDOM_ERROR_SD_ESTIMAND, + value=random_error_sd, + unit=authority.target_unit, + estimator=TWO_REPLICATE_WITHIN_SUBJECT_RANDOM_ERROR_SD_V1, + parameters=parameters, + ), + ) + return make_result( + support, + operation=RES69_TWO_REPLICATE_RANDOM_ERROR_OPERATION, + estimator=TWO_REPLICATE_WITHIN_SUBJECT_RANDOM_ERROR_SD_V1, + estimates=estimates, + parameters=parameters, + authority_references=_reliability_authority_references(authority), + authority_hashes=(authority.canonical_authority_hash,), + evidence_references=_source_evidence_references(authority), + registry_version=authority.registry_version, + ) + except ( + AttributeError, + KeyError, + TypeError, + ValueError, + ZeroDivisionError, + OverflowError, + ) as exc: + return refusal_for_exception(claim, exc, support=support) + + +# Typical error and within-subject SD are aliases under the one registered +# two-replicate estimator; they are intentionally not separate calculations. +calculate_typical_error = calculate_two_replicate_random_error + + +def calculate_raw_relative_error_percent( + support: StatisticalSupport, + authority: object, + *, + registry: MeasurementScaleRegistry = RES69_SCALE_REGISTRY, + denominator: object = _NOT_SUPPLIED, +) -> StatisticalResult | RefusalResult: + """Calculate raw relative error using the derived pooled 2N raw grand mean.""" + + claim = "calculate two-replicate pooled raw relative error percent" + try: + if denominator is not _NOT_SUPPLIED: + raise StatisticalConstraintError( + "raw relative error denominator is derived and cannot be caller supplied", + RES69ReasonCode.SUPPORT_MISMATCH.value, + ) + if not isinstance(authority, ReliabilityDesignAuthority): + raise StatisticalConstraintError( + "source-bound ReliabilityDesignAuthority is required", + RES69ReasonCode.RELIABILITY_AUTHORITY_REQUIRED.value, + ) + if authority.error_scale is not ReliabilityErrorScale.RAW_RELATIVE: + raise StatisticalConstraintError( + "raw relative error requires RAW_RELATIVE source/protocol authority", + RES69ReasonCode.ANALYSIS_DESIGN_MISMATCH.value, + ) + pairs = _validated_pairs(support, authority) + semantics, key, unit = resolve_scale_semantics(support, registry=registry) + if not semantics.raw_relative_error_authorized: + raise StatisticalConstraintError( + "registered scale semantics do not authorize raw relative error", + RES69ReasonCode.SCALE_OPERATION_NOT_AUTHORIZED.value, + ) + raw_reference_mean, random_error_sd, relative_error_percent = _raw_relative_error_values( + tuple(item[3] for item in pairs), + tuple(item[4] for item in pairs), + ) + parameters = _parameters( + ("pair_count", len(pairs)), + ("support_hash", support.canonical_support_hash), + ("authority_hash", authority.canonical_authority_hash), + ("denominator_identity", TWO_REPLICATE_POOLED_RAW_GRAND_MEAN_V1.stable_id), + ) + estimates = ( + make_estimate( + estimand=RES69_RAW_REFERENCE_MEAN_ESTIMAND, + value=raw_reference_mean, + unit=unit, + estimator=TWO_REPLICATE_POOLED_RAW_GRAND_MEAN_V1, + parameters=parameters, + ), + make_estimate( + estimand=RES69_RANDOM_ERROR_SD_ESTIMAND, + value=random_error_sd, + unit=unit, + estimator=TWO_REPLICATE_WITHIN_SUBJECT_RANDOM_ERROR_SD_V1, + parameters=parameters, + ), + make_estimate( + estimand=RES69_RAW_RELATIVE_ERROR_PERCENT_ESTIMAND, + value=relative_error_percent, + unit=RES69_PERCENT_UNIT, + estimator=TWO_REPLICATE_POOLED_RAW_GRAND_MEAN_V1, + parameters=parameters, + ), + ) + return make_result( + support, + operation=RES69_RAW_RELATIVE_ERROR_OPERATION, + estimator=TWO_REPLICATE_POOLED_RAW_GRAND_MEAN_V1, + estimates=estimates, + parameters=parameters, + authority_references=( + *_reliability_authority_references(authority), + semantics.authority_reference, + ), + authority_hashes=(authority.canonical_authority_hash,), + evidence_references=( + *_source_evidence_references(authority), + *semantics.evidence_references, + ), + scale_semantic_key=key, + scale_semantics_authority=semantics.authority_reference, + scale_semantics_hash=canonical_hash(semantics), + registry_version=registry.registry_version, + ) + except ( + AttributeError, + KeyError, + TypeError, + ValueError, + ZeroDivisionError, + OverflowError, + ) as exc: + return refusal_for_exception(claim, exc, support=support) + + +def calculate_log_scale_typical_error( + support: StatisticalSupport, + authority: object, + *, + registry: MeasurementScaleRegistry = RES69_SCALE_REGISTRY, +) -> StatisticalResult | RefusalResult: + """Calculate log typical error and its asymmetric multiplicative interval.""" + + claim = "calculate log-scale multiplicative typical error" + try: + if not isinstance(authority, ReliabilityDesignAuthority): + raise StatisticalConstraintError( + "source-bound ReliabilityDesignAuthority is required", + RES69ReasonCode.RELIABILITY_AUTHORITY_REQUIRED.value, + ) + if authority.error_scale is not ReliabilityErrorScale.LOG_MULTIPLICATIVE: + raise StatisticalConstraintError( + "log typical error requires LOG_MULTIPLICATIVE source/protocol authority", + RES69ReasonCode.ANALYSIS_DESIGN_MISMATCH.value, + ) + pairs = _validated_pairs(support, authority) + semantics, key, _unit = resolve_scale_semantics(support, registry=registry) + if not semantics.log_error_authorized: + raise StatisticalConstraintError( + "registered scale semantics do not authorize log-scale error", + RES69ReasonCode.SCALE_OPERATION_NOT_AUTHORIZED.value, + ) + try: + ( + mean_log_shift, + sd_log_difference, + te_log, + factor, + lower_factor, + upper_factor, + lower_percent, + upper_percent, + ) = _log_scale_typical_error_values( + tuple(item[3] for item in pairs), + tuple(item[4] for item in pairs), + ) + except ValueError as exc: + if "positive" in str(exc): + raise StatisticalConstraintError( + str(exc), + RES69ReasonCode.NONPOSITIVE_LOG_INPUT.value, + ) from exc + raise + parameters = _parameters( + ("pair_count", len(pairs)), + ("support_hash", support.canonical_support_hash), + ("authority_hash", authority.canonical_authority_hash), + ("canonical_factor_interval", f"[{lower_factor!r},{upper_factor!r}]"), + ) + estimates = ( + make_estimate( + estimand=RES69_MEAN_LOG_SHIFT_ESTIMAND, + value=mean_log_shift, + unit=RES69_LOG_RATIO_UNIT, + estimator=RES69_LOG_MULTIPLICATIVE_ERROR_ESTIMATOR, + parameters=parameters, + ), + make_estimate( + estimand=RES69_SD_LOG_DIFFERENCE_ESTIMAND, + value=sd_log_difference, + unit=RES69_LOG_RATIO_UNIT, + estimator=RES69_LOG_MULTIPLICATIVE_ERROR_ESTIMATOR, + parameters=parameters, + ), + make_estimate( + estimand=RES69_LOG_TYPICAL_ERROR_ESTIMAND, + value=te_log, + unit=RES69_LOG_RATIO_UNIT, + estimator=RES69_LOG_MULTIPLICATIVE_ERROR_ESTIMATOR, + parameters=parameters, + ), + make_estimate( + estimand=RES69_MULTIPLICATIVE_FACTOR_ESTIMAND, + value=factor, + unit=RES69_DIMENSIONLESS_UNIT, + estimator=RES69_LOG_MULTIPLICATIVE_ERROR_ESTIMATOR, + parameters=parameters, + ), + make_estimate( + estimand=RES69_LOWER_FACTOR_ESTIMAND, + value=lower_factor, + unit=RES69_DIMENSIONLESS_UNIT, + estimator=RES69_LOG_MULTIPLICATIVE_ERROR_ESTIMATOR, + parameters=parameters, + ), + make_estimate( + estimand=RES69_UPPER_FACTOR_ESTIMAND, + value=upper_factor, + unit=RES69_DIMENSIONLESS_UNIT, + estimator=RES69_LOG_MULTIPLICATIVE_ERROR_ESTIMATOR, + parameters=parameters, + ), + make_estimate( + estimand=RES69_LOWER_PERCENT_ESTIMAND, + value=lower_percent, + unit=RES69_PERCENT_UNIT, + estimator=RES69_LOG_MULTIPLICATIVE_ERROR_ESTIMATOR, + parameters=parameters, + ), + make_estimate( + estimand=RES69_UPPER_PERCENT_ESTIMAND, + value=upper_percent, + unit=RES69_PERCENT_UNIT, + estimator=RES69_LOG_MULTIPLICATIVE_ERROR_ESTIMATOR, + parameters=parameters, + ), + ) + return make_result( + support, + operation=RES69_LOG_SCALE_ERROR_OPERATION, + estimator=RES69_LOG_MULTIPLICATIVE_ERROR_ESTIMATOR, + estimates=estimates, + parameters=parameters, + authority_references=( + *_reliability_authority_references(authority), + semantics.authority_reference, + ), + authority_hashes=(authority.canonical_authority_hash,), + evidence_references=( + *_source_evidence_references(authority), + *semantics.evidence_references, + ), + scale_semantic_key=key, + scale_semantics_authority=semantics.authority_reference, + scale_semantics_hash=canonical_hash(semantics), + registry_version=registry.registry_version, + ) + except ( + AttributeError, + KeyError, + TypeError, + ValueError, + ZeroDivisionError, + OverflowError, + ) as exc: + return refusal_for_exception(claim, exc, support=support) + + +def refuse_unimplemented_reliability_operation( + operation: RegistryReference, + *, + support: StatisticalSupport | None = None, +) -> RefusalResult: + """Represent deferred/rejected reliability methods without placeholder arithmetic.""" + + return refusal( + f"calculate {operation.display_label}", + RES69ReasonCode.OPERATION_NOT_REGISTERED, + support=support, + missing_information=("a registered RES-69 deterministic calculator for this operation",), + ) + + +def calculate_sem_from_icc( + support: StatisticalSupport | None = None, +) -> RefusalResult: + return refuse_unimplemented_reliability_operation(SEM_FROM_REGISTERED_ICC, support=support) + + +def calculate_generic_sem( + support: StatisticalSupport | None = None, +) -> RefusalResult: + return refuse_unimplemented_reliability_operation(GENERIC_SEM, support=support) + + +def calculate_mdc_sdc( + support: StatisticalSupport | None = None, +) -> RefusalResult: + return refuse_unimplemented_reliability_operation(MDC_SDC, support=support) + + +calculate_raw_relative_error = calculate_raw_relative_error_percent +calculate_log_typical_error = calculate_log_scale_typical_error + + +__all__ = [ + "build_reliability_assumption_assessment", + "build_reliability_assumption_source_evidence", + "build_reliability_design_authority", + "build_reliability_design_evidence", + "calculate_generic_sem", + "calculate_log_scale_typical_error", + "calculate_log_typical_error", + "calculate_mdc_sdc", + "calculate_raw_relative_error", + "calculate_raw_relative_error_percent", + "calculate_sem_from_icc", + "calculate_two_replicate_random_error", + "calculate_typical_error", + "refuse_unimplemented_reliability_operation", +] diff --git a/src/dynamislm/longitudinal/statistics/support.py b/src/dynamislm/longitudinal/statistics/support.py new file mode 100644 index 0000000..b256b5a --- /dev/null +++ b/src/dynamislm/longitudinal/statistics/support.py @@ -0,0 +1,501 @@ +"""Construction and validation of exact RES-69 statistical support.""" + +from __future__ import annotations + +import datetime as datetime_module +import math +from collections.abc import Iterable + +from dynamislm.comparability.models import ( + ComparabilityRequest, + ComparabilityResult, + ComparabilityState, +) +from dynamislm.longitudinal.models import ( + LongitudinalAthletePerformanceRecord, + LongitudinalObservationEntry, + MultiSourceAnalysisInput, +) +from dynamislm.longitudinal.statistics.models import ( + MethodComparisonMethodKeyV1, + MissingnessPolicy, + StatisticalComparabilityEvidence, + StatisticalSourceRecordReference, + StatisticalSupport, + StatisticalSupportWindowKind, + StatisticalWindow, + SupportEntryExclusion, + SupportExclusionReason, +) +from dynamislm.measurement.identity import InstanceIdentifier, UnitReference +from dynamislm.measurement.result import ( + QualityStatus, + ResultStatus, + ScalarValue, +) +from dynamislm.population._authority import compute_source_authority, validate_source_decision + + +class StatisticalConstraintError(ValueError): + """Internal fail-closed constraint carrying its RES-69 refusal code.""" + + def __init__( + self, + message: str, + code: str, + *, + missing_information: tuple[str, ...] = (), + ) -> None: + super().__init__(message) + self.code = code + self.missing_information = missing_information + + +def _entry_sort_key(entry: LongitudinalObservationEntry) -> tuple[str, str, str]: + return ( + entry.observed_at.astimezone(datetime_module.UTC).isoformat(timespec="microseconds"), + entry.source_observation_id.qualified, + entry.canonical_entry_hash, + ) + + +def _normalise_inputs( + analysis_input: MultiSourceAnalysisInput | tuple[MultiSourceAnalysisInput, ...], +) -> tuple[MultiSourceAnalysisInput, ...]: + if isinstance(analysis_input, MultiSourceAnalysisInput): + return (analysis_input,) + if not isinstance(analysis_input, tuple) or not analysis_input: + raise ValueError("analysis_input must be a MultiSourceAnalysisInput or non-empty tuple") + if any(not isinstance(item, MultiSourceAnalysisInput) for item in analysis_input): + raise ValueError("analysis_input tuple must contain MultiSourceAnalysisInput values") + return tuple( + sorted( + analysis_input, + key=lambda item: (item.athlete.athlete_id.qualified, item.input_id.qualified), + ) + ) + + +def _record_references( + source_records: tuple[LongitudinalAthletePerformanceRecord, ...], +) -> tuple[StatisticalSourceRecordReference, ...]: + if not isinstance(source_records, tuple) or not source_records: + raise ValueError("source_records must be a non-empty immutable tuple") + references = tuple( + StatisticalSourceRecordReference.from_record(item) for item in source_records + ) + return tuple(sorted(references, key=lambda item: item.record_id.qualified)) + + +def _all_input_entries( + inputs: tuple[MultiSourceAnalysisInput, ...], +) -> dict[str, LongitudinalObservationEntry]: + result: dict[str, LongitudinalObservationEntry] = {} + for analysis_input in inputs: + for entry in analysis_input.entries: + key = entry.canonical_entry_id.qualified + previous = result.get(key) + if previous is not None and previous != entry: + raise ValueError("analysis inputs contain conflicting entry content") + result[key] = entry + return result + + +def _validate_source_records( + inputs: tuple[MultiSourceAnalysisInput, ...], + source_records: tuple[LongitudinalAthletePerformanceRecord, ...], +) -> None: + records_by_entry: dict[str, list[tuple[str, str]]] = {} + expected_athletes = {item.athlete.athlete_id for item in inputs} + for record in source_records: + if record.athlete.athlete_id not in expected_athletes: + raise ValueError("source record athlete is absent from the analysis inputs") + if record.record_id is None: + raise ValueError("source record must have a canonical ID") + record_hash = record.canonical_record_hash + for entry in record.entries: + records_by_entry.setdefault(entry.canonical_entry_id.qualified, []).append( + (entry.canonical_entry_hash, record_hash) + ) + for entry in _all_input_entries(inputs).values(): + matches = records_by_entry.get(entry.canonical_entry_id.qualified, ()) + if not any(entry.canonical_entry_hash == entry_hash for entry_hash, _ in matches): + raise ValueError("source records do not cover every exact analysis-input entry") + + +def _validate_source_qualification(inputs: tuple[MultiSourceAnalysisInput, ...]) -> None: + for entry in _all_input_entries(inputs).values(): + for binding in entry.source_qualification_bindings: + decision = binding.canonical_source_decision + expected = compute_source_authority(decision.source) + validate_source_decision(decision, expected) + + +def build_statistical_support( + analysis_input: MultiSourceAnalysisInput | tuple[MultiSourceAnalysisInput, ...], + included_entries: Iterable[LongitudinalObservationEntry], + *, + source_records: tuple[LongitudinalAthletePerformanceRecord, ...], + excluded_entries: Iterable[SupportEntryExclusion] = (), + missingness_policy: MissingnessPolicy = MissingnessPolicy.NO_IMPUTATION_NO_ZERO_FILL, + window: StatisticalWindow | None = None, + comparability_evidence: tuple[StatisticalComparabilityEvidence, ...] = (), + current_entry_id: InstanceIdentifier | None = None, + reference_entry_ids: tuple[InstanceIdentifier, ...] = (), +) -> StatisticalSupport: + """Build support only when included and excluded entry coverage is explicit.""" + + inputs = _normalise_inputs(analysis_input) + all_entries = _all_input_entries(inputs) + included = tuple(sorted(tuple(included_entries), key=_entry_sort_key)) + excluded = tuple( + sorted(tuple(excluded_entries), key=lambda item: item.reference.entry_id.qualified) + ) + comparability_evidence = tuple( + sorted( + comparability_evidence, + key=lambda item: tuple(identifier.qualified for identifier in item.pair_ids), + ) + ) + if not included: + raise ValueError("included_entries must not be empty") + if not isinstance(excluded, tuple): + excluded = tuple(excluded) + if not excluded and {item.canonical_entry_id.qualified for item in included} != set( + all_entries + ): + raise ValueError("omitted entries require explicit SupportEntryExclusion values") + if window is None: + window = StatisticalWindow(StatisticalSupportWindowKind.FULL_SUPPORT) + records = tuple(source_records) + _validate_source_records(inputs, records) + _validate_source_qualification(inputs) + support = StatisticalSupport( + analysis_inputs=inputs, + included_entries=included, + excluded_entries=excluded, + missingness_policy=missingness_policy, + window=window, + source_records=_record_references(records), + comparability_evidence=comparability_evidence, + current_entry_id=current_entry_id, + reference_entry_ids=reference_entry_ids, + ) + return support + + +def build_comparability_evidence( + request: ComparabilityRequest, + result: ComparabilityResult, +) -> StatisticalComparabilityEvidence: + """Bind an existing authoritative comparability result to its exact request.""" + + return StatisticalComparabilityEvidence(request=request, result=result) + + +def _outside_window_exclusions( + inputs: tuple[MultiSourceAnalysisInput, ...], + included_ids: set[str], +) -> tuple[SupportEntryExclusion, ...]: + return tuple( + SupportEntryExclusion.from_entry(entry, SupportExclusionReason.OUTSIDE_WINDOW) + for entry in _all_input_entries(inputs).values() + if entry.canonical_entry_id.qualified not in included_ids + ) + + +def build_count_window_support( + analysis_input: MultiSourceAnalysisInput, + count: int, + *, + source_records: tuple[LongitudinalAthletePerformanceRecord, ...], + missingness_policy: MissingnessPolicy = MissingnessPolicy.NO_IMPUTATION_NO_ZERO_FILL, + comparability_evidence: tuple[StatisticalComparabilityEvidence, ...] = (), +) -> StatisticalSupport: + """Select the latest exact count of entries, recording all other entries.""" + + if isinstance(count, bool) or not isinstance(count, int) or count < 1: + raise ValueError("count must be a positive integer") + ordered = tuple(sorted(analysis_input.entries, key=_entry_sort_key)) + if count > len(ordered): + raise ValueError("count window exceeds available entries") + selected = ordered[-count:] + return build_statistical_support( + analysis_input, + selected, + source_records=source_records, + excluded_entries=_outside_window_exclusions( + (analysis_input,), + {entry.canonical_entry_id.qualified for entry in selected}, + ), + missingness_policy=missingness_policy, + window=StatisticalWindow(StatisticalSupportWindowKind.COUNT_WINDOW, count=count), + comparability_evidence=comparability_evidence, + ) + + +def _in_time_window( + entry: LongitudinalObservationEntry, + start: datetime_module.datetime, + end: datetime_module.datetime, + start_inclusive: bool, + end_inclusive: bool, +) -> bool: + at = entry.observed_at + left = at >= start if start_inclusive else at > start + right = at <= end if end_inclusive else at < end + return left and right + + +def build_time_window_support( + analysis_input: MultiSourceAnalysisInput, + start: datetime_module.datetime, + end: datetime_module.datetime, + *, + start_inclusive: bool = True, + end_inclusive: bool = True, + source_records: tuple[LongitudinalAthletePerformanceRecord, ...], + missingness_policy: MissingnessPolicy = MissingnessPolicy.NO_IMPUTATION_NO_ZERO_FILL, + comparability_evidence: tuple[StatisticalComparabilityEvidence, ...] = (), +) -> StatisticalSupport: + """Select entries using exact timestamp bounds and inclusivity.""" + + window = StatisticalWindow( + StatisticalSupportWindowKind.TIME_WINDOW, + start=start, + end=end, + start_inclusive=start_inclusive, + end_inclusive=end_inclusive, + ) + selected = tuple( + entry + for entry in analysis_input.entries + if _in_time_window(entry, start, end, start_inclusive, end_inclusive) + ) + return build_statistical_support( + analysis_input, + selected, + source_records=source_records, + excluded_entries=_outside_window_exclusions( + (analysis_input,), + {entry.canonical_entry_id.qualified for entry in selected}, + ), + missingness_policy=missingness_policy, + window=window, + comparability_evidence=comparability_evidence, + ) + + +def build_pair_support( + analysis_input: MultiSourceAnalysisInput, + first: LongitudinalObservationEntry, + second: LongitudinalObservationEntry, + *, + source_records: tuple[LongitudinalAthletePerformanceRecord, ...], + missingness_policy: MissingnessPolicy = MissingnessPolicy.NO_IMPUTATION_NO_ZERO_FILL, + comparability_evidence: tuple[StatisticalComparabilityEvidence, ...] = (), +) -> StatisticalSupport: + """Build an exact two-entry support with explicit non-selected entries.""" + + selected_ids = {first.canonical_entry_id.qualified, second.canonical_entry_id.qualified} + return build_statistical_support( + analysis_input, + (first, second), + source_records=source_records, + excluded_entries=_outside_window_exclusions((analysis_input,), selected_ids), + missingness_policy=missingness_policy, + comparability_evidence=comparability_evidence, + ) + + +def build_reference_window_support( + analysis_input: MultiSourceAnalysisInput, + current: LongitudinalObservationEntry, + reference_entries: Iterable[LongitudinalObservationEntry], + *, + source_records: tuple[LongitudinalAthletePerformanceRecord, ...], + missingness_policy: MissingnessPolicy = MissingnessPolicy.NO_IMPUTATION_NO_ZERO_FILL, + comparability_evidence: tuple[StatisticalComparabilityEvidence, ...] = (), +) -> StatisticalSupport: + """Build support with an explicit current entry and prior reference entries.""" + + references = tuple(sorted(tuple(reference_entries), key=_entry_sort_key)) + selected = (*references, current) + selected_ids = {entry.canonical_entry_id.qualified for entry in selected} + if len(selected_ids) != len(selected): + raise ValueError("reference support entries must be unique") + return build_statistical_support( + analysis_input, + selected, + source_records=source_records, + excluded_entries=_outside_window_exclusions((analysis_input,), selected_ids), + missingness_policy=missingness_policy, + comparability_evidence=comparability_evidence, + current_entry_id=current.canonical_entry_id, + reference_entry_ids=tuple(entry.canonical_entry_id for entry in references), + ) + + +def validate_statistical_support(support: StatisticalSupport) -> None: + """Fail closed on support identity, source qualification, or hash mismatch.""" + + if not isinstance(support, StatisticalSupport): + raise ValueError("support must be a StatisticalSupport") + if not support.source_records: + raise ValueError("authoritative statistical support requires source-record references") + expected_id = InstanceIdentifier( + "statistical-support", + support.canonical_support_hash.removeprefix("sha256:"), + ) + if support.canonical_support_id != expected_id: + raise ValueError("support ID does not match its support hash") + _validate_source_qualification(support.analysis_inputs) + all_entries = _all_input_entries(support.analysis_inputs) + for entry in support.included_entries: + if all_entries.get(entry.canonical_entry_id.qualified) != entry: + raise ValueError("support included entry is not an exact RES-62 entry") + for item in support.excluded_entries: + if all_entries.get(item.reference.entry_id.qualified) is None: + raise ValueError("support exclusion is not an exact RES-62 entry") + + +def scalar_value(entry: LongitudinalObservationEntry) -> tuple[float, UnitReference]: + """Return one valid finite scalar and its exact source unit for internal calculators.""" + + if not isinstance(entry, LongitudinalObservationEntry): + raise ValueError("entry must be a LongitudinalObservationEntry") + observation = entry.observation + result = observation.result + if result.status is not ResultStatus.VALID: + raise ValueError("source result is not valid") + if result.quality.status is QualityStatus.REJECTED: + raise ValueError("source result quality is rejected") + if not isinstance(result.value, ScalarValue): + raise ValueError("statistical operation requires scalar source results") + value = result.value.value + if isinstance(value, bool) or not isinstance(value, int | float): + raise ValueError("source scalar must be numeric and not boolean") + numeric = float(value) + if not math.isfinite(numeric): + raise ValueError("source scalar must be finite") + if result.unit is None: + raise ValueError("source result must carry an exact UnitReference") + return numeric, result.unit + + +def exact_common_unit(entries: tuple[LongitudinalObservationEntry, ...]) -> UnitReference: + if not entries: + raise ValueError("at least one entry is required") + units = tuple(scalar_value(entry)[1] for entry in entries) + first = units[0] + if any(unit != first for unit in units[1:]): + raise ValueError("exact common UnitReference is required; conversion is not registered") + return first + + +def measurement_target_signature(entry: LongitudinalObservationEntry) -> tuple[object, ...]: + identity = entry.observation.identity + return ( + identity.semantic.construct.identifier, + identity.semantic.test_family.identifier, + identity.semantic.measurand.identifier, + identity.semantic.metric_definition.identifier, + ) + + +def _method_signature(entry: LongitudinalObservationEntry) -> MethodComparisonMethodKeyV1: + identity = entry.observation.identity + _value, unit = scalar_value(entry) + return MethodComparisonMethodKeyV1.from_measurement_identity(identity, unit) + + +def validate_comparable_entries( + support: StatisticalSupport, + entries: tuple[LongitudinalObservationEntry, ...], +) -> tuple[object, ...]: + """Require exact units and complete pairwise comparability authority.""" + + if len(entries) < 1: + raise StatisticalConstraintError( + "comparability requires at least one entry", + "RES69_DATA_ADEQUACY_INSUFFICIENT", + ) + try: + exact_common_unit(entries) + except ValueError as exc: + raise StatisticalConstraintError( + "direct statistical arithmetic requires one exact common UnitReference", + "RES69_UNIT_MISMATCH", + missing_information=("registered deterministic unit conversion",), + ) from exc + targets = tuple(measurement_target_signature(entry) for entry in entries) + if any(target != targets[0] for target in targets[1:]): + raise StatisticalConstraintError( + "entries do not share the same construct, test family, measurand, and metric", + "RES69_IDENTITY_UNRESOLVED", + missing_information=("one intended target measurement identity",), + ) + evidence_by_pair: dict[frozenset[str], StatisticalComparabilityEvidence] = {} + for evidence in support.comparability_evidence: + pair = frozenset(item.qualified for item in evidence.pair_ids) + if len(pair) != 2: + raise StatisticalConstraintError( + "comparability evidence must identify two distinct observations", + "RES69_SUPPORT_MISMATCH", + ) + if pair in evidence_by_pair and evidence_by_pair[pair] != evidence: + raise StatisticalConstraintError( + "conflicting comparability evidence was supplied for one observation pair", + "RES69_REGISTRY_INTEGRITY_FAILURE", + ) + evidence_by_pair[pair] = evidence + + references: list[object] = [] + for index, left in enumerate(entries): + for right in entries[index + 1 :]: + pair = frozenset( + (left.source_observation_id.qualified, right.source_observation_id.qualified) + ) + if _method_signature(left) == _method_signature(right): + from dynamislm.longitudinal.statistics.registry import ( + RES69_EXACT_SEMANTIC_COMPARABILITY_RULE, + ) + + references.append(RES69_EXACT_SEMANTIC_COMPARABILITY_RULE) + continue + evidence_for_pair = evidence_by_pair.get(pair) + if evidence_for_pair is None: + raise StatisticalConstraintError( + "material method/device differences require complete pairwise " + "comparability evidence", + "RES69_COMPARABILITY_UNESTABLISHED", + missing_information=( + "authoritative comparability evidence for every material pair", + ), + ) + if evidence_for_pair.result.state not in ( + ComparabilityState.COMPARABLE, + ComparabilityState.COMPARABLE_WITH_CONDITIONS, + ): + raise StatisticalConstraintError( + "comparability evidence is not affirmative", + "RES69_COMPARABILITY_UNESTABLISHED", + ) + assert evidence_for_pair.result.rule_reference is not None + references.append(evidence_for_pair.result.rule_reference) + return tuple(references) + + +__all__ = [ + "build_comparability_evidence", + "build_count_window_support", + "build_pair_support", + "build_reference_window_support", + "build_statistical_support", + "build_time_window_support", + "exact_common_unit", + "measurement_target_signature", + "scalar_value", + "validate_comparable_entries", + "validate_statistical_support", +] diff --git a/src/dynamislm/longitudinal/statistics/validation.py b/src/dynamislm/longitudinal/statistics/validation.py new file mode 100644 index 0000000..939b9b1 --- /dev/null +++ b/src/dynamislm/longitudinal/statistics/validation.py @@ -0,0 +1,489 @@ +"""Fail-closed validation and result/refusal factories for RES-69.""" + +from __future__ import annotations + +from dynamislm.longitudinal.lineage import build_multi_source_provenance_graph +from dynamislm.longitudinal.record import build_multi_source_processing_run +from dynamislm.longitudinal.statistics.models import ( + MeasurementScaleSemanticKeyV1, + MeasurementScaleSemantics, + ReliabilityAssumptionDeclarationOrigin, + RES69ReasonCode, + ScaleAuthorityOrigin, + StatisticalAnalysisRun, + StatisticalEstimate, + StatisticalNonComputable, + StatisticalResult, + StatisticalSupport, + StatisticalUnitReference, +) +from dynamislm.longitudinal.statistics.registry import ( + RES69_METHOD_COMPARISON_DESIGN_OPERATION, + RES69_OPERATION_REGISTRY, + RES69_REGISTRY_VERSION, + RES69_RELIABILITY_ASSUMPTION_DECLARATION_REGISTRY, + RES69_RELIABILITY_DESIGN_OPERATION, + RES69_SCALE_REGISTRY, + RES69_SOFTWARE_VERSION, +) +from dynamislm.longitudinal.statistics.support import ( + StatisticalConstraintError, + exact_common_unit, + validate_statistical_support, +) +from dynamislm.measurement.identity import ( + InstanceIdentifier, + MetadataEntry, + RegistryReference, + UnitReference, +) +from dynamislm.provenance.models import EvidenceReference +from dynamislm.refusal.models import ( + RefusalClass, + RefusalResult, + RefusalStatus, +) +from dynamislm.serialization import canonical_hash + + +def _reason_value(code: RES69ReasonCode | str) -> str: + return code.value if isinstance(code, RES69ReasonCode) else code + + +def _default_refusal_class(code: str) -> RefusalClass: + if code in { + RES69ReasonCode.IDENTITY_UNRESOLVED.value, + }: + return RefusalClass.IDENTITY_UNRESOLVED + if code in { + RES69ReasonCode.COMPARABILITY_UNESTABLISHED.value, + RES69ReasonCode.UNIT_MISMATCH.value, + RES69ReasonCode.UNIT_CONVERSION_NOT_REGISTERED.value, + }: + return RefusalClass.COMPARABILITY_UNESTABLISHED + if code in { + RES69ReasonCode.RELIABILITY_AUTHORITY_REQUIRED.value, + RES69ReasonCode.METHOD_COMPARISON_AUTHORITY_REQUIRED.value, + }: + return RefusalClass.EVIDENCE_SCOPE_UNSUPPORTED + if code in { + RES69ReasonCode.SCALE_SEMANTICS_UNREGISTERED.value, + RES69ReasonCode.SCALE_OPERATION_NOT_AUTHORIZED.value, + RES69ReasonCode.OPERATION_NOT_REGISTERED.value, + RES69ReasonCode.CLASSICAL_LOA_DEFERRED.value, + }: + return RefusalClass.COMPUTATION_NOT_REGISTERED + if code in { + RES69ReasonCode.ANALYSIS_DESIGN_MISMATCH.value, + RES69ReasonCode.SUPPORT_MISMATCH.value, + RES69ReasonCode.INCOMPLETE_PAIR.value, + RES69ReasonCode.UNBALANCED_RELIABILITY_SUPPORT.value, + RES69ReasonCode.ZERO_REFERENCE_SD.value, + RES69ReasonCode.DUPLICATE_TIMESTAMP.value, + }: + return RefusalClass.ANALYSIS_DESIGN_MISMATCH + if code == RES69ReasonCode.INTERPRETATION_NOT_AUTHORIZED.value: + return RefusalClass.UNCERTAINTY_LIMITS_CLAIM + return RefusalClass.DATA_ADEQUACY_INSUFFICIENT + + +def refusal( + blocked_claim: str, + code: RES69ReasonCode | str, + *, + support: StatisticalSupport | None = None, + missing_information: tuple[str, ...] = (), + safe_descriptions: tuple[str, ...] = (), + refusal_class: RefusalClass | None = None, + evidence_references: tuple[RegistryReference, ...] = (), + additional_reason_codes: tuple[str, ...] = (), +) -> RefusalResult: + """Build a deterministic claim-specific refusal while retaining observations.""" + + reason = _reason_value(code) + reasons = tuple(dict.fromkeys((reason, *additional_reason_codes))) + observation_ids = ( + support.source_observation_ids if isinstance(support, StatisticalSupport) else () + ) + safe = safe_descriptions or ( + "the exact source observations remain independently describable under their own identity", + ) + normalized_missing = tuple(dict.fromkeys(missing_information)) + normalized_evidence = tuple(dict.fromkeys(evidence_references)) + payload = { + "blocked_claim": blocked_claim, + "reason_codes": reasons, + "missing_information": normalized_missing, + "observation_ids": observation_ids, + "evidence_references": normalized_evidence, + } + refusal_id = InstanceIdentifier( + "refusal", + f"res69:{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 or _default_refusal_class(reason), + blocked_claim=blocked_claim, + reason_codes=reasons, + missing_information=normalized_missing, + what_can_still_be_safely_described=safe, + evidence_references=normalized_evidence, + observation_ids=observation_ids, + ) + + +def refusal_for_exception( + blocked_claim: str, + exc: Exception, + *, + support: StatisticalSupport | None = None, + fallback_code: RES69ReasonCode = RES69ReasonCode.DATA_ADEQUACY_INSUFFICIENT, +) -> RefusalResult: + if isinstance(exc, StatisticalConstraintError): + code = exc.code + missing = exc.missing_information + else: + code = fallback_code.value + missing = (str(exc),) if str(exc) else () + additional = ( + (RES69ReasonCode.UNIT_CONVERSION_NOT_REGISTERED.value,) + if code == RES69ReasonCode.UNIT_MISMATCH.value + else () + ) + return refusal( + blocked_claim, + code, + support=support, + missing_information=missing, + additional_reason_codes=additional, + ) + + +def build_analysis_run( + support: StatisticalSupport, + *, + operation: RegistryReference, + estimator: RegistryReference, + parameters: tuple[MetadataEntry, ...] = (), + evidence_references: tuple[EvidenceReference, ...] = (), + scale_semantic_key: MeasurementScaleSemanticKeyV1 | None = None, + scale_semantics_authority: RegistryReference | None = None, + scale_semantics_hash: str | None = None, + authority_hashes: tuple[str, ...] = (), + registry_version: str = RES69_REGISTRY_VERSION, +) -> StatisticalAnalysisRun: + """Create one immutable statistical run and one RES-62 graph per input.""" + + validate_statistical_support(support) + registered = RES69_OPERATION_REGISTRY.get(operation) + if registered is None or registered.disposition.value != "IMPLEMENTED": + raise StatisticalConstraintError( + "requested statistical operation is not registered as implemented", + RES69ReasonCode.OPERATION_NOT_REGISTERED.value, + ) + processing_runs = tuple( + build_multi_source_processing_run( + analysis_input, + method=operation, + parameters=parameters, + software_version=RES69_SOFTWARE_VERSION, + ) + for analysis_input in support.analysis_inputs + ) + graphs = tuple( + build_multi_source_provenance_graph(analysis_input, processing_run) + for analysis_input, processing_run in zip( + support.analysis_inputs, + processing_runs, + strict=True, + ) + ) + evidence = tuple(evidence_references) + return StatisticalAnalysisRun( + analysis_inputs=support.analysis_inputs, + support_id=support.canonical_support_id, + support_hash=support.canonical_support_hash, + operation=operation, + estimator=estimator, + parameters=parameters, + software_version=RES69_SOFTWARE_VERSION, + registry_version=registry_version, + processing_runs=processing_runs, + provenance_graphs=graphs, + source_records=support.source_records, + evidence_references=evidence, + support=support, + scale_semantic_key=scale_semantic_key, + scale_semantics_authority=scale_semantics_authority, + scale_semantics_hash=scale_semantics_hash, + authority_hashes=authority_hashes, + ) + + +def make_estimate( + *, + estimand: RegistryReference, + value: float, + unit: StatisticalUnitReference, + estimator: RegistryReference, + parameters: tuple[MetadataEntry, ...] = (), +) -> StatisticalEstimate: + estimate_id = InstanceIdentifier( + "statistical-estimate", + canonical_hash( + { + "estimand": estimand, + "value": value, + "unit": unit, + "estimator": estimator, + "parameters": parameters, + } + ).removeprefix("sha256:"), + ) + return StatisticalEstimate( + estimate_id=estimate_id, + estimand=estimand, + value=value, + unit=unit, + estimator=estimator, + parameters=parameters, + ) + + +def make_result( + support: StatisticalSupport, + *, + operation: RegistryReference, + estimator: RegistryReference, + estimates: tuple[StatisticalEstimate, ...], + parameters: tuple[MetadataEntry, ...] = (), + authority_references: tuple[RegistryReference, ...] = (), + non_computable: tuple[StatisticalNonComputable, ...] = (), + evidence_references: tuple[EvidenceReference, ...] = (), + scale_semantic_key: MeasurementScaleSemanticKeyV1 | None = None, + scale_semantics_authority: RegistryReference | None = None, + scale_semantics_hash: str | None = None, + authority_hashes: tuple[str, ...] = (), + registry_version: str = RES69_REGISTRY_VERSION, +) -> StatisticalResult: + if scale_semantic_key is None: + try: + unit = exact_common_unit(support.included_entries) + scale_semantic_key = MeasurementScaleSemanticKeyV1.from_measurement_identity( + support.included_entries[0].observation.identity, + unit, + ) + except (AttributeError, TypeError, ValueError): + scale_semantic_key = None + run = build_analysis_run( + support, + operation=operation, + estimator=estimator, + parameters=parameters, + evidence_references=evidence_references, + scale_semantic_key=scale_semantic_key, + scale_semantics_authority=scale_semantics_authority, + scale_semantics_hash=scale_semantics_hash, + authority_hashes=authority_hashes, + registry_version=registry_version, + ) + return StatisticalResult( + analysis_run=run, + result_kind=operation, + estimates=estimates, + non_computable=non_computable, + authority_references=authority_references, + ) + + +def resolve_scale_semantics( + support: StatisticalSupport, + *, + registry: object, +) -> tuple[MeasurementScaleSemantics, MeasurementScaleSemanticKeyV1, UnitReference]: + """Resolve registered scale semantics for the exact first observation.""" + + from dynamislm.longitudinal.statistics.models import MeasurementScaleSemanticKeyV1 + from dynamislm.longitudinal.statistics.registry import MeasurementScaleRegistry + + if not isinstance(registry, MeasurementScaleRegistry): + raise StatisticalConstraintError( + "scale authority must be supplied through the typed RES-69 registry", + RES69ReasonCode.SCALE_SEMANTICS_UNREGISTERED.value, + ) + if registry is not RES69_SCALE_REGISTRY: + raise StatisticalConstraintError( + "public scale authority must resolve from the canonical production registry", + RES69ReasonCode.SCALE_OPERATION_NOT_AUTHORIZED.value, + missing_information=("canonical RES-69 production scale registry",), + ) + unit = exact_common_unit(support.included_entries) + identity = support.included_entries[0].observation.identity + key = MeasurementScaleSemanticKeyV1.from_measurement_identity(identity, unit) + try: + semantics = registry.resolve(key) + except ValueError as exc: + raise StatisticalConstraintError( + "scale registry contains conflicting authority", + RES69ReasonCode.REGISTRY_INTEGRITY_FAILURE.value, + ) from exc + if semantics is None: + raise StatisticalConstraintError( + "measurement scale semantics are not registered for this exact semantic key", + RES69ReasonCode.SCALE_SEMANTICS_UNREGISTERED.value, + missing_information=( + "registered MeasurementScaleSemantics for the exact semantic key", + ), + ) + if semantics.authority_origin is not ScaleAuthorityOrigin.PRODUCTION: + raise StatisticalConstraintError( + "synthetic scale authority cannot authorize a production statistical result", + RES69ReasonCode.SCALE_OPERATION_NOT_AUTHORIZED.value, + missing_information=("production scale authority for the exact semantic key",), + ) + return semantics, key, unit + + +def validate_reliability_authority( + authority: object, + support: StatisticalSupport, +) -> None: + from dynamislm.longitudinal.statistics.models import ReliabilityDesignAuthority + + if not isinstance(authority, ReliabilityDesignAuthority) or not authority.is_source_bound: + raise StatisticalConstraintError( + "source-bound ReliabilityDesignAuthority is required", + RES69ReasonCode.RELIABILITY_AUTHORITY_REQUIRED.value, + ) + if ( + authority.support_id != support.canonical_support_id + or authority.support_hash != support.canonical_support_hash + ): + raise StatisticalConstraintError( + "reliability authority does not match the exact statistical support", + RES69ReasonCode.SUPPORT_MISMATCH.value, + ) + if not authority.assumption_assessment.is_source_bound: + raise StatisticalConstraintError( + "source-bound ReliabilityAssumptionAssessment is required", + RES69ReasonCode.RELIABILITY_AUTHORITY_REQUIRED.value, + ) + assessment = authority.assumption_assessment + try: + declaration = RES69_RELIABILITY_ASSUMPTION_DECLARATION_REGISTRY.resolve( + assessment.declaration_reference + ) + except ValueError as exc: + raise StatisticalConstraintError( + "reliability declaration registry contains conflicting authority", + RES69ReasonCode.REGISTRY_INTEGRITY_FAILURE.value, + ) from exc + if declaration is None: + raise StatisticalConstraintError( + "reliability authority requires a registered production assumption declaration", + RES69ReasonCode.RELIABILITY_AUTHORITY_REQUIRED.value, + missing_information=("canonical production reliability assumption declaration",), + ) + if declaration.authority_origin is not ReliabilityAssumptionDeclarationOrigin.PRODUCTION: + raise StatisticalConstraintError( + "synthetic reliability declarations cannot authorize a production statistical result", + RES69ReasonCode.RELIABILITY_AUTHORITY_REQUIRED.value, + ) + if declaration.registry_version != RES69_REGISTRY_VERSION: + raise StatisticalConstraintError( + "reliability declaration registry version is not the registered RES-69 version", + RES69ReasonCode.RELIABILITY_AUTHORITY_REQUIRED.value, + ) + if ( + assessment.declaration_hash != declaration.canonical_declaration_hash + or assessment.study_identity != declaration.study_identity + or assessment.protocol_reference != declaration.protocol_reference + or assessment.evidence_references != declaration.evidence_references + or assessment.stable_underlying_quantity != declaration.stable_underlying_quantity + or assessment.systematic_trial_effect != declaration.systematic_trial_effect + or assessment.error_scale != declaration.error_scale + or assessment.producing_method != declaration.producing_method + or assessment.registry_version != declaration.registry_version + ): + raise StatisticalConstraintError( + "reliability assessment does not match registered declaration authority", + RES69ReasonCode.RELIABILITY_AUTHORITY_REQUIRED.value, + ) + source_evidence = assessment.source_evidence + if ( + source_evidence.declaration_reference != declaration.declaration_reference + or source_evidence.declaration_hash != declaration.canonical_declaration_hash + or source_evidence.study_identity != declaration.study_identity + or source_evidence.protocol_reference != declaration.protocol_reference + or source_evidence.evidence_references != declaration.evidence_references + or source_evidence.producing_method != declaration.producing_method + or source_evidence.registry_version != declaration.registry_version + or assessment.source_evidence_hash != canonical_hash(source_evidence) + ): + raise StatisticalConstraintError( + "reliability source evidence does not match registered declaration authority", + RES69ReasonCode.RELIABILITY_AUTHORITY_REQUIRED.value, + ) + if authority.producing_method != RES69_RELIABILITY_DESIGN_OPERATION: + raise StatisticalConstraintError( + "reliability authority must use the registered design-normalization operation", + RES69ReasonCode.RELIABILITY_AUTHORITY_REQUIRED.value, + ) + if authority.registry_version != RES69_REGISTRY_VERSION: + raise StatisticalConstraintError( + "reliability authority registry version is not the registered RES-69 version", + RES69ReasonCode.RELIABILITY_AUTHORITY_REQUIRED.value, + ) + if ( + assessment.support_id != support.canonical_support_id + or assessment.support_hash != support.canonical_support_hash + ): + raise StatisticalConstraintError( + "reliability assumption assessment does not match exact support", + RES69ReasonCode.SUPPORT_MISMATCH.value, + ) + + +def validate_method_comparison_authority( + authority: object, + support: StatisticalSupport, +) -> None: + from dynamislm.longitudinal.statistics.models import MethodComparisonDesignAuthority + + if not isinstance(authority, MethodComparisonDesignAuthority) or not authority.is_source_bound: + raise StatisticalConstraintError( + "source-bound MethodComparisonDesignAuthority is required", + RES69ReasonCode.METHOD_COMPARISON_AUTHORITY_REQUIRED.value, + ) + if ( + authority.support_id != support.canonical_support_id + or authority.support_hash != support.canonical_support_hash + ): + raise StatisticalConstraintError( + "method-comparison authority does not match the exact statistical support", + RES69ReasonCode.SUPPORT_MISMATCH.value, + ) + if authority.producing_method != RES69_METHOD_COMPARISON_DESIGN_OPERATION: + raise StatisticalConstraintError( + "method-comparison authority must use the registered design-normalization operation", + RES69ReasonCode.METHOD_COMPARISON_AUTHORITY_REQUIRED.value, + ) + + +def validate_statistical_result(result: StatisticalResult) -> None: + if not isinstance(result, StatisticalResult): + raise ValueError("result must be a StatisticalResult") + if result.canonical_result_id != InstanceIdentifier( + "statistical-result", result.canonical_output_hash.removeprefix("sha256:") + ): + raise ValueError("statistical result ID does not match output hash") + + +__all__ = [ + "validate_method_comparison_authority", + "validate_reliability_authority", + "validate_statistical_result", + "validate_statistical_support", +] diff --git a/tests/test_longitudinal_statistics.py b/tests/test_longitudinal_statistics.py new file mode 100644 index 0000000..d86d48f --- /dev/null +++ b/tests/test_longitudinal_statistics.py @@ -0,0 +1,1334 @@ +from __future__ import annotations + +import datetime as datetime_module +import inspect +import json +import math +from dataclasses import replace + +import pytest + +from dynamislm import ( + build_longitudinal_observation_entry, + build_longitudinal_record, + build_multi_source_analysis_input, + canonical_hash, + canonical_json, + from_canonical_json, +) +from dynamislm.football.models import AthleteIdentity +from dynamislm.longitudinal.models import ( + LongitudinalAthletePerformanceRecord, + LongitudinalObservationEntry, + LongitudinalRecordOrigin, + MultiSourceAnalysisInput, + MultiSourceAnalysisScope, +) +from dynamislm.longitudinal.statistics import ( + PRODUCTION_RELIABILITY_ASSUMPTION_DECLARATIONS, + RES69_METHOD_COMPARISON_DESIGN_OPERATION, + RES69_RELIABILITY_ASSUMPTION_ASSESSMENT_OPERATION, + RES69_RELIABILITY_ASSUMPTION_DECLARATION_REGISTRY, + RES69_RELIABILITY_DESIGN_OPERATION, + RES69_REPLICATE_ORDERING, + RES69_SCALE_REGISTRY, + RES69_SCALE_SEMANTICS_AUTHORITY, + AuthorityStatus, + DenominatorPolicy, + MeasurementScaleKind, + MeasurementScaleRegistry, + MeasurementScaleSemanticKeyV1, + MeasurementScaleSemantics, + MethodComparisonDesignAuthority, + MethodComparisonMethodKeyV1, + MethodComparisonPair, + MissingnessPolicy, + ReliabilityAssumptionAssessment, + ReliabilityAssumptionDeclarationOrigin, + ReliabilityAssumptionDeclarationRegistry, + ReliabilityAssumptionDeclarationV1, + ReliabilityAssumptionSourceEvidence, + ReliabilityDesignAuthority, + ReliabilityErrorScale, + ReliabilityQuestion, + ReliabilityReplicatePair, + RES69ReasonCode, + ScaleAuthorityOrigin, + SignedValuePolicy, + StableUnderlyingQuantityStatus, + StatisticalResult, + StatisticalSupport, + StatisticalWindow, + SupportEntryExclusion, + SupportExclusionReason, + SystematicTrialEffectAssessment, + SystematicTrialEffectStatus, + build_count_window_support, + build_method_comparison_design_authority, + build_method_comparison_design_evidence, + build_reference_window_support, + build_reliability_assumption_assessment, + build_reliability_assumption_source_evidence, + build_reliability_design_evidence, + build_statistical_support, + build_time_window_support, + calculate_absolute_change, + calculate_bland_altman_summary, + calculate_descriptive_ols, + calculate_descriptive_within_athlete_sd, + calculate_log_ratio_change, + calculate_log_scale_typical_error, + calculate_raw_relative_error_percent, + calculate_reference_window_deviation, + calculate_relative_change, + calculate_sem_from_icc, + calculate_two_replicate_random_error, + calculate_window_descriptives, + refuse_bland_altman_interpretation, + request_classical_bland_altman_limits, +) +from dynamislm.longitudinal.statistics import descriptive as _descriptive +from dynamislm.longitudinal.statistics import reliability as _reliability +from dynamislm.measurement.cmj.registry import METER +from dynamislm.measurement.identity import ( + InstanceIdentifier, + RegistryReference, + ScientificIdentifier, + SemanticIdentity, + UnitReference, +) +from dynamislm.measurement.result import ResultStatus +from dynamislm.provenance.models import EvidenceReference +from dynamislm.refusal.models import RefusalResult +from test_longitudinal import ( + ATHLETE, + OTHER_ATHLETE, + UTC, + _entry, + _instance, + _reference, + _session, +) + + +def _synthetic_unit(key: str, label: str) -> UnitReference: + return UnitReference(ScientificIdentifier("synthetic-res69", "unit", key, "1.0.0"), label) + + +CENTIMETER = _synthetic_unit("centimeter", "cm") + + +def _with_unit( + entry: LongitudinalObservationEntry, + unit: UnitReference = METER, + *, + identity_id: ScientificIdentifier | None = None, + semantic: SemanticIdentity | None = None, + status: ResultStatus | None = None, +) -> LongitudinalObservationEntry: + identity = entry.observation.identity + if identity_id is not None: + identity = replace(identity, identity_id=identity_id) + if semantic is not None: + identity = replace(identity, semantic=semantic) + result = replace(entry.observation.result, unit=unit) + if status is not None: + result = replace(result, status=status) + observation = replace(entry.observation, identity=identity, result=result) + return build_longitudinal_observation_entry( + observation, + entry.football_context, + entry.source_qualification_bindings, + ) + + +def _entries( + values: tuple[float, ...], + *, + prefix: str, + athlete: AthleteIdentity = ATHLETE, + unit: UnitReference = METER, + method: str = "same-method", + day: int = 1, +) -> tuple[LongitudinalObservationEntry, ...]: + session = _session( + f"{prefix}-session", + datetime_module.datetime(2026, 9, day, 9, tzinfo=UTC), + ) + result: list[LongitudinalObservationEntry] = [] + for index, value in enumerate(values): + source = _entry( + f"{prefix}-{index}", + athlete=athlete, + session=session, + observed_at=datetime_module.datetime(2026, 9, day, 10, index, tzinfo=UTC), + value=value, + device_key="same-device", + processing_method_key=method, + processing_key=f"{prefix}-processing-{index}", + ) + result.append(_with_unit(source, unit)) + return tuple(result) + + +def _record( + entries: tuple[LongitudinalObservationEntry, ...], +) -> LongitudinalAthletePerformanceRecord: + return build_longitudinal_record( + entries[0].football_context.athlete, + entries, + LongitudinalRecordOrigin.SYNTHETIC_DETERMINISTIC, + ) + + +def _input( + entries: tuple[LongitudinalObservationEntry, ...], +) -> MultiSourceAnalysisInput: + return build_multi_source_analysis_input( + entries[0].football_context.athlete, + entries, + MultiSourceAnalysisScope.SAME_SESSION, + ) + + +def _support( + entries: tuple[LongitudinalObservationEntry, ...], + *, + included: tuple[LongitudinalObservationEntry, ...] | None = None, + excluded: tuple[SupportEntryExclusion, ...] = (), + window: StatisticalWindow | None = None, + current_entry_id: InstanceIdentifier | None = None, + reference_entry_ids: tuple[InstanceIdentifier, ...] = (), +) -> StatisticalSupport: + record = _record(entries) + return build_statistical_support( + _input(entries), + entries if included is None else included, + source_records=(record,), + excluded_entries=excluded, + missingness_policy=MissingnessPolicy.NO_IMPUTATION_NO_ZERO_FILL, + window=window, + current_entry_id=current_entry_id, + reference_entry_ids=reference_entry_ids, + ) + + +def _synthetic_scale_registry(entry: LongitudinalObservationEntry) -> MeasurementScaleRegistry: + unit = entry.observation.result.unit + assert unit is not None + key = MeasurementScaleSemanticKeyV1.from_measurement_identity( + entry.observation.identity, + unit, + ) + semantics = MeasurementScaleSemantics( + semantic_key=key, + scale_kind=MeasurementScaleKind.RATIO, + meaningful_zero=True, + signed_value_policy=SignedValuePolicy.STRICTLY_POSITIVE, + denominator_policy=DenominatorPolicy.STRICTLY_POSITIVE, + relative_change_authorized=True, + log_ratio_authorized=True, + raw_relative_error_authorized=True, + log_error_authorized=True, + authority_reference=RES69_SCALE_SEMANTICS_AUTHORITY, + evidence_references=(EvidenceReference(_reference("evidence", "synthetic-scale")),), + authority_origin=ScaleAuthorityOrigin.SYNTHETIC_TEST, + rationale="Synthetic test-only scale authority; not production evidence.", + ) + return RES69_SCALE_REGISTRY.with_synthetic_entry(semantics) + + +def _is_refusal(value: object, code: RES69ReasonCode) -> bool: + return isinstance(value, RefusalResult) and code.value in value.reason_codes + + +def _build_public_assumption_source_evidence( + support: StatisticalSupport, + entries: tuple[LongitudinalObservationEntry, ...], + records: tuple[LongitudinalAthletePerformanceRecord, ...], + *, + declaration_reference: RegistryReference | None = None, + study_identity: RegistryReference | None = None, + protocol_reference: RegistryReference | None = None, + evidence_references: tuple[EvidenceReference, ...] | None = None, + registry: ReliabilityAssumptionDeclarationRegistry = ( + RES69_RELIABILITY_ASSUMPTION_DECLARATION_REGISTRY + ), +) -> ReliabilityAssumptionSourceEvidence: + protocol = protocol_reference or entries[0].observation.identity.semantic.protocol + assert protocol is not None + return build_reliability_assumption_source_evidence( + support=support, + source_records=records, + study_identity=study_identity or _reference("study", "reliability-study"), + protocol_reference=protocol, + declaration_reference=declaration_reference + or _reference("reliability-assumption-declaration", "unknown"), + evidence_references=evidence_references + or (EvidenceReference(_reference("evidence", "arbitrary")),), + producing_method=RES69_RELIABILITY_ASSUMPTION_ASSESSMENT_OPERATION, + registry=registry, + ) + + +def _reliability_fixture() -> tuple[ + StatisticalSupport, + ReliabilityDesignAuthority, + tuple[LongitudinalObservationEntry, ...], + tuple[LongitudinalAthletePerformanceRecord, ...], + tuple[ReliabilityReplicatePair, ...], +]: + first = _entries((10.0, 12.0), prefix="reliability-a", athlete=ATHLETE) + second = _entries((20.0, 23.0), prefix="reliability-b", athlete=OTHER_ATHLETE, day=2) + entries = (*first, *second) + records = (_record(first), _record(second)) + inputs = (_input(first), _input(second)) + support = build_statistical_support( + inputs, + entries, + source_records=records, + missingness_policy=MissingnessPolicy.NO_IMPUTATION_NO_ZERO_FILL, + ) + pairs = ( + ReliabilityReplicatePair.from_entries( + first[0], first[1], occasion_id=_instance("occasion", "a") + ), + ReliabilityReplicatePair.from_entries( + second[0], second[1], occasion_id=_instance("occasion", "b") + ), + ) + authority = _build_reliability_authority(support, entries, records, pairs) + return support, authority, entries, records, pairs + + +def _build_reliability_authority( + support: StatisticalSupport, + entries: tuple[LongitudinalObservationEntry, ...], + records: tuple[LongitudinalAthletePerformanceRecord, ...], + pairs: tuple[ReliabilityReplicatePair, ...], + *, + error_scale: ReliabilityErrorScale = ReliabilityErrorScale.RAW_ABSOLUTE, + balanced_design: bool = True, + replicate_ordering: RegistryReference | None = None, +) -> ReliabilityDesignAuthority: + first = entries[0] + protocol = first.observation.identity.semantic.protocol + assert protocol is not None + assumption_assessment = _reliability._build_synthetic_reliability_assumption_assessment( + support=support, + source_records=records, + study_identity=_reference("study", "reliability-study"), + protocol_reference=protocol, + evidence_references=(EvidenceReference(_reference("evidence", "reliability-assumptions")),), + stable_underlying_quantity=StableUnderlyingQuantityStatus.SUPPORTED, + systematic_trial_effect=SystematicTrialEffectAssessment( + SystematicTrialEffectStatus.SYSTEMATIC_EFFECT_PRESENT, + (EvidenceReference(_reference("evidence", "systematic-effect")),), + "The protocol assessed a systematic trial effect; the mean shift remains " + "separate from random error.", + ), + error_scale=error_scale, + producing_method=RES69_RELIABILITY_ASSUMPTION_ASSESSMENT_OPERATION, + ) + evidence = build_reliability_design_evidence( + support=support, + source_records=records, + study_identity=_reference("study", "reliability-study"), + replicate_pairs=pairs, + replicate_ordering=replicate_ordering or RES69_REPLICATE_ORDERING, + target_measurement_identity_ids=tuple( + entry.observation.identity.identity_id for entry in entries + ), + target_construct=first.observation.identity.semantic.construct, + target_test_family=first.observation.identity.semantic.test_family, + target_measurand=first.observation.identity.semantic.measurand, + target_metric_definition=first.observation.identity.semantic.metric_definition, + target_unit=METER, + protocol_reference=protocol, + evidence_references=(EvidenceReference(_reference("evidence", "reliability-design")),), + repeatability_question=ReliabilityQuestion.REPEATABILITY, + assumption_assessment=assumption_assessment, + missingness_policy=MissingnessPolicy.NO_IMPUTATION_NO_ZERO_FILL, + balanced_design=balanced_design, + producing_method=RES69_RELIABILITY_DESIGN_OPERATION, + ) + return _reliability._build_reliability_design_authority_from_evidence(evidence) + + +def test_statistical_support_canonicalizes_same_athlete_multi_input_order() -> None: + first = _entries((1.0, 2.0), prefix="same-athlete-input-a") + second = _entries((3.0, 4.0), prefix="same-athlete-input-b") + inputs = (_input(first), _input(second)) + records = (_record(first), _record(second)) + descending = tuple(sorted(inputs, key=lambda item: item.input_id.qualified, reverse=True)) + support_a = build_statistical_support( + descending, + (*first, *second), + source_records=records, + missingness_policy=MissingnessPolicy.NO_IMPUTATION_NO_ZERO_FILL, + ) + support_b = build_statistical_support( + tuple(reversed(descending)), + (*first, *second), + source_records=records, + missingness_policy=MissingnessPolicy.NO_IMPUTATION_NO_ZERO_FILL, + ) + assert support_a.analysis_inputs == tuple( + sorted( + inputs, + key=lambda item: (item.athlete.athlete_id.qualified, item.input_id.qualified), + ) + ) + assert support_a == support_b + assert support_a.canonical_support_hash == support_b.canonical_support_hash + + +def test_duplicate_exact_analysis_input_id_refuses() -> None: + entries = _entries((1.0, 2.0), prefix="duplicate-analysis-input") + analysis_input = _input(entries) + with pytest.raises(ValueError, match="duplicate exact input IDs"): + build_statistical_support( + (analysis_input, analysis_input), + entries, + source_records=(_record(entries),), + missingness_policy=MissingnessPolicy.NO_IMPUTATION_NO_ZERO_FILL, + ) + + +def test_distinct_same_athlete_analysis_inputs_remain_valid() -> None: + first = _entries((1.0, 2.0), prefix="distinct-analysis-input-a") + second = _entries((3.0, 4.0), prefix="distinct-analysis-input-b") + support = build_statistical_support( + (_input(first), _input(second)), + (*first, *second), + source_records=(_record(first), _record(second)), + missingness_policy=MissingnessPolicy.NO_IMPUTATION_NO_ZERO_FILL, + ) + assert len(support.analysis_inputs) == 2 + + +def test_absolute_relative_percent_and_log_ratio_gold_cases() -> None: + entries = _entries((10.0, 12.0), prefix="change") + support = _support(entries) + absolute = calculate_absolute_change(support) + assert isinstance(absolute, StatisticalResult) + assert absolute.estimate("change").value == 2.0 + assert absolute.estimate("change").unit == METER + + registry = _synthetic_scale_registry(entries[0]) + relative = calculate_relative_change(support, registry=registry) + assert _is_refusal(relative, RES69ReasonCode.SCALE_OPERATION_NOT_AUTHORIZED) + assert _descriptive._relative_change_value(10.0, 12.0) == pytest.approx(0.2) + log_ratio = calculate_log_ratio_change(support, registry=registry) + assert _is_refusal(log_ratio, RES69ReasonCode.SCALE_OPERATION_NOT_AUTHORIZED) + assert _descriptive._log_ratio_value(10.0, 12.0) == pytest.approx(math.log(1.2)) + + +def test_exact_unit_and_identity_rules_fail_closed() -> None: + entries = _entries((10.0, 12.0), prefix="unit-mismatch") + changed = _with_unit(entries[1], CENTIMETER) + result = calculate_absolute_change(_support((entries[0], changed))) + assert _is_refusal(result, RES69ReasonCode.UNIT_MISMATCH) + + missing_unit = build_longitudinal_observation_entry( + replace( + entries[1].observation, + result=replace(entries[1].observation.result, unit=None), + ), + entries[1].football_context, + entries[1].source_qualification_bindings, + ) + result = calculate_window_descriptives(_support((entries[0], missing_unit))) + assert _is_refusal(result, RES69ReasonCode.DATA_ADEQUACY_INSUFFICIENT) + + semantic = replace( + entries[1].observation.identity.semantic, + metric_definition=_reference("metric", "different-metric"), + ) + identity_changed = _with_unit(entries[1], semantic=semantic) + result = calculate_absolute_change(_support((entries[0], identity_changed))) + assert _is_refusal(result, RES69ReasonCode.IDENTITY_UNRESOLVED) + + invalid = _with_unit(entries[1], status=ResultStatus.INVALID) + result = calculate_absolute_change(_support((entries[0], invalid))) + assert isinstance(result, RefusalResult) + assert result.observation_ids + + reversed_result = calculate_absolute_change( + _support(entries), + baseline_entry=entries[1], + followup_entry=entries[0], + ) + assert isinstance(reversed_result, RefusalResult) + + +def test_scale_registry_requires_registered_semantics_and_ignores_caller_flags() -> None: + entries = _entries((10.0, 12.0), prefix="scale-gate") + support = _support(entries) + assert _is_refusal( + calculate_relative_change(support, ratio_scale=True), + RES69ReasonCode.SCALE_SEMANTICS_UNREGISTERED, + ) + semantics_registry = _synthetic_scale_registry(entries[0]) + result = calculate_relative_change(support, registry=semantics_registry, ratio_scale=True) + assert _is_refusal(result, RES69ReasonCode.SCALE_OPERATION_NOT_AUTHORIZED) + + interval = replace( + semantics_registry.entries[-1], + scale_kind=MeasurementScaleKind.INTERVAL, + meaningful_zero=False, + signed_value_policy=SignedValuePolicy.SIGNED, + denominator_policy=DenominatorPolicy.NONZERO, + relative_change_authorized=False, + log_ratio_authorized=False, + raw_relative_error_authorized=False, + log_error_authorized=False, + ) + interval_registry = MeasurementScaleRegistry( + tuple( + item + for item in semantics_registry.entries + if item.semantic_key != interval.semantic_key + ) + ).with_synthetic_entry(interval) + assert _is_refusal( + calculate_relative_change(support, registry=interval_registry), + RES69ReasonCode.SCALE_OPERATION_NOT_AUTHORIZED, + ) + + +def test_public_scale_operations_reject_synthetic_scale_authority() -> None: + entries = _entries((10.0, 12.0), prefix="synthetic-scale-public") + support = _support(entries) + registry = _synthetic_scale_registry(entries[0]) + assert _is_refusal( + calculate_relative_change(support, registry=registry), + RES69ReasonCode.SCALE_OPERATION_NOT_AUTHORIZED, + ) + assert _is_refusal( + calculate_log_ratio_change(support, registry=registry), + RES69ReasonCode.SCALE_OPERATION_NOT_AUTHORIZED, + ) + + reliability_support, authority, reliability_entries, records, pairs = _reliability_fixture() + raw_authority = _build_reliability_authority( + reliability_support, + reliability_entries, + records, + pairs, + error_scale=ReliabilityErrorScale.RAW_RELATIVE, + ) + log_authority = _build_reliability_authority( + reliability_support, + reliability_entries, + records, + pairs, + error_scale=ReliabilityErrorScale.LOG_MULTIPLICATIVE, + ) + assert _is_refusal( + calculate_raw_relative_error_percent( + reliability_support, + raw_authority, + registry=registry, + ), + RES69ReasonCode.RELIABILITY_AUTHORITY_REQUIRED, + ) + assert _is_refusal( + calculate_log_scale_typical_error( + reliability_support, + log_authority, + registry=registry, + ), + RES69ReasonCode.RELIABILITY_AUTHORITY_REQUIRED, + ) + assert authority.is_source_bound + + +def test_public_scale_operations_fail_closed_without_production_entry() -> None: + entries = _entries((10.0, 12.0), prefix="empty-production-scale") + support = _support(entries) + assert not RES69_SCALE_REGISTRY.entries + assert _is_refusal( + calculate_relative_change(support), + RES69ReasonCode.SCALE_SEMANTICS_UNREGISTERED, + ) + + +def test_scale_key_uses_semantics_not_instance_identity() -> None: + first, second = _entries((10.0, 12.0), prefix="scale-key") + first_unit = first.observation.result.unit + second_unit = second.observation.result.unit + assert first_unit is not None and second_unit is not None + first_key = MeasurementScaleSemanticKeyV1.from_measurement_identity( + first.observation.identity, + first_unit, + ) + second_key = MeasurementScaleSemanticKeyV1.from_measurement_identity( + second.observation.identity, + second_unit, + ) + assert first.observation.identity.identity_id != second.observation.identity.identity_id + assert first_key == second_key + assert first_key.stable_key == second_key.stable_key + + +def test_count_time_and_one_value_window_descriptives() -> None: + entries = _entries((1.0, 3.0, 5.0), prefix="window") + record = _record(entries) + analysis_input = _input(entries) + count_support = build_count_window_support(analysis_input, 2, source_records=(record,)) + result = calculate_window_descriptives(count_support) + assert isinstance(result, StatisticalResult) + assert result.estimate("mean").value == pytest.approx(4.0) + assert result.estimate("median").value == pytest.approx(4.0) + assert result.estimate("sample-sd").value == pytest.approx(math.sqrt(2.0)) + assert result.estimate("minimum").value == 3.0 + assert result.estimate("maximum").value == 5.0 + assert result.estimate("range").value == 2.0 + assert len(count_support.excluded_entries) == 1 + + time_support = build_time_window_support( + analysis_input, + entries[1].observed_at, + entries[2].observed_at, + start_inclusive=True, + end_inclusive=False, + source_records=(record,), + ) + time_result = calculate_window_descriptives(time_support) + assert isinstance(time_result, StatisticalResult) + assert time_result.estimate("mean").value == 3.0 + assert time_support.window.end_inclusive is False + + one_support = build_statistical_support( + analysis_input, + (entries[0],), + source_records=(record,), + excluded_entries=( + SupportEntryExclusion.from_entry(entries[1], SupportExclusionReason.NOT_SELECTED), + SupportEntryExclusion.from_entry(entries[2], SupportExclusionReason.NOT_SELECTED), + ), + ) + one_result = calculate_window_descriptives(one_support) + assert isinstance(one_result, StatisticalResult) + assert one_result.estimate("range").value == 0.0 + assert one_result.non_computable + + +def test_missing_window_entries_cannot_be_silently_dropped() -> None: + entries = _entries((1.0, 2.0, 3.0), prefix="missing-window") + with pytest.raises(ValueError, match="explicit"): + build_statistical_support( + _input(entries), + entries[:2], + source_records=(_record(entries),), + ) + + +def test_reference_window_deviation_excludes_current_and_rejects_zero_sd() -> None: + entries = _entries((1.0, 3.0, 5.0), prefix="reference") + support = build_reference_window_support( + _input(entries), entries[2], entries[:2], source_records=(_record(entries),) + ) + result = calculate_reference_window_deviation(support) + assert isinstance(result, StatisticalResult) + assert result.estimate("reference-window-z").value == pytest.approx(3.0 / math.sqrt(2.0)) + assert entries[2].canonical_entry_id not in support.reference_entry_ids + + zero = _entries((2.0, 2.0, 3.0), prefix="reference-zero") + result = calculate_reference_window_deviation( + build_reference_window_support( + _input(zero), zero[2], zero[:2], source_records=(_record(zero),) + ) + ) + assert _is_refusal(result, RES69ReasonCode.ZERO_REFERENCE_SD) + + +def test_ols_uses_exact_irregular_timestamps_and_rejects_duplicates() -> None: + entries = _entries((2.0, 8.0, 23.0), prefix="ols-irregular") + irregular = tuple( + build_longitudinal_observation_entry( + replace( + entry.observation, + context=replace( + entry.observation.context, + observed_at=datetime_module.datetime(2026, 9, 1, 10, tzinfo=UTC) + + datetime_module.timedelta(seconds=seconds), + ), + ), + entry.football_context, + entry.source_qualification_bindings, + ) + for entry, seconds in zip(entries, (0, 2, 7), strict=True) + ) + result = calculate_descriptive_ols(_support(irregular)) + assert isinstance(result, StatisticalResult) + assert result.estimate("ols-intercept").value == pytest.approx(2.0) + assert result.estimate("ols-slope").value == pytest.approx(3.0) + assert result.estimate("ols-intercept").unit == METER + from dynamislm.longitudinal.statistics import DerivedUnitReference + + slope_unit = result.estimate("ols-slope").unit + assert isinstance(slope_unit, DerivedUnitReference) + assert slope_unit.numerator == METER + + duplicate = _with_unit(entries[1]) + duplicate = build_longitudinal_observation_entry( + replace( + duplicate.observation, + context=replace(duplicate.observation.context, observed_at=entries[0].observed_at), + ), + duplicate.football_context, + duplicate.source_qualification_bindings, + ) + result = calculate_descriptive_ols(_support((entries[0], duplicate))) + assert _is_refusal(result, RES69ReasonCode.DUPLICATE_TIMESTAMP) + + +def test_descriptive_within_athlete_sd_is_distinct_from_reliability() -> None: + result = calculate_descriptive_within_athlete_sd( + _support(_entries((1.0, 3.0, 5.0), prefix="sd")) + ) + assert isinstance(result, StatisticalResult) + assert result.estimate("sample-sd").value == pytest.approx(2.0) + assert result.estimate("sample-sd").estimator.identifier.key == "within-athlete-sample-sd-v1" + + +def test_two_replicate_random_error_and_source_bound_gate() -> None: + support, authority, _entries_value, _records, _pairs = _reliability_fixture() + mean_shift, sd_difference, random_error_sd = _reliability._two_replicate_random_error_values( + (10.0, 20.0), + (12.0, 23.0), + ) + assert mean_shift == pytest.approx(2.5) + assert sd_difference == pytest.approx(math.sqrt(0.5)) + assert random_error_sd == pytest.approx(0.5) + assert _is_refusal( + calculate_two_replicate_random_error(support, authority), + RES69ReasonCode.RELIABILITY_AUTHORITY_REQUIRED, + ) + assert authority.is_source_bound + unverified = replace( + authority, authority_status=AuthorityStatus.UNVERIFIED, authority_token=None + ) + assert _is_refusal( + calculate_two_replicate_random_error(support, unverified), + RES69ReasonCode.RELIABILITY_AUTHORITY_REQUIRED, + ) + + +def test_reliability_assumption_source_bound_contract() -> None: + support, authority, _entries_value, _records, _pairs = _reliability_fixture() + assessment = authority.assumption_assessment + assert isinstance(assessment, ReliabilityAssumptionAssessment) + assert assessment.is_source_bound + assert assessment.support_id == support.canonical_support_id + assert assessment.support_hash == support.canonical_support_hash + assert assessment.source_records == support.source_records + assert assessment.source_artifacts == support.source_artifacts + assert assessment.source_provenance == support.source_provenance + + +def test_reliability_assumption_free_fields_cannot_mint_authority() -> None: + parameter_names = set(inspect.signature(build_reliability_design_evidence).parameters) + assert "stable_underlying_quantity" not in parameter_names + assert "systematic_trial_effect" not in parameter_names + assert "error_scale" not in parameter_names + source_parameter_names = set( + inspect.signature(build_reliability_assumption_source_evidence).parameters + ) + assert "stable_underlying_quantity" not in source_parameter_names + assert "systematic_trial_effect" not in source_parameter_names + assert "error_scale" not in source_parameter_names + assert "declaration_reference" in source_parameter_names + + +def test_free_stable_quantity_claim_cannot_mint_assessment() -> None: + support, _authority, entries, records, _pairs = _reliability_fixture() + with pytest.raises(TypeError, match="stable_underlying_quantity"): + _build_public_assumption_source_evidence( + support, + entries, + records, + stable_underlying_quantity=StableUnderlyingQuantityStatus.SUPPORTED, # type: ignore[call-arg] + ) + + +def test_free_error_scale_claim_cannot_mint_assessment() -> None: + support, _authority, entries, records, _pairs = _reliability_fixture() + with pytest.raises(TypeError, match="error_scale"): + _build_public_assumption_source_evidence( + support, + entries, + records, + error_scale=ReliabilityErrorScale.RAW_RELATIVE, # type: ignore[call-arg] + ) + + +def test_free_systematic_effect_claim_cannot_mint_assessment() -> None: + support, _authority, entries, records, _pairs = _reliability_fixture() + with pytest.raises(TypeError, match="systematic_trial_effect"): + _build_public_assumption_source_evidence( + support, + entries, + records, + systematic_trial_effect=SystematicTrialEffectStatus.SYSTEMATIC_EFFECT_PRESENT, # type: ignore[call-arg] + ) + + +def test_reliability_assumption_registry_is_empty_without_scientific_authority() -> None: + assert isinstance( + RES69_RELIABILITY_ASSUMPTION_DECLARATION_REGISTRY, + ReliabilityAssumptionDeclarationRegistry, + ) + assert RES69_RELIABILITY_ASSUMPTION_DECLARATION_REGISTRY.entries == () + assert PRODUCTION_RELIABILITY_ASSUMPTION_DECLARATIONS == 0 + + +def test_unknown_production_declaration_ref_fails_closed() -> None: + support, _authority, entries, records, _pairs = _reliability_fixture() + with pytest.raises(ValueError, match="not registered"): + _build_public_assumption_source_evidence(support, entries, records) + + +def test_fabricated_evidence_reference_does_not_authorize_assumptions() -> None: + support, _authority, entries, records, _pairs = _reliability_fixture() + with pytest.raises(ValueError, match="not registered"): + _build_public_assumption_source_evidence( + support, + entries, + records, + declaration_reference=_reference("reliability-assumption-declaration", "fabricated"), + evidence_references=(EvidenceReference(_reference("evidence", "fabricated")),), + ) + + +def test_caller_registry_cannot_mint_reliability_assumption_authority() -> None: + support, authority, entries, records, _pairs = _reliability_fixture() + assessment = authority.assumption_assessment + source = assessment.source_evidence + declaration = ReliabilityAssumptionDeclarationV1( + declaration_reference=source.declaration_reference, + study_identity=assessment.study_identity, + protocol_reference=assessment.protocol_reference, + stable_underlying_quantity=assessment.stable_underlying_quantity, + systematic_trial_effect=assessment.systematic_trial_effect, + error_scale=assessment.error_scale, + evidence_references=assessment.evidence_references, + producing_method=assessment.producing_method, + registry_version=assessment.registry_version, + authority_origin=ReliabilityAssumptionDeclarationOrigin.PRODUCTION, + ) + custom_registry = ReliabilityAssumptionDeclarationRegistry((declaration,)) + with pytest.raises(ValueError, match="canonical production"): + _build_public_assumption_source_evidence( + support, + entries, + records, + declaration_reference=declaration.declaration_reference, + study_identity=declaration.study_identity, + protocol_reference=declaration.protocol_reference, + evidence_references=declaration.evidence_references, + registry=custom_registry, + ) + + +def test_synthetic_declaration_cannot_authorize_public_reliability_result() -> None: + support, authority, _entries_value, _records, _pairs = _reliability_fixture() + assert _is_refusal( + calculate_two_replicate_random_error(support, authority), + RES69ReasonCode.RELIABILITY_AUTHORITY_REQUIRED, + ) + + +def test_declaration_protocol_mismatch_refuses() -> None: + support, authority, entries, records, _pairs = _reliability_fixture() + source = authority.assumption_assessment.source_evidence + with pytest.raises(ValueError, match="not registered"): + _build_public_assumption_source_evidence( + support, + entries, + records, + declaration_reference=source.declaration_reference, + protocol_reference=_reference("protocol", "mismatched"), + ) + + +def test_declaration_study_identity_mismatch_refuses() -> None: + support, authority, entries, records, _pairs = _reliability_fixture() + source = authority.assumption_assessment.source_evidence + with pytest.raises(ValueError, match="not registered"): + _build_public_assumption_source_evidence( + support, + entries, + records, + declaration_reference=source.declaration_reference, + study_identity=_reference("study", "mismatched"), + ) + + +def test_declaration_hash_tamper_refuses() -> None: + _support_value, authority, _entries_value, _records, _pairs = _reliability_fixture() + source = authority.assumption_assessment.source_evidence + with pytest.raises(ValueError): + build_reliability_assumption_assessment( + replace(source, declaration_hash="sha256:" + "0" * 64) + ) + + +def test_assessment_declaration_hash_tamper_refuses() -> None: + _support_value, authority, _entries_value, _records, _pairs = _reliability_fixture() + with pytest.raises(ValueError): + replace(authority.assumption_assessment, declaration_hash="sha256:" + "0" * 64) + + +def test_public_reliability_claim_without_production_declaration_refuses() -> None: + support, authority, _entries_value, _records, _pairs = _reliability_fixture() + assert PRODUCTION_RELIABILITY_ASSUMPTION_DECLARATIONS == 0 + assert _is_refusal( + calculate_two_replicate_random_error(support, authority), + RES69ReasonCode.RELIABILITY_AUTHORITY_REQUIRED, + ) + + +def test_reliability_assumption_wrong_support_refuses() -> None: + _support_value, authority, _entries_value, _records, _pairs = _reliability_fixture() + source = authority.assumption_assessment.source_evidence + wrong_entries = _entries((11.0, 13.0, 20.0, 23.0), prefix="wrong-assumption-support") + with pytest.raises(ValueError, match="not registered"): + build_reliability_assumption_assessment(replace(source, support=_support(wrong_entries))) + + +def test_reliability_assumption_wrong_protocol_refuses() -> None: + _support_value, authority, _entries_value, _records, _pairs = _reliability_fixture() + source = authority.assumption_assessment.source_evidence + with pytest.raises(ValueError, match="not registered"): + build_reliability_assumption_assessment( + replace(source, protocol_reference=_reference("protocol", "wrong-protocol")) + ) + + +def test_reliability_assumption_missing_protocol_refuses() -> None: + _, authority, entries, _records, _pairs = _reliability_fixture() + first = entries[:2] + second = entries[2:] + missing_protocol = _with_unit( + first[1], + semantic=replace(first[1].observation.identity.semantic, protocol=None), + ) + mixed_first = (first[0], missing_protocol) + mixed_entries = (*mixed_first, *second) + mixed_records = (_record(mixed_first), _record(second)) + mixed_support = build_statistical_support( + (_input(mixed_first), _input(second)), + mixed_entries, + source_records=mixed_records, + missingness_policy=MissingnessPolicy.NO_IMPUTATION_NO_ZERO_FILL, + ) + source = replace( + authority.assumption_assessment.source_evidence, + support=mixed_support, + source_records=mixed_records, + ) + with pytest.raises(ValueError, match="not registered"): + build_reliability_assumption_assessment(source) + + +def test_reliability_assumption_tamper_refuses() -> None: + _support_value, authority, _entries_value, _records, _pairs = _reliability_fixture() + with pytest.raises(ValueError): + replace( + authority.assumption_assessment, + source_evidence_hash="sha256:" + "0" * 64, + ) + + +def test_direct_reliability_assumption_stays_unverified() -> None: + support, authority, _entries_value, _records, _pairs = _reliability_fixture() + unverified_assessment = replace( + authority.assumption_assessment, + authority_status=AuthorityStatus.UNVERIFIED, + authority_token=None, + authority_hash=None, + ) + unverified_authority = replace( + authority, + assumption_assessment=unverified_assessment, + authority_status=AuthorityStatus.UNVERIFIED, + authority_token=None, + authority_hash=None, + ) + assert not unverified_assessment.is_source_bound + assert _is_refusal( + calculate_two_replicate_random_error(support, unverified_authority), + RES69ReasonCode.RELIABILITY_AUTHORITY_REQUIRED, + ) + + +def test_arbitrary_reliability_assumption_producing_method_refuses() -> None: + _support_value, authority, _entries_value, _records, _pairs = _reliability_fixture() + source = authority.assumption_assessment.source_evidence + with pytest.raises(ValueError, match="registered assessment operation"): + build_reliability_assumption_assessment( + replace(source, producing_method=_reference("registered-operation", "arbitrary")) + ) + + +def test_reliability_n_one_unbalanced_and_wrong_order_refuse() -> None: + entries = _entries((10.0, 12.0), prefix="reliability-one") + support = _support(entries) + pair = ReliabilityReplicatePair.from_entries(entries[0], entries[1]) + authority = _build_reliability_authority(support, entries, (_record(entries),), (pair,)) + assert _is_refusal( + calculate_two_replicate_random_error(support, authority), + RES69ReasonCode.RELIABILITY_AUTHORITY_REQUIRED, + ) + + unbalanced = _build_reliability_authority( + support, entries, (_record(entries),), (pair,), balanced_design=False + ) + assert _is_refusal( + calculate_two_replicate_random_error(support, unbalanced), + RES69ReasonCode.RELIABILITY_AUTHORITY_REQUIRED, + ) + + with pytest.raises(ValueError, match="at least two"): + _reliability._two_replicate_random_error_values((10.0,), (12.0,)) + + wrong_order = _reference("replicate-ordering", "wrong") + assert wrong_order != RES69_REPLICATE_ORDERING + + +def test_raw_relative_error_uses_only_derived_pooled_grand_mean() -> None: + support, _authority, entries, records, pairs = _reliability_fixture() + raw_authority = _build_reliability_authority( + support, entries, records, pairs, error_scale=ReliabilityErrorScale.RAW_RELATIVE + ) + result = calculate_raw_relative_error_percent( + support, + raw_authority, + registry=_synthetic_scale_registry(entries[0]), + ) + assert _is_refusal(result, RES69ReasonCode.RELIABILITY_AUTHORITY_REQUIRED) + raw_mean, random_error, relative_error = _reliability._raw_relative_error_values( + (10.0, 20.0), + (12.0, 23.0), + ) + assert raw_mean == pytest.approx(16.25) + assert random_error == pytest.approx(0.5) + assert relative_error == pytest.approx(100.0 * 0.5 / 16.25) + supplied = calculate_raw_relative_error_percent( + support, + raw_authority, + registry=_synthetic_scale_registry(entries[0]), + denominator=1.0, + ) + assert _is_refusal(supplied, RES69ReasonCode.SUPPORT_MISMATCH) + + log_authority = _build_reliability_authority( + support, entries, records, pairs, error_scale=ReliabilityErrorScale.LOG_MULTIPLICATIVE + ) + assert _is_refusal( + calculate_raw_relative_error_percent( + support, log_authority, registry=_synthetic_scale_registry(entries[0]) + ), + RES69ReasonCode.ANALYSIS_DESIGN_MISMATCH, + ) + + +def test_log_typical_error_has_factor_interval_not_symmetric_percent() -> None: + support, _authority, entries, records, pairs = _reliability_fixture() + log_authority = _build_reliability_authority( + support, entries, records, pairs, error_scale=ReliabilityErrorScale.LOG_MULTIPLICATIVE + ) + result = calculate_log_scale_typical_error( + support, + log_authority, + registry=_synthetic_scale_registry(entries[0]), + ) + assert _is_refusal(result, RES69ReasonCode.RELIABILITY_AUTHORITY_REQUIRED) + (_, _, te, factor, lower, upper, lower_percent, upper_percent) = ( + _reliability._log_scale_typical_error_values((10.0, 20.0), (12.0, 23.0)) + ) + assert factor == pytest.approx(math.exp(te)) + assert upper == pytest.approx(math.exp(te)) + assert lower == pytest.approx(1.0 / upper) + assert lower_percent == pytest.approx(100.0 * (1.0 - lower)) + assert upper_percent == pytest.approx(100.0 * (upper - 1.0)) + assert lower_percent != pytest.approx(upper_percent) + + negative = _entries((0.0, 1.0), prefix="log-negative") + support_negative = _support(negative) + negative_authority = _build_reliability_authority( + support_negative, + negative, + (_record(negative),), + (ReliabilityReplicatePair.from_entries(negative[0], negative[1]),), + error_scale=ReliabilityErrorScale.LOG_MULTIPLICATIVE, + ) + result = calculate_log_scale_typical_error( + support_negative, + negative_authority, + registry=_synthetic_scale_registry(negative[0]), + ) + assert isinstance(result, RefusalResult) + + +def test_sem_from_icc_remains_non_callable() -> None: + result = calculate_sem_from_icc() + assert _is_refusal(result, RES69ReasonCode.OPERATION_NOT_REGISTERED) + + +def _method_comparison_fixture() -> tuple[ + StatisticalSupport, + MethodComparisonDesignAuthority, + tuple[LongitudinalObservationEntry, ...], + tuple[LongitudinalAthletePerformanceRecord, ...], +]: + def pair_entries( + prefix: str, + athlete: AthleteIdentity, + day: int, + ) -> tuple[LongitudinalObservationEntry, ...]: + session = _session( + f"{prefix}-session", + datetime_module.datetime(2026, 9, day, 9, tzinfo=UTC), + ) + method_a = _with_unit( + _entry( + f"{prefix}-a", + athlete=athlete, + session=session, + observed_at=datetime_module.datetime(2026, 9, day, 10, tzinfo=UTC), + value=10.0 if athlete is ATHLETE else 20.0, + device_key="method-a-device", + processing_method_key="method-a", + processing_key=f"{prefix}-a-processing", + ), + identity_id=ScientificIdentifier( + "synthetic-res69", "measurement-identity", f"method-a:{prefix}", "1.0.0" + ), + ) + method_b = _with_unit( + _entry( + f"{prefix}-b", + athlete=athlete, + session=session, + observed_at=datetime_module.datetime(2026, 9, day, 10, 1, tzinfo=UTC), + value=12.0 if athlete is ATHLETE else 23.0, + device_key="method-b-device", + processing_method_key="method-b", + processing_key=f"{prefix}-b-processing", + ), + identity_id=ScientificIdentifier( + "synthetic-res69", "measurement-identity", f"method-b:{prefix}", "1.0.0" + ), + ) + return method_a, method_b + + first = pair_entries("ba-a", ATHLETE, 1) + second = pair_entries("ba-b", OTHER_ATHLETE, 2) + entries = (*first, *second) + records = (_record(first), _record(second)) + inputs = (_input(first), _input(second)) + support = build_statistical_support( + inputs, + entries, + source_records=records, + missingness_policy=MissingnessPolicy.NO_IMPUTATION_NO_ZERO_FILL, + ) + pairs = ( + MethodComparisonPair.from_entries( + first[0], first[1], occasion_id=_instance("occasion", "ba-a") + ), + MethodComparisonPair.from_entries( + second[0], second[1], occasion_id=_instance("occasion", "ba-b") + ), + ) + method_a_key = MethodComparisonMethodKeyV1.from_measurement_identity( + first[0].observation.identity, + METER, + ) + method_b_key = MethodComparisonMethodKeyV1.from_measurement_identity( + first[1].observation.identity, + METER, + ) + evidence = build_method_comparison_design_evidence( + support=support, + source_records=records, + design_identity=_reference("study", "method-comparison"), + pairs=pairs, + method_a_key=method_a_key, + method_b_key=method_b_key, + target_construct=first[0].observation.identity.semantic.construct, + target_measurand=first[0].observation.identity.semantic.measurand, + metric_a_definition=first[0].observation.identity.semantic.metric_definition, + metric_b_definition=first[1].observation.identity.semantic.metric_definition, + unit_a=METER, + unit_b=METER, + evidence_references=(EvidenceReference(_reference("evidence", "method-comparison")),), + producing_method=RES69_METHOD_COMPARISON_DESIGN_OPERATION, + ) + return support, build_method_comparison_design_authority(evidence), entries, records + + +def test_bland_altman_summary_is_narrow_and_allows_method_difference() -> None: + support, authority, _entries_value, _records = _method_comparison_fixture() + result = calculate_bland_altman_summary(support, authority) + assert isinstance(result, StatisticalResult) + assert result.estimate("ba-bias").value == pytest.approx(2.5) + assert result.estimate("ba-sd-difference").value == pytest.approx(math.sqrt(0.5)) + assert all("loa" not in estimate.estimand.identifier.key for estimate in result.estimates) + assert _is_refusal( + request_classical_bland_altman_limits(support, authority), + RES69ReasonCode.CLASSICAL_LOA_DEFERRED, + ) + assert _is_refusal( + refuse_bland_altman_interpretation(support), + RES69ReasonCode.INTERPRETATION_NOT_AUTHORIZED, + ) + + +def test_method_comparison_uses_stable_method_key_across_instance_ids() -> None: + support, authority, entries, _records = _method_comparison_fixture() + first_a, first_b, second_a, second_b = entries + assert ( + len( + { + first_a.observation.identity.identity_id, + first_b.observation.identity.identity_id, + second_a.observation.identity.identity_id, + second_b.observation.identity.identity_id, + } + ) + == 4 + ) + assert MethodComparisonMethodKeyV1.from_measurement_identity( + first_a.observation.identity, + METER, + ) == MethodComparisonMethodKeyV1.from_measurement_identity( + second_a.observation.identity, + METER, + ) + assert MethodComparisonMethodKeyV1.from_measurement_identity( + first_b.observation.identity, + METER, + ) == MethodComparisonMethodKeyV1.from_measurement_identity( + second_b.observation.identity, + METER, + ) + assert authority.method_a_key != authority.method_b_key + assert isinstance(calculate_bland_altman_summary(support, authority), StatisticalResult) + + +def test_method_comparison_material_method_change_refuses() -> None: + support, authority, _entries_value, records = _method_comparison_fixture() + changed_key = replace( + authority.method_a_key, + device_identity=_reference("device", "materially-different-device"), + ) + evidence = build_method_comparison_design_evidence( + support=support, + source_records=records, + design_identity=authority.design_identity, + pairs=authority.pairs, + method_a_key=changed_key, + method_b_key=authority.method_b_key, + target_construct=authority.target_construct, + target_measurand=authority.target_measurand, + metric_a_definition=authority.metric_a_definition, + metric_b_definition=authority.metric_b_definition, + unit_a=authority.unit_a, + unit_b=authority.unit_b, + evidence_references=authority.evidence_references, + producing_method=RES69_METHOD_COMPARISON_DESIGN_OPERATION, + ) + with pytest.raises(ValueError): + build_method_comparison_design_authority(evidence) + + +def test_method_comparison_arbitrary_producing_method_refuses() -> None: + support, authority, _entries_value, records = _method_comparison_fixture() + evidence = build_method_comparison_design_evidence( + support=support, + source_records=records, + design_identity=authority.design_identity, + pairs=authority.pairs, + method_a_key=authority.method_a_key, + method_b_key=authority.method_b_key, + target_construct=authority.target_construct, + target_measurand=authority.target_measurand, + metric_a_definition=authority.metric_a_definition, + metric_b_definition=authority.metric_b_definition, + unit_a=authority.unit_a, + unit_b=authority.unit_b, + evidence_references=authority.evidence_references, + producing_method=_reference("registered-operation", "arbitrary-method-comparison"), + ) + with pytest.raises(ValueError): + build_method_comparison_design_authority(evidence) + + +def test_direct_method_comparison_authority_stays_unverified() -> None: + support, authority, _entries_value, _records = _method_comparison_fixture() + unverified = replace( + authority, + authority_status=AuthorityStatus.UNVERIFIED, + authority_token=None, + ) + assert not unverified.is_source_bound + assert _is_refusal( + calculate_bland_altman_summary(support, unverified), + RES69ReasonCode.METHOD_COMPARISON_AUTHORITY_REQUIRED, + ) + + +def test_method_comparison_direct_mint_and_unit_mismatch_are_blocked() -> None: + support, authority, _entries_value, records = _method_comparison_fixture() + unverified = replace( + authority, authority_status=AuthorityStatus.UNVERIFIED, authority_token=None + ) + assert _is_refusal( + calculate_bland_altman_summary(support, unverified), + RES69ReasonCode.METHOD_COMPARISON_AUTHORITY_REQUIRED, + ) + bad_evidence = build_method_comparison_design_evidence( + support=support, + source_records=records, + design_identity=_reference("study", "ba-bad-unit"), + pairs=authority.pairs, + method_a_key=authority.method_a_key, + method_b_key=authority.method_b_key, + target_construct=authority.target_construct, + target_measurand=authority.target_measurand, + metric_a_definition=authority.metric_a_definition, + metric_b_definition=authority.metric_b_definition, + unit_a=METER, + unit_b=CENTIMETER, + evidence_references=authority.evidence_references, + producing_method=RES69_METHOD_COMPARISON_DESIGN_OPERATION, + ) + with pytest.raises(ValueError, match="exact common units"): + build_method_comparison_design_authority(bad_evidence) + + +def test_v3_round_trip_and_tamper_rejection() -> None: + support, authority, _entries_value, _records, _pairs = _reliability_fixture() + result = calculate_two_replicate_random_error(support, authority) + assert isinstance(result, RefusalResult) + for value in (support, authority): + serialized = canonical_json(value) + restored = from_canonical_json(serialized, type(value)) + assert restored == value + assert canonical_json(restored) == serialized + assert canonical_hash(restored) == canonical_hash(value) + + wire = json.loads(canonical_json(support)) + wire["payload"]["support_hash"] = "sha256:" + "0" * 64 + with pytest.raises(ValueError): + from_canonical_json(json.dumps(wire), StatisticalSupport) + + authority_wire = json.loads(canonical_json(authority)) + authority_wire["payload"]["source_evidence_hash"] = "sha256:" + "1" * 64 + with pytest.raises(ValueError): + from_canonical_json(json.dumps(authority_wire), ReliabilityDesignAuthority)