Release portable deterministic core 1.1.0: frozen formats, offline recovery and regression checks
Browse files- .gitattributes +8 -6
- .gitignore +20 -15
- ARCHIVE_README.md +38 -0
- ARCHIVE_SHA256.txt +1 -0
- ARCHIVE_SHA512.txt +1 -0
- CHANGELOG.md +37 -0
- JSON_CONTRACT.md +166 -149
- MAINTENANCE.md +86 -0
- MATRIX_FORMAT.md +114 -0
- README.md +137 -126
- VALIDATION.json +38 -0
- bodul-offline-1.1.0.zip +3 -0
- bodulskihal_core/__init__.py +119 -119
- bodulskihal_core/adapter.py +263 -263
- bodulskihal_core/conversation_gate.py +149 -149
- bodulskihal_core/engine.py +688 -688
- bodulskihal_core/evaluation.py +191 -191
- bodulskihal_core/guard_loop.py +254 -254
- bodulskihal_core/ingestion.py +556 -556
- bodulskihal_core/lifecycle.py +279 -279
- bodulskihal_core/raw_pipeline.py +463 -463
- bodulskihal_core/research_demo.py +146 -146
- bodulskihal_core/runtime_provenance.py +47 -47
- build_archive.py +41 -0
- demo.py +62 -22
- examples/contradiction.json +22 -0
- examples/matrix-result.json +11 -0
- examples/matrix.txt +1 -0
- examples/supported.json +22 -0
- examples/unsupported.json +14 -0
- matrix_core.py +199 -114
- offline.html +84 -0
- offline_cli.py +59 -0
- requirements.txt +2 -1
- run_example.py +3 -3
- safe_json.py +86 -0
- schemas/matrix-result-v1.json +105 -0
- test_vectors.json +110 -0
- tests/test_longevity.py +265 -0
- verify_release.py +66 -0
.gitattributes
CHANGED
|
@@ -1,6 +1,8 @@
|
|
| 1 |
-
* text=auto eol=lf
|
| 2 |
-
*.
|
| 3 |
-
*.
|
| 4 |
-
*.
|
| 5 |
-
*.
|
| 6 |
-
*.
|
|
|
|
|
|
|
|
|
| 1 |
+
* text=auto eol=lf
|
| 2 |
+
*.zip -text -diff
|
| 3 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.gguf filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
bodul-offline-1.1.0.zip filter=lfs diff=lfs merge=lfs -text
|
.gitignore
CHANGED
|
@@ -1,15 +1,20 @@
|
|
| 1 |
-
__pycache__/
|
| 2 |
-
*.py[cod]
|
| 3 |
-
.env
|
| 4 |
-
.env.*
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
.
|
| 9 |
-
|
| 10 |
-
.
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
*.
|
| 15 |
-
*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.py[cod]
|
| 3 |
+
.env
|
| 4 |
+
.env.*
|
| 5 |
+
.venv/
|
| 6 |
+
venv/
|
| 7 |
+
.cache/
|
| 8 |
+
.pytest_cache/
|
| 9 |
+
*.log
|
| 10 |
+
*.tmp
|
| 11 |
+
*.bak
|
| 12 |
+
*.swp
|
| 13 |
+
*.pem
|
| 14 |
+
*.key
|
| 15 |
+
credentials*
|
| 16 |
+
secrets*
|
| 17 |
+
tokens/
|
| 18 |
+
build/
|
| 19 |
+
dist/
|
| 20 |
+
*.egg-info/
|
ARCHIVE_README.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Bodulski HAL: portable execution archive 1.1.0
|
| 2 |
+
|
| 3 |
+
Author: Damir Balenović (mirdax25-ux). License: Apache-2.0.
|
| 4 |
+
|
| 5 |
+
This archive contains Matrix Core 3.6.0-OMEGA and the supplied-evidence verifier
|
| 6 |
+
2.1.2. Both use Python's standard library. No Hugging Face account, inference
|
| 7 |
+
endpoint, model weights, Docker, pip installation or network is needed to run
|
| 8 |
+
the Python examples. An installed Python runtime is required.
|
| 9 |
+
|
| 10 |
+
```bash
|
| 11 |
+
python -B verify_release.py
|
| 12 |
+
python -B -m unittest discover -s tests -q
|
| 13 |
+
python -B offline_cli.py matrix examples/matrix.txt --at "2026-11-03T12:00:00Z"
|
| 14 |
+
python -B offline_cli.py verify-matrix examples/matrix-result.json
|
| 15 |
+
python -B offline_cli.py claims examples/supported.json
|
| 16 |
+
```
|
| 17 |
+
|
| 18 |
+
Open `offline.html` directly for the single-file Matrix browser tool. It has no
|
| 19 |
+
external assets or network requests. The full evidence verifier is available
|
| 20 |
+
through `offline_cli.py claims`; it evaluates supplied structured evidence,
|
| 21 |
+
not current external reality. Its CLI returns 0 for APPROVE, 3 for BLOCK or
|
| 22 |
+
ESCALATE, and 2 for file/JSON input errors. Matrix returns 0 for successful
|
| 23 |
+
checksum/verification and 2 for rejected input.
|
| 24 |
+
|
| 25 |
+
Use `-` instead of a file name to read UTF-8 standard input. For durable Windows
|
| 26 |
+
archives, use the CLI's file inputs and avoid shell-dependent text encodings.
|
| 27 |
+
`examples/matrix-result.json` is synthetic public data, suitable for replay.
|
| 28 |
+
Do not put personal evidence or secrets in this public archive.
|
| 29 |
+
|
| 30 |
+
See `MATRIX_FORMAT.md` for exact bytes and frozen Unicode rules,
|
| 31 |
+
`JSON_CONTRACT.md` for the verifier interface, `MAINTENANCE.md` for backup,
|
| 32 |
+
restoration and migration procedures, and `CHANGELOG.md` for compatibility notes.
|
| 33 |
+
`RELEASE_MANIFEST.json` lists SHA-256/SHA-512 digests of the source contents.
|
| 34 |
+
Digests establish byte consistency, not authorship or real-world truth.
|
| 35 |
+
|
| 36 |
+
The year-2126 and leap-year fixtures test date arithmetic; they are not a
|
| 37 |
+
century-long uptime or security test. Preserve this archive and its trusted
|
| 38 |
+
digests in independent storage and periodically restore it on a clean machine.
|
ARCHIVE_SHA256.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
b1e2f3a1a8ce43278cb0382df1e493f1cbbe7db0554846647d41af6b4ecf6e1a bodul-offline-1.1.0.zip
|
ARCHIVE_SHA512.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
090fac7bb696199896114695e8f684f6546791dfb1d5386a3c5b542b945ffa9d907f01051c93b58eb15fb3caa495cb6f9fb231daad3cc462905ae9b6d21e31dd bodul-offline-1.1.0.zip
|
CHANGELOG.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Release notes
|
| 2 |
+
|
| 3 |
+
## Portable source release 1.1.0 — 2026-11-03
|
| 4 |
+
|
| 5 |
+
- Matrix Core 3.6.0-OMEGA freezes the 29-character whitespace table and v1
|
| 6 |
+
checksum byte contract; existing valid 3.5.1 checksum values are preserved.
|
| 7 |
+
- Results add `checksum_format`, `normalization_profile` and `hash_algorithm`.
|
| 8 |
+
Complete-object clients must account for these additional metadata fields.
|
| 9 |
+
- Invalid Unicode and raw inputs exceeding 1 MiB receive explicit errors and
|
| 10 |
+
no checksum. Timestamp strings use the documented narrow ASCII grammar.
|
| 11 |
+
- Saved-result verification supports explicit v1 records and original
|
| 12 |
+
unlabeled 3.5.1-OMEGA records, rejecting unknown/partial formats.
|
| 13 |
+
- Public JSON entry points reject duplicate keys, non-finite numbers,
|
| 14 |
+
excessive nesting, oversized inputs and lone surrogates. Python callers use
|
| 15 |
+
the same bounded serialization boundary. Human-approval flags remain ignored
|
| 16 |
+
in the public demo. The supplied-evidence algorithm remains 2.1.2.
|
| 17 |
+
- Offline CLI, independent vectors, single-file HTML checksum tool, source ZIP,
|
| 18 |
+
dual archive digests, source manifest and restoration instructions are added.
|
| 19 |
+
- Regression fixtures include 2126, non-leap centuries and the leap year 2400.
|
| 20 |
+
|
| 21 |
+
## Matrix 3.5.1-OMEGA — 2026-11-03
|
| 22 |
+
|
| 23 |
+
UTC date selection, aware-time requirements, invalid-input behavior, JSON CLI,
|
| 24 |
+
16 unit tests, model cards and a Gradio-Lite Matrix panel were published.
|
| 25 |
+
|
| 26 |
+
## Failure records addressed by 1.1.0
|
| 27 |
+
|
| 28 |
+
| Failure mode | Cause | Guardrail and regression |
|
| 29 |
+
| --- | --- | --- |
|
| 30 |
+
| Future normalization drift | Runtime Unicode properties could change | Frozen whitespace table; all 29 code points plus preserved non-whitespace fixtures. |
|
| 31 |
+
| Lone surrogate raises an encoding exception | Invalid scalar input reached UTF-8 hashing | Explicit rejection without a seal; Python/JSON/browser fixtures. |
|
| 32 |
+
| Ambiguous JSON input | Default parsers accept duplicate keys and non-finite numbers | Bounded strict JSON parser and malformed-input tests. |
|
| 33 |
+
| Lost hosting/CDN | Presentation depended on external delivery | Self-contained HTML and standard-library archive; actual offline restoration checks. |
|
| 34 |
+
| Unsupported future format interpreted silently | Records lacked explicit format metadata | Labeled formats, legacy-only reader, unknown-format rejection tests. |
|
| 35 |
+
|
| 36 |
+
Passing these regressions covers their recorded fixtures. It does not certify
|
| 37 |
+
the absence of every defect or future runtime incompatibility.
|
JSON_CONTRACT.md
CHANGED
|
@@ -1,149 +1,166 @@
|
|
| 1 |
-
# BodulskiHal JSON Contract — schema v3
|
| 2 |
-
|
| 3 |
-
Schema v3 keeps the deterministic JSON adapter boundary and adds epistemic metadata needed by the real conversation benchmark.
|
| 4 |
-
|
| 5 |
-
## Single-claim request
|
| 6 |
-
|
| 7 |
-
```json
|
| 8 |
-
{
|
| 9 |
-
"claim": {
|
| 10 |
-
"id": "count-1",
|
| 11 |
-
"text": "Conversation has 30 questions",
|
| 12 |
-
"type": "observation",
|
| 13 |
-
"risk": "low",
|
| 14 |
-
"consequential": true,
|
| 15 |
-
"authority_scope": "external_fact",
|
| 16 |
-
"answerability": "answerable"
|
| 17 |
-
},
|
| 18 |
-
"evidence": [
|
| 19 |
-
{
|
| 20 |
-
"source_id": "count-tool",
|
| 21 |
-
"evidence_id": "count-tool-1",
|
| 22 |
-
"supports": true,
|
| 23 |
-
"observed_at": "2026-10-03T21:30:00+00:00",
|
| 24 |
-
"directness": "direct",
|
| 25 |
-
"post_action": false,
|
| 26 |
-
"eligible": true,
|
| 27 |
-
"source_role": "external",
|
| 28 |
-
"source_ref": "tool://conversation-counter",
|
| 29 |
-
"independence_group": "conversation-counter",
|
| 30 |
-
"origin": "tool_observation",
|
| 31 |
-
"authority_scope": "external_fact"
|
| 32 |
-
}
|
| 33 |
-
],
|
| 34 |
-
"policy": {
|
| 35 |
-
"version": "3.0",
|
| 36 |
-
"max_age_seconds": 300,
|
| 37 |
-
"reject_future_evidence": true,
|
| 38 |
-
"require_independent_action_evidence": true,
|
| 39 |
-
"medium_risk_min_independent_sources": 2,
|
| 40 |
-
"require_runtime_provenance": false
|
| 41 |
-
},
|
| 42 |
-
"evaluation_time": "2026-10-03T21:30:00+00:00",
|
| 43 |
-
"human_approved": false
|
| 44 |
-
}
|
| 45 |
-
```
|
| 46 |
-
|
| 47 |
-
New claim fields are optional in the Python adapter and default fail-safely to:
|
| 48 |
-
- `authority_scope = external_fact`
|
| 49 |
-
- `answerability = answerable`
|
| 50 |
-
|
| 51 |
-
New evidence fields are optional and default to:
|
| 52 |
-
- `origin = external_source`
|
| 53 |
-
- `authority_scope = external_fact`
|
| 54 |
-
- `independence_group = null`
|
| 55 |
-
|
| 56 |
-
For source-independence quorum, eligible direct supporting evidence is grouped by
|
| 57 |
-
shared `source_id`, `source_ref`, or `independence_group`, including transitive
|
| 58 |
-
overlap. Values are trimmed and compared within their own field namespace.
|
| 59 |
-
Optional metadata can merge identities; it cannot split a known source into
|
| 60 |
-
independent votes. Different group labels on the same provenance reference still
|
| 61 |
-
count as one group. No URL canonicalization or external source authentication is
|
| 62 |
-
implied by this deterministic comparison.
|
| 63 |
-
|
| 64 |
-
The ingestion evidence boundary accepts the same optional `independence_group`
|
| 65 |
-
field. The Python ingestion API requires a real `TypeAuthority` enum member;
|
| 66 |
-
strings and other runtime values are rejected before claim typing. Lifecycle
|
| 67 |
-
checkpoint/approval flags likewise require actual booleans.
|
| 68 |
-
|
| 69 |
-
## Classification
|
| 70 |
-
|
| 71 |
-
Schema v3 supports:
|
| 72 |
-
- FACT
|
| 73 |
-
- INFERENCE
|
| 74 |
-
- ESTIMATE
|
| 75 |
-
- UNKNOWN
|
| 76 |
-
- REFUTED
|
| 77 |
-
- CONFLICT
|
| 78 |
-
|
| 79 |
-
Pure eligible counterevidence is REFUTED. Mixed supporting and contradicting evidence is CONFLICT.
|
| 80 |
-
|
| 81 |
-
## Evidence origin
|
| 82 |
-
|
| 83 |
-
Allowed values:
|
| 84 |
-
- external_source
|
| 85 |
-
- tool_observation
|
| 86 |
-
- computed_result
|
| 87 |
-
- memory
|
| 88 |
-
- user_assertion
|
| 89 |
-
- user_self_report
|
| 90 |
-
- user_preference
|
| 91 |
-
- user_instruction
|
| 92 |
-
|
| 93 |
-
## Authority scope
|
| 94 |
-
|
| 95 |
-
Allowed values:
|
| 96 |
-
- external_fact
|
| 97 |
-
- system_state
|
| 98 |
-
- user_self_report
|
| 99 |
-
- user_preference
|
| 100 |
-
- user_instruction
|
| 101 |
-
|
| 102 |
-
A user assertion is not sufficient evidence for an external fact merely because it was supplied by the caller.
|
| 103 |
-
|
| 104 |
-
## Answerability
|
| 105 |
-
|
| 106 |
-
Allowed values:
|
| 107 |
-
- answerable
|
| 108 |
-
- partially_answerable
|
| 109 |
-
- underspecified
|
| 110 |
-
- false_premise
|
| 111 |
-
- contradictory_input
|
| 112 |
-
- needs_clarification
|
| 113 |
-
- out_of_scope
|
| 114 |
-
|
| 115 |
-
Non-answerable claims fail closed before ordinary fact approval. `needs_clarification` escalates.
|
| 116 |
-
|
| 117 |
-
## Runtime provenance
|
| 118 |
-
|
| 119 |
-
Every receipt contains runtime provenance fields. A policy may set:
|
| 120 |
-
|
| 121 |
-
`require_runtime_provenance = true`
|
| 122 |
-
|
| 123 |
-
Then the verifier requires:
|
| 124 |
-
- `BODULSKIHAL_SOURCE_COMMIT`
|
| 125 |
-
- `BODULSKIHAL_SOURCE_REF`
|
| 126 |
-
- `BODULSKIHAL_BUILD_ID`
|
| 127 |
-
|
| 128 |
-
to identify a verified build.
|
| 129 |
-
|
| 130 |
-
## Compound request
|
| 131 |
-
|
| 132 |
-
Use `claims`, `evidence_by_claim`, optional `human_approvals`, one shared policy and one evaluation time.
|
| 133 |
-
|
| 134 |
-
Unknown claim ids in evidence or approval maps are rejected.
|
| 135 |
-
|
| 136 |
-
## Fail-closed boundary rules
|
| 137 |
-
|
| 138 |
-
- Required strings must be non-empty.
|
| 139 |
-
- Boolean fields must be actual booleans.
|
| 140 |
-
- Datetimes must be ISO 8601 and timezone-aware.
|
| 141 |
-
- Evidence/claim identities must be unique.
|
| 142 |
-
- Provenance enum values must be known.
|
| 143 |
-
- Content SHA-256, when supplied, must be a valid 64-character hexadecimal digest.
|
| 144 |
-
- Memory and user-origin evidence are subject to authority policy; origin does not imply truth.
|
| 145 |
-
- The adapter does not fetch URLs, authenticate arbitrary external sources or perform external actions.
|
| 146 |
-
|
| 147 |
-
## Conversation contract
|
| 148 |
-
|
| 149 |
-
Conversation goal/referent/constraint verification is exposed through `conversation_gate.py` as a deterministic internal gate. Semantic extraction of those fields is upstream work for BodulLogic/PRECICA or another preparation layer.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# BodulskiHal JSON Contract — schema v3
|
| 2 |
+
|
| 3 |
+
Schema v3 keeps the deterministic JSON adapter boundary and adds epistemic metadata needed by the real conversation benchmark.
|
| 4 |
+
|
| 5 |
+
## Single-claim request
|
| 6 |
+
|
| 7 |
+
```json
|
| 8 |
+
{
|
| 9 |
+
"claim": {
|
| 10 |
+
"id": "count-1",
|
| 11 |
+
"text": "Conversation has 30 questions",
|
| 12 |
+
"type": "observation",
|
| 13 |
+
"risk": "low",
|
| 14 |
+
"consequential": true,
|
| 15 |
+
"authority_scope": "external_fact",
|
| 16 |
+
"answerability": "answerable"
|
| 17 |
+
},
|
| 18 |
+
"evidence": [
|
| 19 |
+
{
|
| 20 |
+
"source_id": "count-tool",
|
| 21 |
+
"evidence_id": "count-tool-1",
|
| 22 |
+
"supports": true,
|
| 23 |
+
"observed_at": "2026-10-03T21:30:00+00:00",
|
| 24 |
+
"directness": "direct",
|
| 25 |
+
"post_action": false,
|
| 26 |
+
"eligible": true,
|
| 27 |
+
"source_role": "external",
|
| 28 |
+
"source_ref": "tool://conversation-counter",
|
| 29 |
+
"independence_group": "conversation-counter",
|
| 30 |
+
"origin": "tool_observation",
|
| 31 |
+
"authority_scope": "external_fact"
|
| 32 |
+
}
|
| 33 |
+
],
|
| 34 |
+
"policy": {
|
| 35 |
+
"version": "3.0",
|
| 36 |
+
"max_age_seconds": 300,
|
| 37 |
+
"reject_future_evidence": true,
|
| 38 |
+
"require_independent_action_evidence": true,
|
| 39 |
+
"medium_risk_min_independent_sources": 2,
|
| 40 |
+
"require_runtime_provenance": false
|
| 41 |
+
},
|
| 42 |
+
"evaluation_time": "2026-10-03T21:30:00+00:00",
|
| 43 |
+
"human_approved": false
|
| 44 |
+
}
|
| 45 |
+
```
|
| 46 |
+
|
| 47 |
+
New claim fields are optional in the Python adapter and default fail-safely to:
|
| 48 |
+
- `authority_scope = external_fact`
|
| 49 |
+
- `answerability = answerable`
|
| 50 |
+
|
| 51 |
+
New evidence fields are optional and default to:
|
| 52 |
+
- `origin = external_source`
|
| 53 |
+
- `authority_scope = external_fact`
|
| 54 |
+
- `independence_group = null`
|
| 55 |
+
|
| 56 |
+
For source-independence quorum, eligible direct supporting evidence is grouped by
|
| 57 |
+
shared `source_id`, `source_ref`, or `independence_group`, including transitive
|
| 58 |
+
overlap. Values are trimmed and compared within their own field namespace.
|
| 59 |
+
Optional metadata can merge identities; it cannot split a known source into
|
| 60 |
+
independent votes. Different group labels on the same provenance reference still
|
| 61 |
+
count as one group. No URL canonicalization or external source authentication is
|
| 62 |
+
implied by this deterministic comparison.
|
| 63 |
+
|
| 64 |
+
The ingestion evidence boundary accepts the same optional `independence_group`
|
| 65 |
+
field. The Python ingestion API requires a real `TypeAuthority` enum member;
|
| 66 |
+
strings and other runtime values are rejected before claim typing. Lifecycle
|
| 67 |
+
checkpoint/approval flags likewise require actual booleans.
|
| 68 |
+
|
| 69 |
+
## Classification
|
| 70 |
+
|
| 71 |
+
Schema v3 supports:
|
| 72 |
+
- FACT
|
| 73 |
+
- INFERENCE
|
| 74 |
+
- ESTIMATE
|
| 75 |
+
- UNKNOWN
|
| 76 |
+
- REFUTED
|
| 77 |
+
- CONFLICT
|
| 78 |
+
|
| 79 |
+
Pure eligible counterevidence is REFUTED. Mixed supporting and contradicting evidence is CONFLICT.
|
| 80 |
+
|
| 81 |
+
## Evidence origin
|
| 82 |
+
|
| 83 |
+
Allowed values:
|
| 84 |
+
- external_source
|
| 85 |
+
- tool_observation
|
| 86 |
+
- computed_result
|
| 87 |
+
- memory
|
| 88 |
+
- user_assertion
|
| 89 |
+
- user_self_report
|
| 90 |
+
- user_preference
|
| 91 |
+
- user_instruction
|
| 92 |
+
|
| 93 |
+
## Authority scope
|
| 94 |
+
|
| 95 |
+
Allowed values:
|
| 96 |
+
- external_fact
|
| 97 |
+
- system_state
|
| 98 |
+
- user_self_report
|
| 99 |
+
- user_preference
|
| 100 |
+
- user_instruction
|
| 101 |
+
|
| 102 |
+
A user assertion is not sufficient evidence for an external fact merely because it was supplied by the caller.
|
| 103 |
+
|
| 104 |
+
## Answerability
|
| 105 |
+
|
| 106 |
+
Allowed values:
|
| 107 |
+
- answerable
|
| 108 |
+
- partially_answerable
|
| 109 |
+
- underspecified
|
| 110 |
+
- false_premise
|
| 111 |
+
- contradictory_input
|
| 112 |
+
- needs_clarification
|
| 113 |
+
- out_of_scope
|
| 114 |
+
|
| 115 |
+
Non-answerable claims fail closed before ordinary fact approval. `needs_clarification` escalates.
|
| 116 |
+
|
| 117 |
+
## Runtime provenance
|
| 118 |
+
|
| 119 |
+
Every receipt contains runtime provenance fields. A policy may set:
|
| 120 |
+
|
| 121 |
+
`require_runtime_provenance = true`
|
| 122 |
+
|
| 123 |
+
Then the verifier requires:
|
| 124 |
+
- `BODULSKIHAL_SOURCE_COMMIT`
|
| 125 |
+
- `BODULSKIHAL_SOURCE_REF`
|
| 126 |
+
- `BODULSKIHAL_BUILD_ID`
|
| 127 |
+
|
| 128 |
+
to identify a verified build.
|
| 129 |
+
|
| 130 |
+
## Compound request
|
| 131 |
+
|
| 132 |
+
Use `claims`, `evidence_by_claim`, optional `human_approvals`, one shared policy and one evaluation time.
|
| 133 |
+
|
| 134 |
+
Unknown claim ids in evidence or approval maps are rejected.
|
| 135 |
+
|
| 136 |
+
## Fail-closed boundary rules
|
| 137 |
+
|
| 138 |
+
- Required strings must be non-empty.
|
| 139 |
+
- Boolean fields must be actual booleans.
|
| 140 |
+
- Datetimes must be ISO 8601 and timezone-aware.
|
| 141 |
+
- Evidence/claim identities must be unique.
|
| 142 |
+
- Provenance enum values must be known.
|
| 143 |
+
- Content SHA-256, when supplied, must be a valid 64-character hexadecimal digest.
|
| 144 |
+
- Memory and user-origin evidence are subject to authority policy; origin does not imply truth.
|
| 145 |
+
- The adapter does not fetch URLs, authenticate arbitrary external sources or perform external actions.
|
| 146 |
+
|
| 147 |
+
## Conversation contract
|
| 148 |
+
|
| 149 |
+
Conversation goal/referent/constraint verification is exposed through `conversation_gate.py` as a deterministic internal gate. Semantic extraction of those fields is upstream work for BodulLogic/PRECICA or another preparation layer.
|
| 150 |
+
|
| 151 |
+
## Portable public boundary 1.1.0
|
| 152 |
+
|
| 153 |
+
The public `demo.evaluate`, `demo.evaluate_json`, and offline CLI impose a
|
| 154 |
+
1 MiB UTF-8 JSON limit, 32 nesting levels, 128 integer digits, 1000 evidence
|
| 155 |
+
records and 100 batch claims. Duplicate keys, non-finite numbers and lone
|
| 156 |
+
surrogates are rejected. Public approval flags remain ignored.
|
| 157 |
+
|
| 158 |
+
Timestamp strings follow the narrow ASCII interchange grammar documented in
|
| 159 |
+
MATRIX_FORMAT.md and are normalized to UTC before evaluation. The boundary
|
| 160 |
+
rejects unknown -00:00 offsets, leap seconds and implicit/local time zones.
|
| 161 |
+
Equivalent timestamp offsets therefore give the same public receipt. Lower-level
|
| 162 |
+
Python adapter APIs retain their existing schema-3 behavior and are trusted
|
| 163 |
+
integration interfaces. The underlying decision algorithm remains 2.1.2.
|
| 164 |
+
|
| 165 |
+
Historical replay requires the original explicit evaluation time, policy and
|
| 166 |
+
evidence timestamps. APPROVE in such a replay does not imply present-day truth.
|
MAINTENANCE.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Preservation and maintenance
|
| 2 |
+
|
| 3 |
+
This release aims to make future reconstruction practical. It does not promise
|
| 4 |
+
100 years of service availability, browser compatibility, cryptographic
|
| 5 |
+
security, or unattended maintenance. No background maintenance job is installed
|
| 6 |
+
by this release. A designated human maintainer remains necessary.
|
| 7 |
+
|
| 8 |
+
## What can be preserved independently
|
| 9 |
+
|
| 10 |
+
- Python sources, tests, synthetic fixtures and format specifications.
|
| 11 |
+
- A single-file offline Matrix page with its own SHA-256 implementation.
|
| 12 |
+
- A deterministic ZIP source archive, two hash manifests, Apache-2.0 license,
|
| 13 |
+
and instructions that require no account, model weights, pip or network.
|
| 14 |
+
- Original evidence, its acquisition timestamp, policy, evaluation time and
|
| 15 |
+
runtime context, retained separately under the data owner's control.
|
| 16 |
+
|
| 17 |
+
The verification kernel and Matrix core use Python's standard library. The
|
| 18 |
+
Gradio-Lite presentation is optional and has pinned external dependencies; its
|
| 19 |
+
continued operation depends on browsers, package/CDN availability and the host.
|
| 20 |
+
The offline archive is the recovery path if those dependencies disappear.
|
| 21 |
+
|
| 22 |
+
## Review schedule
|
| 23 |
+
|
| 24 |
+
| When | Maintainer action |
|
| 25 |
+
| --- | --- |
|
| 26 |
+
| After any source or dependency change | Run tests, compare vectors, review decision changes, regenerate manifests and archive, verify deployed bytes. |
|
| 27 |
+
| Quarterly | Open the live demo, test an empty/invalid input and one known vector; check official runtime and dependency advisories. |
|
| 28 |
+
| Annually | Restore an archive onto a clean machine with networking disabled; run integrity check and tests; check backup readability. |
|
| 29 |
+
| Before an OS/Python/browser upgrade | Test the archived vectors and public-boundary regressions with the proposed runtime. Keep the previous working archive. |
|
| 30 |
+
| When hash guidance changes | Introduce a new explicit format; preserve old readers and create linked migration records. |
|
| 31 |
+
| Before moving hosts or retiring a service | Copy and verify original files; retain at least two independent storage locations and one offline copy. |
|
| 32 |
+
|
| 33 |
+
Suggested backup practice: three copies across two storage types, with one
|
| 34 |
+
offline or offsite copy. Check stored bytes periodically and replace aging
|
| 35 |
+
media. A Git tag or a checksum beside an archive does not authenticate an
|
| 36 |
+
attacker-controlled replacement; keep a trusted digest/revision separately.
|
| 37 |
+
|
| 38 |
+
## Restore without Hugging Face
|
| 39 |
+
|
| 40 |
+
1. Copy `bodul-offline-1.1.0.zip` and its trusted `ARCHIVE_SHA256.txt` and
|
| 41 |
+
`ARCHIVE_SHA512.txt` from your backup. Compare the ZIP bytes with a trusted
|
| 42 |
+
tool before executing its contents. Extract only into a new directory.
|
| 43 |
+
2. Open `offline.html` for the local checksum tool. It loads no external scripts
|
| 44 |
+
and its content security policy blocks network connections.
|
| 45 |
+
3. With Python available, run `python -B verify_release.py`, then
|
| 46 |
+
`python -B -m unittest discover -s tests -q`.
|
| 47 |
+
4. Run `python -B offline_cli.py matrix examples/matrix.txt --at
|
| 48 |
+
"2026-11-03T12:00:00Z"` and compare with `test_vectors.json`.
|
| 49 |
+
5. Run `python -B offline_cli.py claims examples/supported.json`. This checks
|
| 50 |
+
the archived synthetic fixture at its explicit historical evaluation time;
|
| 51 |
+
it does not establish present-day truth or current source freshness.
|
| 52 |
+
|
| 53 |
+
The source ZIP uses sorted paths, fixed timestamps, fixed permissions and
|
| 54 |
+
uncompressed entries so identical source bytes produce identical archive bytes.
|
| 55 |
+
Text files are UTF-8/LF. There is no absolute install location in the package.
|
| 56 |
+
To reproduce a verified extracted archive, run
|
| 57 |
+
`python -B build_archive.py ../rebuilt.zip`; the destination must be outside the
|
| 58 |
+
extracted directory and must not already exist. Compare its digest with your
|
| 59 |
+
trusted archive record before replacing a backup.
|
| 60 |
+
|
| 61 |
+
## Runtime policy
|
| 62 |
+
|
| 63 |
+
Reference minimum: Python 3.10. The validation record identifies which versions
|
| 64 |
+
were actually executed; a minimum version declaration is not evidence that
|
| 65 |
+
every interpreter/OS combination was tested. Preserve the working interpreter
|
| 66 |
+
installer or distribution under its applicable license for disaster recovery.
|
| 67 |
+
Re-run tests rather than assuming a future interpreter is compatible.
|
| 68 |
+
|
| 69 |
+
Keep platform-specific hosting, UI and authenticated MCP adapters separate from
|
| 70 |
+
the pure execution modules. The public demo ignores caller-supplied approval
|
| 71 |
+
flags. Direct Python integrations must provide their own authenticated human
|
| 72 |
+
review and source-collection boundary before acting on a decision.
|
| 73 |
+
|
| 74 |
+
## Version and trust boundaries
|
| 75 |
+
|
| 76 |
+
- Matrix implementation: 3.6.0-OMEGA; frozen checksum format: bodul-matrix-v1.
|
| 77 |
+
- Supplied-evidence algorithm: 2.1.2; existing JSON adapter schema: 3.
|
| 78 |
+
- Public bounded JSON boundary: 1.1.0; archive release: 1.1.0.
|
| 79 |
+
- No source acquisition, identity authentication, external action or truth
|
| 80 |
+
guarantee is supplied by a local checksum or a caller-provided evidence flag.
|
| 81 |
+
- Archive integrity verification proves comparison with the selected manifest,
|
| 82 |
+
not trusted authorship. Keep the verifier and manifest in your trust review.
|
| 83 |
+
|
| 84 |
+
Do not silently alter behavior under a frozen format ID. Preserve legacy
|
| 85 |
+
vectors, publish migration notes, and test readers against modified and unknown
|
| 86 |
+
formats. Never label unmeasured reliability as a 0% hallucination guarantee.
|
MATRIX_FORMAT.md
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Matrix checksum format: bodul-matrix-v1
|
| 2 |
+
|
| 3 |
+
Status: frozen compatibility specification, published 2026-11-03. Reference
|
| 4 |
+
implementation: Matrix Core 3.6.0-OMEGA, Apache-2.0. Changes to this document may
|
| 5 |
+
clarify examples but MUST NOT silently change the v1 byte sequence.
|
| 6 |
+
|
| 7 |
+
## Inputs and normalization
|
| 8 |
+
|
| 9 |
+
The payload is a non-empty sequence of Unicode scalar values. Encode with strict
|
| 10 |
+
UTF-8, without adding a byte order mark. An existing U+FEFF in input is data.
|
| 11 |
+
Lone UTF-16 surrogates are invalid. The reference entry points impose a limit of
|
| 12 |
+
1,048,576 raw UTF-8 bytes BEFORE normalization; oversized input receives no seal.
|
| 13 |
+
|
| 14 |
+
Normalization profile **bodul-whitespace-v1** uses exactly these 29 code points:
|
| 15 |
+
|
| 16 |
+
```text
|
| 17 |
+
0009 000A 000B 000C 000D 001C 001D 001E 001F 0020
|
| 18 |
+
0085 00A0 1680 2000 2001 2002 2003 2004 2005 2006
|
| 19 |
+
2007 2008 2009 200A 2028 2029 202F 205F 3000
|
| 20 |
+
```
|
| 21 |
+
|
| 22 |
+
Replace each maximal run with one U+0020, then remove leading and trailing
|
| 23 |
+
U+0020. Reject an empty result. No NFC, NFKC, case folding, punctuation changes,
|
| 24 |
+
locale rules, or future Unicode property lookup applies. U+180E, U+200B, U+2060,
|
| 25 |
+
and U+FEFF remain data. Visually similar text may intentionally have different
|
| 26 |
+
checksums. This table preserves the legacy Python 3.12 whitespace behavior
|
| 27 |
+
without depending on future Unicode databases.
|
| 28 |
+
|
| 29 |
+
## UTC date and exact message bytes
|
| 30 |
+
|
| 31 |
+
Convert an explicitly supplied aware datetime to UTC FIRST. Select its date
|
| 32 |
+
in the proleptic Gregorian calendar, years 0001 through 9999 inclusive. Format
|
| 33 |
+
the date as ten ASCII characters `YYYY-MM-DD`, including leading zeroes.
|
| 34 |
+
|
| 35 |
+
Concatenate exactly, without quotes, BOM, terminal newline, or NUL terminator:
|
| 36 |
+
|
| 37 |
+
```text
|
| 38 |
+
BODUL_MATRIX::<normalized text>::<YYYY-MM-DD>::OMEGA_FLOW
|
| 39 |
+
```
|
| 40 |
+
|
| 41 |
+
Encode as strict UTF-8 and apply SHA-256 as specified by
|
| 42 |
+
[FIPS 180-4](https://csrc.nist.gov/pubs/fips/180-4/upd1/final).
|
| 43 |
+
Return 64 lowercase hexadecimal characters, preserving leading zeroes.
|
| 44 |
+
|
| 45 |
+
The suffix is not a parser for recovering text: verification uses the supplied
|
| 46 |
+
payload and date. Literal `::` in the payload is ordinary data. There are no
|
| 47 |
+
secret keys. The engine version, full timestamp, and response labels are NOT
|
| 48 |
+
part of the hash input.
|
| 49 |
+
|
| 50 |
+
## Timestamp interchange grammar
|
| 51 |
+
|
| 52 |
+
The portable JSON/CLI boundary accepts only ASCII:
|
| 53 |
+
|
| 54 |
+
```text
|
| 55 |
+
YYYY-MM-DDTHH:MM:SS[.ffffff](Z|+HH:MM|-HH:MM)
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
Fractional seconds, when present, have 1 through 6 digits. Offset hours are
|
| 59 |
+
00..23; offset minutes are 00..59. Calendar validity is checked. `-00:00` denotes
|
| 60 |
+
an unknown offset and is rejected. Leap seconds, offsets containing seconds,
|
| 61 |
+
week dates, compact dates, spaces instead of `T`, and missing offsets are
|
| 62 |
+
rejected. Results use a UTC `+00:00` offset; nonzero microseconds use six digits.
|
| 63 |
+
UTC conversion outside 0001..9999 is rejected. This deliberately narrow grammar
|
| 64 |
+
prevents a future parser expansion from changing accepted input implicitly.
|
| 65 |
+
|
| 66 |
+
The Python datetime API also accepts aware datetime objects; archive replay
|
| 67 |
+
uses the stricter text interchange grammar. With no `now`, the Python core
|
| 68 |
+
captures UTC once. Offline CLI replay requires `--at` explicitly.
|
| 69 |
+
|
| 70 |
+
## Output and historical verification
|
| 71 |
+
|
| 72 |
+
Success includes `valid: true`, normalized `input_payload`, `seal_date`,
|
| 73 |
+
`matrix_verification_seal`, `timestamp`, `engine_version`, `checksum_format`,
|
| 74 |
+
`normalization_profile`, and `hash_algorithm`. An invalid payload includes
|
| 75 |
+
`valid: false`, an error code and metadata, with NO seal. Machine schema:
|
| 76 |
+
`schemas/matrix-result-v1.json`; independent fixtures: `test_vectors.json`.
|
| 77 |
+
|
| 78 |
+
`verify_result` verifies the consistency of a successful exported result. It
|
| 79 |
+
requires the exact field set, supported format/profile/hash identifiers,
|
| 80 |
+
canonical normalized input, a timestamp matching the UTC date, and the correct
|
| 81 |
+
seal. Original unlabeled **3.5.1-OMEGA** records remain readable. Other unlabeled
|
| 82 |
+
records and unknown future formats fail closed. Invalid-input records cannot
|
| 83 |
+
be verified as successful checksums.
|
| 84 |
+
|
| 85 |
+
Existing valid 3.5.1 checksums remain unchanged. Version 3.6 adds explicit
|
| 86 |
+
format metadata and controlled Unicode/size errors; clients comparing complete
|
| 87 |
+
objects must account for the additional fields and engine version.
|
| 88 |
+
|
| 89 |
+
The full result repeats only when text, time and engine metadata also match.
|
| 90 |
+
The seal repeats for matching normalized text and UTC date. Metadata may be
|
| 91 |
+
edited without changing a same-day seal; verification does not authenticate
|
| 92 |
+
that metadata, authorship, original acquisition time, or external truth.
|
| 93 |
+
|
| 94 |
+
## Future migrations
|
| 95 |
+
|
| 96 |
+
Never redefine v1 or infer the format from digest length alone. If a new hash,
|
| 97 |
+
normalization profile or message layout is needed, publish a NEW format ID,
|
| 98 |
+
domain-separated bytes, independent vectors, reader and migration notes. Keep
|
| 99 |
+
the old reader for historical checks. Store original source text, old result,
|
| 100 |
+
new result, migration date and source revision together; do not overwrite the
|
| 101 |
+
only historical record. Rehashing does not prove when the original existed.
|
| 102 |
+
|
| 103 |
+
SHA-256 security is not guaranteed for a century. Review cryptographic guidance
|
| 104 |
+
periodically. Archive manifests include SHA-256 and SHA-512 for byte comparison;
|
| 105 |
+
neither is a signature, and both belong to the SHA-2 family.
|
| 106 |
+
|
| 107 |
+
## References
|
| 108 |
+
|
| 109 |
+
- [Python whitespace behavior](https://docs.python.org/3/library/stdtypes.html#str.isspace)
|
| 110 |
+
- [NIST Secure Hash Standard](https://csrc.nist.gov/pubs/fips/180-4/upd1/final)
|
| 111 |
+
- [RFC 3339 timestamp conventions](https://www.rfc-editor.org/rfc/rfc3339)
|
| 112 |
+
|
| 113 |
+
These links explain current conventions. The explicit rules and bundled
|
| 114 |
+
fixtures above are the preservation contract if the websites disappear.
|
README.md
CHANGED
|
@@ -1,126 +1,137 @@
|
|
| 1 |
-
---
|
| 2 |
-
license: apache-2.0
|
| 3 |
-
|
| 4 |
-
--
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
python
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
)
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: apache-2.0
|
| 3 |
+
language: [en, hr]
|
| 4 |
+
tags: [deterministic-ai, hal, system-architecture, agentic, mcp, reproducibility, offline]
|
| 5 |
+
---
|
| 6 |
+
# Deterministic Engine
|
| 7 |
+
|
| 8 |
+
## Executive Summary
|
| 9 |
+
|
| 10 |
+
Portable deterministic execution for structured evidence and text integrity.
|
| 11 |
+
Bodulski HAL evaluates supplied evidence against explicit policy; Matrix Core
|
| 12 |
+
normalizes text with a frozen character table and computes a UTC-date checksum.
|
| 13 |
+
Neither path performs probabilistic text generation or requires model weights.
|
| 14 |
+
The calling application controls source collection, trust and enforcement.
|
| 15 |
+
|
| 16 |
+
Release **1.1.0** separates the standard-library core from optional presentation
|
| 17 |
+
and hosting. The source archive, exact format specification, independent vectors
|
| 18 |
+
and restoration instructions make the core recoverable without this service.
|
| 19 |
+
|
| 20 |
+
## Architecture Overview
|
| 21 |
+
|
| 22 |
+
```text
|
| 23 |
+
Structured evidence + policy + explicit evaluation time
|
| 24 |
+
|
|
| 25 |
+
Bounded strict JSON boundary
|
| 26 |
+
|
|
| 27 |
+
Freshness / authority / contradiction gates
|
| 28 |
+
|
|
| 29 |
+
Classification + APPROVE / BLOCK / ESCALATE + receipt
|
| 30 |
+
|
|
| 31 |
+
Calling application enforces the decision
|
| 32 |
+
|
| 33 |
+
Text + explicit instant -> frozen whitespace profile -> UTC date
|
| 34 |
+
-> exact UTF-8 v1 message -> SHA-256 checksum
|
| 35 |
+
-> export -> offline consistency verification
|
| 36 |
+
```
|
| 37 |
+
|
| 38 |
+
## Quickstart Code Snippets
|
| 39 |
+
|
| 40 |
+
Download [the complete offline source archive](bodul-offline-1.1.0.zip), extract
|
| 41 |
+
it into a new directory, then use an installed Python runtime:
|
| 42 |
+
|
| 43 |
+
```bash
|
| 44 |
+
python -B verify_release.py
|
| 45 |
+
python -B -m unittest discover -s tests -q
|
| 46 |
+
python -B offline_cli.py matrix examples/matrix.txt --at "2026-11-03T12:00:00Z"
|
| 47 |
+
python -B offline_cli.py verify-matrix examples/matrix-result.json
|
| 48 |
+
python -B offline_cli.py claims examples/supported.json
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
```python
|
| 52 |
+
from datetime import datetime, timezone
|
| 53 |
+
from matrix_core import DeterministicMatrixCore, verify_result
|
| 54 |
+
from demo import evaluate, example
|
| 55 |
+
|
| 56 |
+
at = datetime(2026, 11, 3, 12, tzinfo=timezone.utc)
|
| 57 |
+
result = DeterministicMatrixCore().enforce_zero_tolerance_matrix(
|
| 58 |
+
" Evidence\tbefore\nconfidence. ", now=at
|
| 59 |
+
)
|
| 60 |
+
assert result["matrix_verification_seal"] == "36e7be60a50a6d846b8b162c199a5e534c612ba171163d9c3406e2f2b12c0651"
|
| 61 |
+
assert verify_result(result)["verified"] is True
|
| 62 |
+
assert evaluate(example("unsupported", at=at))["verdict"] == "BLOCK"
|
| 63 |
+
```
|
| 64 |
+
|
| 65 |
+
The examples are synthetic public fixtures. Historical replay uses their
|
| 66 |
+
original explicit evaluation time; it is not evidence of present-day freshness.
|
| 67 |
+
|
| 68 |
+
## Deterministic Execution Proof
|
| 69 |
+
|
| 70 |
+
For fixed text and UTC date, the v1 checksum byte sequence is specified in
|
| 71 |
+
[MATRIX_FORMAT.md](MATRIX_FORMAT.md), independent of a future Unicode database.
|
| 72 |
+
For the entire response to repeat, the full instant and engine metadata must
|
| 73 |
+
also match. The evidence verifier likewise requires fixed input, policy,
|
| 74 |
+
evaluation time and runtime provenance. [test_vectors.json](test_vectors.json)
|
| 75 |
+
includes legacy compatibility, Unicode, 2126, Gregorian boundary and leap-year
|
| 76 |
+
fixtures. These fixtures test arithmetic and reproducibility, not future uptime.
|
| 77 |
+
|
| 78 |
+
The core performs no stochastic generation. **A 0% real-world hallucination
|
| 79 |
+
rate is neither measured nor guaranteed.** Supplied evidence can be fabricated;
|
| 80 |
+
callers must authenticate sources and enforce BLOCK/ESCALATE decisions.
|
| 81 |
+
|
| 82 |
+
## Benchmark & Reliability Specifications
|
| 83 |
+
|
| 84 |
+
| Property | Release contract |
|
| 85 |
+
| --- | --- |
|
| 86 |
+
| Matrix implementation | 3.6.0-OMEGA |
|
| 87 |
+
| Frozen checksum / normalization | bodul-matrix-v1 / bodul-whitespace-v1 |
|
| 88 |
+
| Supplied-evidence algorithm / JSON schema | 2.1.2 / 3 |
|
| 89 |
+
| Public JSON boundary / archive | 1.1.0 / 1.1.0 |
|
| 90 |
+
| Core dependencies | Python standard library; no inference service |
|
| 91 |
+
| Raw Matrix / JSON input limits | 1 MiB each; JSON nesting 32 levels |
|
| 92 |
+
| Public evidence limits | 1000 evidence records; 100 batch claims |
|
| 93 |
+
| Validation | [Recorded tests and local timing](VALIDATION.json) |
|
| 94 |
+
| Integrity | SHA-256/SHA-512 archive comparison; no source authentication |
|
| 95 |
+
|
| 96 |
+
Local timing measures synthetic in-process execution on the recorded runtime;
|
| 97 |
+
it excludes cold starts, UI, network and real-world truth accuracy. It is not
|
| 98 |
+
a hosted SLA, enterprise certification or a century-long reliability result.
|
| 99 |
+
Invalid Unicode, duplicate JSON keys, non-finite numbers and malformed records
|
| 100 |
+
fail closed at the documented public boundary.
|
| 101 |
+
|
| 102 |
+
## Preservation and System Integration
|
| 103 |
+
|
| 104 |
+
- [Free interactive evidence playground](https://huggingface.co/spaces/mirdax25-ux/bodulski-hal-playground).
|
| 105 |
+
- [Single-file offline Matrix tool](https://mirdax25-ux-bodulski-hal-playground.static.hf.space/offline.html): save and open locally, with no CDN or network.
|
| 106 |
+
- [Portable source archive](bodul-offline-1.1.0.zip), [SHA-256](ARCHIVE_SHA256.txt), [SHA-512](ARCHIVE_SHA512.txt).
|
| 107 |
+
- [Maintenance, backup and restoration](MAINTENANCE.md); [compatibility notes](CHANGELOG.md).
|
| 108 |
+
- [Evidence JSON contract](JSON_CONTRACT.md); [Matrix result schema](schemas/matrix-result-v1.json).
|
| 109 |
+
- [Bodulski HAL](https://huggingface.co/mirdax25-ux/Bodul) and [Deterministic Engine](https://huggingface.co/mirdax25-ux/deterministic-engine).
|
| 110 |
+
- [Separately authenticated Apify MCP Actor](https://apify.com/bodul/bodulskihal-claim-verifier).
|
| 111 |
+
|
| 112 |
+
The hosted Space is **Static/Gradio-Lite**, with pinned browser dependencies
|
| 113 |
+
and no paid compute, persistent volume or inference endpoint. The first load
|
| 114 |
+
downloads its Python runtime. It exposes no server-side MCP endpoint. Use the
|
| 115 |
+
standalone offline page/archive if the host or CDNs are unavailable.
|
| 116 |
+
|
| 117 |
+
## Trust and Compatibility
|
| 118 |
+
|
| 119 |
+
`valid: true` establishes accepted text, not truth or permission. A Matrix
|
| 120 |
+
checksum can be recomputed by anyone; full timestamps and engine versions are
|
| 121 |
+
unsealed metadata. `verify_result` checks consistency, not authorship. Existing
|
| 122 |
+
3.5.1 checksum values remain valid; 3.6 adds explicit format fields and controlled
|
| 123 |
+
input errors. Unknown future formats are rejected until a reader is added.
|
| 124 |
+
|
| 125 |
+
Public Python/JSON entry points ignore caller-provided human approval flags.
|
| 126 |
+
The lower-level kernel is a trusted integration API and requires external
|
| 127 |
+
authentication and source validation. The public demo does not fetch URLs,
|
| 128 |
+
execute payload text, or establish source identity. Do not submit secrets.
|
| 129 |
+
|
| 130 |
+
No service availability, runtime support or cryptographic strength can be
|
| 131 |
+
promised for 100 years. The preservation strategy is explicit formats, readable
|
| 132 |
+
source, tested recovery, multiple backups and maintained migration paths.
|
| 133 |
+
|
| 134 |
+
## Author and License
|
| 135 |
+
|
| 136 |
+
Damir Balenović — [GitHub](https://github.com/mirdax25-ux) · [X](https://x.com/ddddddd97424402) · [LinkedIn](https://www.linkedin.com/in/damir-balenovic-594015429/).
|
| 137 |
+
Apache License 2.0; see [LICENSE](LICENSE).
|
VALIDATION.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"started_at": "2026-11-03T10:21:46.791090+00:00",
|
| 3 |
+
"status": "passed",
|
| 4 |
+
"interpreters": [
|
| 5 |
+
{
|
| 6 |
+
"runtime": "Python 3.12.14",
|
| 7 |
+
"passed": true,
|
| 8 |
+
"tests": 46
|
| 9 |
+
},
|
| 10 |
+
{
|
| 11 |
+
"runtime": "Python 3.15.2",
|
| 12 |
+
"passed": true,
|
| 13 |
+
"tests": 46
|
| 14 |
+
}
|
| 15 |
+
],
|
| 16 |
+
"browser_logic": {
|
| 17 |
+
"passed": true,
|
| 18 |
+
"node": "v22.23.2",
|
| 19 |
+
"sha256_reference_cases": 18,
|
| 20 |
+
"vectors": 13,
|
| 21 |
+
"whitespace_cases": 29,
|
| 22 |
+
"selftest_checks": 9
|
| 23 |
+
},
|
| 24 |
+
"python_javascript_full_result_matches": 13,
|
| 25 |
+
"space_app_constructed_and_handlers_passed": true,
|
| 26 |
+
"space_embedded_source_matches": true,
|
| 27 |
+
"benchmark": {
|
| 28 |
+
"iterations": 1000,
|
| 29 |
+
"identical_results": 1000,
|
| 30 |
+
"p50_ms": 0.2542,
|
| 31 |
+
"p95_ms": 0.4011,
|
| 32 |
+
"runtime": "3.12.14",
|
| 33 |
+
"machine": "AMD64",
|
| 34 |
+
"scope": "one synthetic claim, in-process public boundary; excludes UI, network, cold start, external truth accuracy"
|
| 35 |
+
},
|
| 36 |
+
"documentation_examples_passed": 3,
|
| 37 |
+
"completed_at": "2026-11-03T10:22:03.684574+00:00"
|
| 38 |
+
}
|
bodul-offline-1.1.0.zip
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:b1e2f3a1a8ce43278cb0382df1e493f1cbbe7db0554846647d41af6b4ecf6e1a
|
| 3 |
+
size 213666
|
bodulskihal_core/__init__.py
CHANGED
|
@@ -1,120 +1,120 @@
|
|
| 1 |
-
from .engine import (
|
| 2 |
-
ALGORITHM_VERSION,
|
| 3 |
-
AnswerabilityStatus,
|
| 4 |
-
AuthorityScope,
|
| 5 |
-
Claim,
|
| 6 |
-
ClaimType,
|
| 7 |
-
Classification,
|
| 8 |
-
CompoundResult,
|
| 9 |
-
Directness,
|
| 10 |
-
Evidence,
|
| 11 |
-
EvidenceOrigin,
|
| 12 |
-
SourceRole,
|
| 13 |
-
Policy,
|
| 14 |
-
RiskLevel,
|
| 15 |
-
VerificationResult,
|
| 16 |
-
Verdict,
|
| 17 |
-
verify_claim,
|
| 18 |
-
verify_compound,
|
| 19 |
-
canonical_receipt_sha256,
|
| 20 |
-
make_failure_evolution_record,
|
| 21 |
-
)
|
| 22 |
-
|
| 23 |
-
__all__ = [
|
| 24 |
-
"ALGORITHM_VERSION",
|
| 25 |
-
"AnswerabilityStatus",
|
| 26 |
-
"AuthorityScope",
|
| 27 |
-
"Claim",
|
| 28 |
-
"ClaimType",
|
| 29 |
-
"Classification",
|
| 30 |
-
"CompoundResult",
|
| 31 |
-
"Directness",
|
| 32 |
-
"Evidence",
|
| 33 |
-
"EvidenceOrigin",
|
| 34 |
-
"SourceRole",
|
| 35 |
-
"Policy",
|
| 36 |
-
"RiskLevel",
|
| 37 |
-
"VerificationResult",
|
| 38 |
-
"Verdict",
|
| 39 |
-
"verify_claim",
|
| 40 |
-
"verify_compound",
|
| 41 |
-
"canonical_receipt_sha256",
|
| 42 |
-
"make_failure_evolution_record",
|
| 43 |
-
"SCHEMA_VERSION",
|
| 44 |
-
"verify_payload",
|
| 45 |
-
"verify_compound_payload",
|
| 46 |
-
"run_research_demo",
|
| 47 |
-
"evaluate_benchmark",
|
| 48 |
-
"GUARD_VERSION",
|
| 49 |
-
"Realism",
|
| 50 |
-
"GuardStage",
|
| 51 |
-
"UnknownSeverity",
|
| 52 |
-
"UnknownRecord",
|
| 53 |
-
"SelfCheck",
|
| 54 |
-
"GuardResult",
|
| 55 |
-
"run_guard_loop",
|
| 56 |
-
"ProjectState",
|
| 57 |
-
"LifecycleResult",
|
| 58 |
-
"freeze_project",
|
| 59 |
-
"authorize_mutation",
|
| 60 |
-
"reopen_project",
|
| 61 |
-
"SpeakerRole",
|
| 62 |
-
"AtomStatus",
|
| 63 |
-
"RawResponseEnvelope",
|
| 64 |
-
"ClaimAtom",
|
| 65 |
-
"EvidenceContract",
|
| 66 |
-
"bind_atom",
|
| 67 |
-
"atomize_raw_response",
|
| 68 |
-
"build_evidence_contract",
|
| 69 |
-
"ConstraintRecord",
|
| 70 |
-
"ConversationContract",
|
| 71 |
-
"ConversationGateResult",
|
| 72 |
-
"canonical_conversation_contract_sha256",
|
| 73 |
-
"evaluate_conversation_contract",
|
| 74 |
-
"RuntimeProvenance",
|
| 75 |
-
"current_runtime_provenance",
|
| 76 |
-
]
|
| 77 |
-
|
| 78 |
-
from .adapter import SCHEMA_VERSION, verify_compound_payload, verify_payload
|
| 79 |
-
from .evaluation import evaluate_benchmark
|
| 80 |
-
from .research_demo import run_research_demo
|
| 81 |
-
|
| 82 |
-
from .guard_loop import (
|
| 83 |
-
GUARD_VERSION,
|
| 84 |
-
GuardResult,
|
| 85 |
-
GuardStage,
|
| 86 |
-
Realism,
|
| 87 |
-
SelfCheck,
|
| 88 |
-
UnknownRecord,
|
| 89 |
-
UnknownSeverity,
|
| 90 |
-
run_guard_loop,
|
| 91 |
-
)
|
| 92 |
-
|
| 93 |
-
from .lifecycle import (
|
| 94 |
-
LifecycleResult,
|
| 95 |
-
ProjectState,
|
| 96 |
-
authorize_mutation,
|
| 97 |
-
freeze_project,
|
| 98 |
-
reopen_project,
|
| 99 |
-
)
|
| 100 |
-
|
| 101 |
-
from .raw_pipeline import (
|
| 102 |
-
AtomStatus,
|
| 103 |
-
ClaimAtom,
|
| 104 |
-
EvidenceContract,
|
| 105 |
-
RawResponseEnvelope,
|
| 106 |
-
SpeakerRole,
|
| 107 |
-
atomize_raw_response,
|
| 108 |
-
bind_atom,
|
| 109 |
-
build_evidence_contract,
|
| 110 |
-
)
|
| 111 |
-
|
| 112 |
-
from .conversation_gate import (
|
| 113 |
-
ConstraintRecord,
|
| 114 |
-
ConversationContract,
|
| 115 |
-
ConversationGateResult,
|
| 116 |
-
canonical_conversation_contract_sha256,
|
| 117 |
-
evaluate_conversation_contract,
|
| 118 |
-
)
|
| 119 |
-
|
| 120 |
from .runtime_provenance import RuntimeProvenance, current_runtime_provenance
|
|
|
|
| 1 |
+
from .engine import (
|
| 2 |
+
ALGORITHM_VERSION,
|
| 3 |
+
AnswerabilityStatus,
|
| 4 |
+
AuthorityScope,
|
| 5 |
+
Claim,
|
| 6 |
+
ClaimType,
|
| 7 |
+
Classification,
|
| 8 |
+
CompoundResult,
|
| 9 |
+
Directness,
|
| 10 |
+
Evidence,
|
| 11 |
+
EvidenceOrigin,
|
| 12 |
+
SourceRole,
|
| 13 |
+
Policy,
|
| 14 |
+
RiskLevel,
|
| 15 |
+
VerificationResult,
|
| 16 |
+
Verdict,
|
| 17 |
+
verify_claim,
|
| 18 |
+
verify_compound,
|
| 19 |
+
canonical_receipt_sha256,
|
| 20 |
+
make_failure_evolution_record,
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
__all__ = [
|
| 24 |
+
"ALGORITHM_VERSION",
|
| 25 |
+
"AnswerabilityStatus",
|
| 26 |
+
"AuthorityScope",
|
| 27 |
+
"Claim",
|
| 28 |
+
"ClaimType",
|
| 29 |
+
"Classification",
|
| 30 |
+
"CompoundResult",
|
| 31 |
+
"Directness",
|
| 32 |
+
"Evidence",
|
| 33 |
+
"EvidenceOrigin",
|
| 34 |
+
"SourceRole",
|
| 35 |
+
"Policy",
|
| 36 |
+
"RiskLevel",
|
| 37 |
+
"VerificationResult",
|
| 38 |
+
"Verdict",
|
| 39 |
+
"verify_claim",
|
| 40 |
+
"verify_compound",
|
| 41 |
+
"canonical_receipt_sha256",
|
| 42 |
+
"make_failure_evolution_record",
|
| 43 |
+
"SCHEMA_VERSION",
|
| 44 |
+
"verify_payload",
|
| 45 |
+
"verify_compound_payload",
|
| 46 |
+
"run_research_demo",
|
| 47 |
+
"evaluate_benchmark",
|
| 48 |
+
"GUARD_VERSION",
|
| 49 |
+
"Realism",
|
| 50 |
+
"GuardStage",
|
| 51 |
+
"UnknownSeverity",
|
| 52 |
+
"UnknownRecord",
|
| 53 |
+
"SelfCheck",
|
| 54 |
+
"GuardResult",
|
| 55 |
+
"run_guard_loop",
|
| 56 |
+
"ProjectState",
|
| 57 |
+
"LifecycleResult",
|
| 58 |
+
"freeze_project",
|
| 59 |
+
"authorize_mutation",
|
| 60 |
+
"reopen_project",
|
| 61 |
+
"SpeakerRole",
|
| 62 |
+
"AtomStatus",
|
| 63 |
+
"RawResponseEnvelope",
|
| 64 |
+
"ClaimAtom",
|
| 65 |
+
"EvidenceContract",
|
| 66 |
+
"bind_atom",
|
| 67 |
+
"atomize_raw_response",
|
| 68 |
+
"build_evidence_contract",
|
| 69 |
+
"ConstraintRecord",
|
| 70 |
+
"ConversationContract",
|
| 71 |
+
"ConversationGateResult",
|
| 72 |
+
"canonical_conversation_contract_sha256",
|
| 73 |
+
"evaluate_conversation_contract",
|
| 74 |
+
"RuntimeProvenance",
|
| 75 |
+
"current_runtime_provenance",
|
| 76 |
+
]
|
| 77 |
+
|
| 78 |
+
from .adapter import SCHEMA_VERSION, verify_compound_payload, verify_payload
|
| 79 |
+
from .evaluation import evaluate_benchmark
|
| 80 |
+
from .research_demo import run_research_demo
|
| 81 |
+
|
| 82 |
+
from .guard_loop import (
|
| 83 |
+
GUARD_VERSION,
|
| 84 |
+
GuardResult,
|
| 85 |
+
GuardStage,
|
| 86 |
+
Realism,
|
| 87 |
+
SelfCheck,
|
| 88 |
+
UnknownRecord,
|
| 89 |
+
UnknownSeverity,
|
| 90 |
+
run_guard_loop,
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
from .lifecycle import (
|
| 94 |
+
LifecycleResult,
|
| 95 |
+
ProjectState,
|
| 96 |
+
authorize_mutation,
|
| 97 |
+
freeze_project,
|
| 98 |
+
reopen_project,
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
from .raw_pipeline import (
|
| 102 |
+
AtomStatus,
|
| 103 |
+
ClaimAtom,
|
| 104 |
+
EvidenceContract,
|
| 105 |
+
RawResponseEnvelope,
|
| 106 |
+
SpeakerRole,
|
| 107 |
+
atomize_raw_response,
|
| 108 |
+
bind_atom,
|
| 109 |
+
build_evidence_contract,
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
from .conversation_gate import (
|
| 113 |
+
ConstraintRecord,
|
| 114 |
+
ConversationContract,
|
| 115 |
+
ConversationGateResult,
|
| 116 |
+
canonical_conversation_contract_sha256,
|
| 117 |
+
evaluate_conversation_contract,
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
from .runtime_provenance import RuntimeProvenance, current_runtime_provenance
|
bodulskihal_core/adapter.py
CHANGED
|
@@ -1,264 +1,264 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
from datetime import datetime
|
| 4 |
-
from typing import Any, Mapping
|
| 5 |
-
|
| 6 |
-
from .engine import (
|
| 7 |
-
ALGORITHM_VERSION,
|
| 8 |
-
AnswerabilityStatus,
|
| 9 |
-
AuthorityScope,
|
| 10 |
-
Claim,
|
| 11 |
-
ClaimType,
|
| 12 |
-
Directness,
|
| 13 |
-
Evidence,
|
| 14 |
-
EvidenceOrigin,
|
| 15 |
-
Policy,
|
| 16 |
-
RiskLevel,
|
| 17 |
-
SourceRole,
|
| 18 |
-
verify_claim,
|
| 19 |
-
verify_compound,
|
| 20 |
-
)
|
| 21 |
-
|
| 22 |
-
SCHEMA_VERSION = 3
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
def _mapping(value: Any, path: str) -> Mapping[str, Any]:
|
| 26 |
-
if not isinstance(value, Mapping):
|
| 27 |
-
raise TypeError(f"{path} must be object")
|
| 28 |
-
return value
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
def _list(value: Any, path: str) -> list[Any]:
|
| 32 |
-
if not isinstance(value, list):
|
| 33 |
-
raise TypeError(f"{path} must be list")
|
| 34 |
-
return value
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
def _required(obj: Mapping[str, Any], key: str, path: str) -> Any:
|
| 38 |
-
if key not in obj:
|
| 39 |
-
raise ValueError(f"missing required field: {path}.{key}")
|
| 40 |
-
return obj[key]
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
def _string(value: Any, path: str) -> str:
|
| 44 |
-
if not isinstance(value, str) or not value:
|
| 45 |
-
raise TypeError(f"{path} must be non-empty string")
|
| 46 |
-
return value
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
def _optional_string(value: Any, path: str) -> str | None:
|
| 50 |
-
if value is None:
|
| 51 |
-
return None
|
| 52 |
-
return _string(value, path)
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
def _bool(value: Any, path: str) -> bool:
|
| 56 |
-
if type(value) is not bool:
|
| 57 |
-
raise TypeError(f"{path} must be bool")
|
| 58 |
-
return value
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
def _int(value: Any, path: str) -> int:
|
| 62 |
-
if isinstance(value, bool) or not isinstance(value, int):
|
| 63 |
-
raise TypeError(f"{path} must be int")
|
| 64 |
-
return value
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
def _datetime(value: Any, path: str) -> datetime:
|
| 68 |
-
raw = _string(value, path)
|
| 69 |
-
if raw.endswith("Z"):
|
| 70 |
-
raw = raw[:-1] + "+00:00"
|
| 71 |
-
try:
|
| 72 |
-
parsed = datetime.fromisoformat(raw)
|
| 73 |
-
except ValueError as exc:
|
| 74 |
-
raise ValueError(f"{path} must be ISO 8601 datetime") from exc
|
| 75 |
-
if parsed.tzinfo is None or parsed.utcoffset() is None:
|
| 76 |
-
raise ValueError(f"{path} must be timezone-aware")
|
| 77 |
-
return parsed
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
def _enum(enum_type, value: Any, path: str):
|
| 81 |
-
raw = _string(value, path)
|
| 82 |
-
try:
|
| 83 |
-
return enum_type(raw)
|
| 84 |
-
except ValueError as exc:
|
| 85 |
-
raise ValueError(f"invalid {path}: {raw}") from exc
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
def _claim(raw: Any, path: str = "claim") -> Claim:
|
| 89 |
-
obj = _mapping(raw, path)
|
| 90 |
-
return Claim(
|
| 91 |
-
claim_id=_string(_required(obj, "id", path), f"{path}.id"),
|
| 92 |
-
text=_string(_required(obj, "text", path), f"{path}.text"),
|
| 93 |
-
claim_type=_enum(ClaimType, _required(obj, "type", path), f"{path}.type"),
|
| 94 |
-
risk=_enum(RiskLevel, obj.get("risk", RiskLevel.LOW.value), f"{path}.risk"),
|
| 95 |
-
consequential=_bool(obj.get("consequential", True), f"{path}.consequential"),
|
| 96 |
-
authority_scope=_enum(
|
| 97 |
-
AuthorityScope,
|
| 98 |
-
obj.get("authority_scope", AuthorityScope.EXTERNAL_FACT.value),
|
| 99 |
-
f"{path}.authority_scope",
|
| 100 |
-
),
|
| 101 |
-
answerability=_enum(
|
| 102 |
-
AnswerabilityStatus,
|
| 103 |
-
obj.get("answerability", AnswerabilityStatus.ANSWERABLE.value),
|
| 104 |
-
f"{path}.answerability",
|
| 105 |
-
),
|
| 106 |
-
)
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
def _evidence(raw: Any, path: str) -> Evidence:
|
| 110 |
-
obj = _mapping(raw, path)
|
| 111 |
-
return Evidence(
|
| 112 |
-
source_id=_string(_required(obj, "source_id", path), f"{path}.source_id"),
|
| 113 |
-
supports=_bool(_required(obj, "supports", path), f"{path}.supports"),
|
| 114 |
-
observed_at=_datetime(_required(obj, "observed_at", path), f"{path}.observed_at"),
|
| 115 |
-
directness=_enum(Directness, _required(obj, "directness", path), f"{path}.directness"),
|
| 116 |
-
post_action=_bool(obj.get("post_action", False), f"{path}.post_action"),
|
| 117 |
-
eligible=_bool(obj.get("eligible", True), f"{path}.eligible"),
|
| 118 |
-
evidence_id=_optional_string(obj.get("evidence_id"), f"{path}.evidence_id"),
|
| 119 |
-
source_role=_enum(
|
| 120 |
-
SourceRole,
|
| 121 |
-
obj.get("source_role", SourceRole.EXTERNAL.value),
|
| 122 |
-
f"{path}.source_role",
|
| 123 |
-
),
|
| 124 |
-
source_ref=_optional_string(obj.get("source_ref"), f"{path}.source_ref"),
|
| 125 |
-
independence_group=_optional_string(
|
| 126 |
-
obj.get("independence_group"),
|
| 127 |
-
f"{path}.independence_group",
|
| 128 |
-
),
|
| 129 |
-
content_sha256=_optional_string(
|
| 130 |
-
obj.get("content_sha256"),
|
| 131 |
-
f"{path}.content_sha256",
|
| 132 |
-
),
|
| 133 |
-
origin=_enum(
|
| 134 |
-
EvidenceOrigin,
|
| 135 |
-
obj.get("origin", EvidenceOrigin.EXTERNAL_SOURCE.value),
|
| 136 |
-
f"{path}.origin",
|
| 137 |
-
),
|
| 138 |
-
authority_scope=_enum(
|
| 139 |
-
AuthorityScope,
|
| 140 |
-
obj.get("authority_scope", AuthorityScope.EXTERNAL_FACT.value),
|
| 141 |
-
f"{path}.authority_scope",
|
| 142 |
-
),
|
| 143 |
-
)
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
def _policy(raw: Any) -> Policy:
|
| 147 |
-
path = "policy"
|
| 148 |
-
obj = _mapping(raw, path)
|
| 149 |
-
return Policy(
|
| 150 |
-
version=_string(_required(obj, "version", path), "policy.version"),
|
| 151 |
-
max_age_seconds=_int(_required(obj, "max_age_seconds", path), "policy.max_age_seconds"),
|
| 152 |
-
reject_future_evidence=_bool(
|
| 153 |
-
obj.get("reject_future_evidence", True),
|
| 154 |
-
"policy.reject_future_evidence",
|
| 155 |
-
),
|
| 156 |
-
require_independent_action_evidence=_bool(
|
| 157 |
-
obj.get("require_independent_action_evidence", True),
|
| 158 |
-
"policy.require_independent_action_evidence",
|
| 159 |
-
),
|
| 160 |
-
medium_risk_min_independent_sources=_int(
|
| 161 |
-
obj.get("medium_risk_min_independent_sources", 2),
|
| 162 |
-
"policy.medium_risk_min_independent_sources",
|
| 163 |
-
),
|
| 164 |
-
require_runtime_provenance=_bool(
|
| 165 |
-
obj.get("require_runtime_provenance", False),
|
| 166 |
-
"policy.require_runtime_provenance",
|
| 167 |
-
),
|
| 168 |
-
)
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
def _result_dict(result) -> dict[str, Any]:
|
| 172 |
-
return {
|
| 173 |
-
"schema_version": SCHEMA_VERSION,
|
| 174 |
-
"algorithm_version": ALGORITHM_VERSION,
|
| 175 |
-
"classification": result.classification.value,
|
| 176 |
-
"verdict": result.verdict.value,
|
| 177 |
-
"reason": result.reason,
|
| 178 |
-
"receipt": result.receipt,
|
| 179 |
-
"failure_record": result.failure_record,
|
| 180 |
-
}
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
def verify_payload(payload: Mapping[str, Any]) -> dict[str, Any]:
|
| 184 |
-
obj = _mapping(payload, "payload")
|
| 185 |
-
claim = _claim(_required(obj, "claim", "payload"))
|
| 186 |
-
evidence_raw = _list(_required(obj, "evidence", "payload"), "payload.evidence")
|
| 187 |
-
evidence = tuple(
|
| 188 |
-
_evidence(item, f"evidence[{index}]")
|
| 189 |
-
for index, item in enumerate(evidence_raw)
|
| 190 |
-
)
|
| 191 |
-
policy = _policy(_required(obj, "policy", "payload"))
|
| 192 |
-
evaluation_time = _datetime(
|
| 193 |
-
_required(obj, "evaluation_time", "payload"),
|
| 194 |
-
"payload.evaluation_time",
|
| 195 |
-
)
|
| 196 |
-
human_approved = _bool(obj.get("human_approved", False), "payload.human_approved")
|
| 197 |
-
return _result_dict(
|
| 198 |
-
verify_claim(
|
| 199 |
-
claim,
|
| 200 |
-
evidence,
|
| 201 |
-
policy=policy,
|
| 202 |
-
evaluation_time=evaluation_time,
|
| 203 |
-
human_approved=human_approved,
|
| 204 |
-
)
|
| 205 |
-
)
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
def verify_compound_payload(payload: Mapping[str, Any]) -> dict[str, Any]:
|
| 209 |
-
obj = _mapping(payload, "payload")
|
| 210 |
-
claims_raw = _list(_required(obj, "claims", "payload"), "payload.claims")
|
| 211 |
-
claims = tuple(_claim(item, f"claims[{index}]") for index, item in enumerate(claims_raw))
|
| 212 |
-
claim_ids = {claim.claim_id for claim in claims}
|
| 213 |
-
|
| 214 |
-
evidence_map_raw = _mapping(
|
| 215 |
-
_required(obj, "evidence_by_claim", "payload"),
|
| 216 |
-
"payload.evidence_by_claim",
|
| 217 |
-
)
|
| 218 |
-
unknown_evidence_ids = set(evidence_map_raw) - claim_ids
|
| 219 |
-
if unknown_evidence_ids:
|
| 220 |
-
raise ValueError(
|
| 221 |
-
"evidence_by_claim contains unknown claim id: "
|
| 222 |
-
+ ", ".join(sorted(unknown_evidence_ids))
|
| 223 |
-
)
|
| 224 |
-
|
| 225 |
-
evidence_by_claim = {
|
| 226 |
-
claim_id: tuple(
|
| 227 |
-
_evidence(item, f"evidence_by_claim.{claim_id}[{index}]")
|
| 228 |
-
for index, item in enumerate(
|
| 229 |
-
_list(evidence_map_raw.get(claim_id, []), f"evidence_by_claim.{claim_id}")
|
| 230 |
-
)
|
| 231 |
-
)
|
| 232 |
-
for claim_id in claim_ids
|
| 233 |
-
}
|
| 234 |
-
|
| 235 |
-
approvals_raw = _mapping(obj.get("human_approvals", {}), "payload.human_approvals")
|
| 236 |
-
unknown_approval_ids = set(approvals_raw) - claim_ids
|
| 237 |
-
if unknown_approval_ids:
|
| 238 |
-
raise ValueError(
|
| 239 |
-
"human_approvals contains unknown claim id: "
|
| 240 |
-
+ ", ".join(sorted(unknown_approval_ids))
|
| 241 |
-
)
|
| 242 |
-
approvals = {
|
| 243 |
-
claim_id: _bool(value, f"human_approvals.{claim_id}")
|
| 244 |
-
for claim_id, value in approvals_raw.items()
|
| 245 |
-
}
|
| 246 |
-
|
| 247 |
-
policy = _policy(_required(obj, "policy", "payload"))
|
| 248 |
-
evaluation_time = _datetime(
|
| 249 |
-
_required(obj, "evaluation_time", "payload"),
|
| 250 |
-
"payload.evaluation_time",
|
| 251 |
-
)
|
| 252 |
-
result = verify_compound(
|
| 253 |
-
claims,
|
| 254 |
-
evidence_by_claim,
|
| 255 |
-
policy=policy,
|
| 256 |
-
evaluation_time=evaluation_time,
|
| 257 |
-
human_approvals=approvals,
|
| 258 |
-
)
|
| 259 |
-
return {
|
| 260 |
-
"schema_version": SCHEMA_VERSION,
|
| 261 |
-
"algorithm_version": ALGORITHM_VERSION,
|
| 262 |
-
"verdict": result.verdict.value,
|
| 263 |
-
"results": [_result_dict(item) for item in result.results],
|
| 264 |
}
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from typing import Any, Mapping
|
| 5 |
+
|
| 6 |
+
from .engine import (
|
| 7 |
+
ALGORITHM_VERSION,
|
| 8 |
+
AnswerabilityStatus,
|
| 9 |
+
AuthorityScope,
|
| 10 |
+
Claim,
|
| 11 |
+
ClaimType,
|
| 12 |
+
Directness,
|
| 13 |
+
Evidence,
|
| 14 |
+
EvidenceOrigin,
|
| 15 |
+
Policy,
|
| 16 |
+
RiskLevel,
|
| 17 |
+
SourceRole,
|
| 18 |
+
verify_claim,
|
| 19 |
+
verify_compound,
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
SCHEMA_VERSION = 3
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _mapping(value: Any, path: str) -> Mapping[str, Any]:
|
| 26 |
+
if not isinstance(value, Mapping):
|
| 27 |
+
raise TypeError(f"{path} must be object")
|
| 28 |
+
return value
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _list(value: Any, path: str) -> list[Any]:
|
| 32 |
+
if not isinstance(value, list):
|
| 33 |
+
raise TypeError(f"{path} must be list")
|
| 34 |
+
return value
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _required(obj: Mapping[str, Any], key: str, path: str) -> Any:
|
| 38 |
+
if key not in obj:
|
| 39 |
+
raise ValueError(f"missing required field: {path}.{key}")
|
| 40 |
+
return obj[key]
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _string(value: Any, path: str) -> str:
|
| 44 |
+
if not isinstance(value, str) or not value:
|
| 45 |
+
raise TypeError(f"{path} must be non-empty string")
|
| 46 |
+
return value
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _optional_string(value: Any, path: str) -> str | None:
|
| 50 |
+
if value is None:
|
| 51 |
+
return None
|
| 52 |
+
return _string(value, path)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _bool(value: Any, path: str) -> bool:
|
| 56 |
+
if type(value) is not bool:
|
| 57 |
+
raise TypeError(f"{path} must be bool")
|
| 58 |
+
return value
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _int(value: Any, path: str) -> int:
|
| 62 |
+
if isinstance(value, bool) or not isinstance(value, int):
|
| 63 |
+
raise TypeError(f"{path} must be int")
|
| 64 |
+
return value
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _datetime(value: Any, path: str) -> datetime:
|
| 68 |
+
raw = _string(value, path)
|
| 69 |
+
if raw.endswith("Z"):
|
| 70 |
+
raw = raw[:-1] + "+00:00"
|
| 71 |
+
try:
|
| 72 |
+
parsed = datetime.fromisoformat(raw)
|
| 73 |
+
except ValueError as exc:
|
| 74 |
+
raise ValueError(f"{path} must be ISO 8601 datetime") from exc
|
| 75 |
+
if parsed.tzinfo is None or parsed.utcoffset() is None:
|
| 76 |
+
raise ValueError(f"{path} must be timezone-aware")
|
| 77 |
+
return parsed
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def _enum(enum_type, value: Any, path: str):
|
| 81 |
+
raw = _string(value, path)
|
| 82 |
+
try:
|
| 83 |
+
return enum_type(raw)
|
| 84 |
+
except ValueError as exc:
|
| 85 |
+
raise ValueError(f"invalid {path}: {raw}") from exc
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _claim(raw: Any, path: str = "claim") -> Claim:
|
| 89 |
+
obj = _mapping(raw, path)
|
| 90 |
+
return Claim(
|
| 91 |
+
claim_id=_string(_required(obj, "id", path), f"{path}.id"),
|
| 92 |
+
text=_string(_required(obj, "text", path), f"{path}.text"),
|
| 93 |
+
claim_type=_enum(ClaimType, _required(obj, "type", path), f"{path}.type"),
|
| 94 |
+
risk=_enum(RiskLevel, obj.get("risk", RiskLevel.LOW.value), f"{path}.risk"),
|
| 95 |
+
consequential=_bool(obj.get("consequential", True), f"{path}.consequential"),
|
| 96 |
+
authority_scope=_enum(
|
| 97 |
+
AuthorityScope,
|
| 98 |
+
obj.get("authority_scope", AuthorityScope.EXTERNAL_FACT.value),
|
| 99 |
+
f"{path}.authority_scope",
|
| 100 |
+
),
|
| 101 |
+
answerability=_enum(
|
| 102 |
+
AnswerabilityStatus,
|
| 103 |
+
obj.get("answerability", AnswerabilityStatus.ANSWERABLE.value),
|
| 104 |
+
f"{path}.answerability",
|
| 105 |
+
),
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def _evidence(raw: Any, path: str) -> Evidence:
|
| 110 |
+
obj = _mapping(raw, path)
|
| 111 |
+
return Evidence(
|
| 112 |
+
source_id=_string(_required(obj, "source_id", path), f"{path}.source_id"),
|
| 113 |
+
supports=_bool(_required(obj, "supports", path), f"{path}.supports"),
|
| 114 |
+
observed_at=_datetime(_required(obj, "observed_at", path), f"{path}.observed_at"),
|
| 115 |
+
directness=_enum(Directness, _required(obj, "directness", path), f"{path}.directness"),
|
| 116 |
+
post_action=_bool(obj.get("post_action", False), f"{path}.post_action"),
|
| 117 |
+
eligible=_bool(obj.get("eligible", True), f"{path}.eligible"),
|
| 118 |
+
evidence_id=_optional_string(obj.get("evidence_id"), f"{path}.evidence_id"),
|
| 119 |
+
source_role=_enum(
|
| 120 |
+
SourceRole,
|
| 121 |
+
obj.get("source_role", SourceRole.EXTERNAL.value),
|
| 122 |
+
f"{path}.source_role",
|
| 123 |
+
),
|
| 124 |
+
source_ref=_optional_string(obj.get("source_ref"), f"{path}.source_ref"),
|
| 125 |
+
independence_group=_optional_string(
|
| 126 |
+
obj.get("independence_group"),
|
| 127 |
+
f"{path}.independence_group",
|
| 128 |
+
),
|
| 129 |
+
content_sha256=_optional_string(
|
| 130 |
+
obj.get("content_sha256"),
|
| 131 |
+
f"{path}.content_sha256",
|
| 132 |
+
),
|
| 133 |
+
origin=_enum(
|
| 134 |
+
EvidenceOrigin,
|
| 135 |
+
obj.get("origin", EvidenceOrigin.EXTERNAL_SOURCE.value),
|
| 136 |
+
f"{path}.origin",
|
| 137 |
+
),
|
| 138 |
+
authority_scope=_enum(
|
| 139 |
+
AuthorityScope,
|
| 140 |
+
obj.get("authority_scope", AuthorityScope.EXTERNAL_FACT.value),
|
| 141 |
+
f"{path}.authority_scope",
|
| 142 |
+
),
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def _policy(raw: Any) -> Policy:
|
| 147 |
+
path = "policy"
|
| 148 |
+
obj = _mapping(raw, path)
|
| 149 |
+
return Policy(
|
| 150 |
+
version=_string(_required(obj, "version", path), "policy.version"),
|
| 151 |
+
max_age_seconds=_int(_required(obj, "max_age_seconds", path), "policy.max_age_seconds"),
|
| 152 |
+
reject_future_evidence=_bool(
|
| 153 |
+
obj.get("reject_future_evidence", True),
|
| 154 |
+
"policy.reject_future_evidence",
|
| 155 |
+
),
|
| 156 |
+
require_independent_action_evidence=_bool(
|
| 157 |
+
obj.get("require_independent_action_evidence", True),
|
| 158 |
+
"policy.require_independent_action_evidence",
|
| 159 |
+
),
|
| 160 |
+
medium_risk_min_independent_sources=_int(
|
| 161 |
+
obj.get("medium_risk_min_independent_sources", 2),
|
| 162 |
+
"policy.medium_risk_min_independent_sources",
|
| 163 |
+
),
|
| 164 |
+
require_runtime_provenance=_bool(
|
| 165 |
+
obj.get("require_runtime_provenance", False),
|
| 166 |
+
"policy.require_runtime_provenance",
|
| 167 |
+
),
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def _result_dict(result) -> dict[str, Any]:
|
| 172 |
+
return {
|
| 173 |
+
"schema_version": SCHEMA_VERSION,
|
| 174 |
+
"algorithm_version": ALGORITHM_VERSION,
|
| 175 |
+
"classification": result.classification.value,
|
| 176 |
+
"verdict": result.verdict.value,
|
| 177 |
+
"reason": result.reason,
|
| 178 |
+
"receipt": result.receipt,
|
| 179 |
+
"failure_record": result.failure_record,
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def verify_payload(payload: Mapping[str, Any]) -> dict[str, Any]:
|
| 184 |
+
obj = _mapping(payload, "payload")
|
| 185 |
+
claim = _claim(_required(obj, "claim", "payload"))
|
| 186 |
+
evidence_raw = _list(_required(obj, "evidence", "payload"), "payload.evidence")
|
| 187 |
+
evidence = tuple(
|
| 188 |
+
_evidence(item, f"evidence[{index}]")
|
| 189 |
+
for index, item in enumerate(evidence_raw)
|
| 190 |
+
)
|
| 191 |
+
policy = _policy(_required(obj, "policy", "payload"))
|
| 192 |
+
evaluation_time = _datetime(
|
| 193 |
+
_required(obj, "evaluation_time", "payload"),
|
| 194 |
+
"payload.evaluation_time",
|
| 195 |
+
)
|
| 196 |
+
human_approved = _bool(obj.get("human_approved", False), "payload.human_approved")
|
| 197 |
+
return _result_dict(
|
| 198 |
+
verify_claim(
|
| 199 |
+
claim,
|
| 200 |
+
evidence,
|
| 201 |
+
policy=policy,
|
| 202 |
+
evaluation_time=evaluation_time,
|
| 203 |
+
human_approved=human_approved,
|
| 204 |
+
)
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def verify_compound_payload(payload: Mapping[str, Any]) -> dict[str, Any]:
|
| 209 |
+
obj = _mapping(payload, "payload")
|
| 210 |
+
claims_raw = _list(_required(obj, "claims", "payload"), "payload.claims")
|
| 211 |
+
claims = tuple(_claim(item, f"claims[{index}]") for index, item in enumerate(claims_raw))
|
| 212 |
+
claim_ids = {claim.claim_id for claim in claims}
|
| 213 |
+
|
| 214 |
+
evidence_map_raw = _mapping(
|
| 215 |
+
_required(obj, "evidence_by_claim", "payload"),
|
| 216 |
+
"payload.evidence_by_claim",
|
| 217 |
+
)
|
| 218 |
+
unknown_evidence_ids = set(evidence_map_raw) - claim_ids
|
| 219 |
+
if unknown_evidence_ids:
|
| 220 |
+
raise ValueError(
|
| 221 |
+
"evidence_by_claim contains unknown claim id: "
|
| 222 |
+
+ ", ".join(sorted(unknown_evidence_ids))
|
| 223 |
+
)
|
| 224 |
+
|
| 225 |
+
evidence_by_claim = {
|
| 226 |
+
claim_id: tuple(
|
| 227 |
+
_evidence(item, f"evidence_by_claim.{claim_id}[{index}]")
|
| 228 |
+
for index, item in enumerate(
|
| 229 |
+
_list(evidence_map_raw.get(claim_id, []), f"evidence_by_claim.{claim_id}")
|
| 230 |
+
)
|
| 231 |
+
)
|
| 232 |
+
for claim_id in claim_ids
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
approvals_raw = _mapping(obj.get("human_approvals", {}), "payload.human_approvals")
|
| 236 |
+
unknown_approval_ids = set(approvals_raw) - claim_ids
|
| 237 |
+
if unknown_approval_ids:
|
| 238 |
+
raise ValueError(
|
| 239 |
+
"human_approvals contains unknown claim id: "
|
| 240 |
+
+ ", ".join(sorted(unknown_approval_ids))
|
| 241 |
+
)
|
| 242 |
+
approvals = {
|
| 243 |
+
claim_id: _bool(value, f"human_approvals.{claim_id}")
|
| 244 |
+
for claim_id, value in approvals_raw.items()
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
policy = _policy(_required(obj, "policy", "payload"))
|
| 248 |
+
evaluation_time = _datetime(
|
| 249 |
+
_required(obj, "evaluation_time", "payload"),
|
| 250 |
+
"payload.evaluation_time",
|
| 251 |
+
)
|
| 252 |
+
result = verify_compound(
|
| 253 |
+
claims,
|
| 254 |
+
evidence_by_claim,
|
| 255 |
+
policy=policy,
|
| 256 |
+
evaluation_time=evaluation_time,
|
| 257 |
+
human_approvals=approvals,
|
| 258 |
+
)
|
| 259 |
+
return {
|
| 260 |
+
"schema_version": SCHEMA_VERSION,
|
| 261 |
+
"algorithm_version": ALGORITHM_VERSION,
|
| 262 |
+
"verdict": result.verdict.value,
|
| 263 |
+
"results": [_result_dict(item) for item in result.results],
|
| 264 |
}
|
bodulskihal_core/conversation_gate.py
CHANGED
|
@@ -1,150 +1,150 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
import hashlib
|
| 4 |
-
import json
|
| 5 |
-
from dataclasses import dataclass
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
@dataclass(frozen=True)
|
| 9 |
-
class ConstraintRecord:
|
| 10 |
-
constraint_id: str
|
| 11 |
-
kind: str
|
| 12 |
-
value: str
|
| 13 |
-
active: bool = True
|
| 14 |
-
satisfied: bool = False
|
| 15 |
-
source_turn_id: str | None = None
|
| 16 |
-
priority: int = 0
|
| 17 |
-
supersedes: str | None = None
|
| 18 |
-
|
| 19 |
-
def __post_init__(self) -> None:
|
| 20 |
-
for field_name in ("constraint_id", "kind", "value"):
|
| 21 |
-
value = getattr(self, field_name)
|
| 22 |
-
if not isinstance(value, str) or not value.strip():
|
| 23 |
-
raise ValueError(f"{field_name} must be non-empty string")
|
| 24 |
-
if type(self.active) is not bool:
|
| 25 |
-
raise TypeError("constraint.active must be bool")
|
| 26 |
-
if type(self.satisfied) is not bool:
|
| 27 |
-
raise TypeError("constraint.satisfied must be bool")
|
| 28 |
-
if isinstance(self.priority, bool) or not isinstance(self.priority, int):
|
| 29 |
-
raise TypeError("constraint.priority must be int")
|
| 30 |
-
for field_name in ("source_turn_id", "supersedes"):
|
| 31 |
-
value = getattr(self, field_name)
|
| 32 |
-
if value is not None and (
|
| 33 |
-
not isinstance(value, str) or not value.strip()
|
| 34 |
-
):
|
| 35 |
-
raise ValueError(f"{field_name} must be non-empty when supplied")
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
@dataclass(frozen=True)
|
| 39 |
-
class ConversationContract:
|
| 40 |
-
goal_id: str
|
| 41 |
-
referent_id: str
|
| 42 |
-
candidate_goal_id: str
|
| 43 |
-
candidate_referent_id: str
|
| 44 |
-
constraints: tuple[ConstraintRecord, ...] = ()
|
| 45 |
-
|
| 46 |
-
def __post_init__(self) -> None:
|
| 47 |
-
for field_name in (
|
| 48 |
-
"goal_id",
|
| 49 |
-
"referent_id",
|
| 50 |
-
"candidate_goal_id",
|
| 51 |
-
"candidate_referent_id",
|
| 52 |
-
):
|
| 53 |
-
value = getattr(self, field_name)
|
| 54 |
-
if not isinstance(value, str) or not value.strip():
|
| 55 |
-
raise ValueError(f"{field_name} must be non-empty string")
|
| 56 |
-
if not isinstance(self.constraints, tuple):
|
| 57 |
-
raise TypeError("constraints must be tuple")
|
| 58 |
-
if any(not isinstance(item, ConstraintRecord) for item in self.constraints):
|
| 59 |
-
raise TypeError("constraints must contain ConstraintRecord values")
|
| 60 |
-
ids = [item.constraint_id for item in self.constraints]
|
| 61 |
-
if len(ids) != len(set(ids)):
|
| 62 |
-
raise ValueError("duplicate constraint_id")
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
@dataclass(frozen=True)
|
| 66 |
-
class ConversationGateResult:
|
| 67 |
-
approved: bool
|
| 68 |
-
reason: str
|
| 69 |
-
failed_constraint_ids: tuple[str, ...]
|
| 70 |
-
contract_sha256: str
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
def canonical_conversation_contract_sha256(contract: ConversationContract) -> str:
|
| 74 |
-
payload = {
|
| 75 |
-
"goal_id": contract.goal_id,
|
| 76 |
-
"referent_id": contract.referent_id,
|
| 77 |
-
"candidate_goal_id": contract.candidate_goal_id,
|
| 78 |
-
"candidate_referent_id": contract.candidate_referent_id,
|
| 79 |
-
"constraints": [
|
| 80 |
-
{
|
| 81 |
-
"constraint_id": item.constraint_id,
|
| 82 |
-
"kind": item.kind,
|
| 83 |
-
"value": item.value,
|
| 84 |
-
"active": item.active,
|
| 85 |
-
"satisfied": item.satisfied,
|
| 86 |
-
"source_turn_id": item.source_turn_id,
|
| 87 |
-
"priority": item.priority,
|
| 88 |
-
"supersedes": item.supersedes,
|
| 89 |
-
}
|
| 90 |
-
for item in sorted(
|
| 91 |
-
contract.constraints,
|
| 92 |
-
key=lambda value: (value.priority, value.constraint_id),
|
| 93 |
-
)
|
| 94 |
-
],
|
| 95 |
-
}
|
| 96 |
-
canonical = json.dumps(
|
| 97 |
-
payload,
|
| 98 |
-
sort_keys=True,
|
| 99 |
-
ensure_ascii=False,
|
| 100 |
-
separators=(",", ":"),
|
| 101 |
-
).encode("utf-8")
|
| 102 |
-
return hashlib.sha256(canonical).hexdigest()
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
def evaluate_conversation_contract(
|
| 106 |
-
contract: ConversationContract,
|
| 107 |
-
) -> ConversationGateResult:
|
| 108 |
-
if not isinstance(contract, ConversationContract):
|
| 109 |
-
raise TypeError("contract must be ConversationContract")
|
| 110 |
-
|
| 111 |
-
digest = canonical_conversation_contract_sha256(contract)
|
| 112 |
-
|
| 113 |
-
if contract.candidate_goal_id != contract.goal_id:
|
| 114 |
-
return ConversationGateResult(
|
| 115 |
-
approved=False,
|
| 116 |
-
reason="goal_mismatch",
|
| 117 |
-
failed_constraint_ids=(),
|
| 118 |
-
contract_sha256=digest,
|
| 119 |
-
)
|
| 120 |
-
|
| 121 |
-
if contract.candidate_referent_id != contract.referent_id:
|
| 122 |
-
return ConversationGateResult(
|
| 123 |
-
approved=False,
|
| 124 |
-
reason="referent_mismatch",
|
| 125 |
-
failed_constraint_ids=(),
|
| 126 |
-
contract_sha256=digest,
|
| 127 |
-
)
|
| 128 |
-
|
| 129 |
-
failed = tuple(
|
| 130 |
-
item.constraint_id
|
| 131 |
-
for item in sorted(
|
| 132 |
-
contract.constraints,
|
| 133 |
-
key=lambda value: (-value.priority, value.constraint_id),
|
| 134 |
-
)
|
| 135 |
-
if item.active and not item.satisfied
|
| 136 |
-
)
|
| 137 |
-
if failed:
|
| 138 |
-
return ConversationGateResult(
|
| 139 |
-
approved=False,
|
| 140 |
-
reason="active_constraint_failed",
|
| 141 |
-
failed_constraint_ids=failed,
|
| 142 |
-
contract_sha256=digest,
|
| 143 |
-
)
|
| 144 |
-
|
| 145 |
-
return ConversationGateResult(
|
| 146 |
-
approved=True,
|
| 147 |
-
reason="conversation_contract_satisfied",
|
| 148 |
-
failed_constraint_ids=(),
|
| 149 |
-
contract_sha256=digest,
|
| 150 |
)
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import hashlib
|
| 4 |
+
import json
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
@dataclass(frozen=True)
|
| 9 |
+
class ConstraintRecord:
|
| 10 |
+
constraint_id: str
|
| 11 |
+
kind: str
|
| 12 |
+
value: str
|
| 13 |
+
active: bool = True
|
| 14 |
+
satisfied: bool = False
|
| 15 |
+
source_turn_id: str | None = None
|
| 16 |
+
priority: int = 0
|
| 17 |
+
supersedes: str | None = None
|
| 18 |
+
|
| 19 |
+
def __post_init__(self) -> None:
|
| 20 |
+
for field_name in ("constraint_id", "kind", "value"):
|
| 21 |
+
value = getattr(self, field_name)
|
| 22 |
+
if not isinstance(value, str) or not value.strip():
|
| 23 |
+
raise ValueError(f"{field_name} must be non-empty string")
|
| 24 |
+
if type(self.active) is not bool:
|
| 25 |
+
raise TypeError("constraint.active must be bool")
|
| 26 |
+
if type(self.satisfied) is not bool:
|
| 27 |
+
raise TypeError("constraint.satisfied must be bool")
|
| 28 |
+
if isinstance(self.priority, bool) or not isinstance(self.priority, int):
|
| 29 |
+
raise TypeError("constraint.priority must be int")
|
| 30 |
+
for field_name in ("source_turn_id", "supersedes"):
|
| 31 |
+
value = getattr(self, field_name)
|
| 32 |
+
if value is not None and (
|
| 33 |
+
not isinstance(value, str) or not value.strip()
|
| 34 |
+
):
|
| 35 |
+
raise ValueError(f"{field_name} must be non-empty when supplied")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@dataclass(frozen=True)
|
| 39 |
+
class ConversationContract:
|
| 40 |
+
goal_id: str
|
| 41 |
+
referent_id: str
|
| 42 |
+
candidate_goal_id: str
|
| 43 |
+
candidate_referent_id: str
|
| 44 |
+
constraints: tuple[ConstraintRecord, ...] = ()
|
| 45 |
+
|
| 46 |
+
def __post_init__(self) -> None:
|
| 47 |
+
for field_name in (
|
| 48 |
+
"goal_id",
|
| 49 |
+
"referent_id",
|
| 50 |
+
"candidate_goal_id",
|
| 51 |
+
"candidate_referent_id",
|
| 52 |
+
):
|
| 53 |
+
value = getattr(self, field_name)
|
| 54 |
+
if not isinstance(value, str) or not value.strip():
|
| 55 |
+
raise ValueError(f"{field_name} must be non-empty string")
|
| 56 |
+
if not isinstance(self.constraints, tuple):
|
| 57 |
+
raise TypeError("constraints must be tuple")
|
| 58 |
+
if any(not isinstance(item, ConstraintRecord) for item in self.constraints):
|
| 59 |
+
raise TypeError("constraints must contain ConstraintRecord values")
|
| 60 |
+
ids = [item.constraint_id for item in self.constraints]
|
| 61 |
+
if len(ids) != len(set(ids)):
|
| 62 |
+
raise ValueError("duplicate constraint_id")
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
@dataclass(frozen=True)
|
| 66 |
+
class ConversationGateResult:
|
| 67 |
+
approved: bool
|
| 68 |
+
reason: str
|
| 69 |
+
failed_constraint_ids: tuple[str, ...]
|
| 70 |
+
contract_sha256: str
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def canonical_conversation_contract_sha256(contract: ConversationContract) -> str:
|
| 74 |
+
payload = {
|
| 75 |
+
"goal_id": contract.goal_id,
|
| 76 |
+
"referent_id": contract.referent_id,
|
| 77 |
+
"candidate_goal_id": contract.candidate_goal_id,
|
| 78 |
+
"candidate_referent_id": contract.candidate_referent_id,
|
| 79 |
+
"constraints": [
|
| 80 |
+
{
|
| 81 |
+
"constraint_id": item.constraint_id,
|
| 82 |
+
"kind": item.kind,
|
| 83 |
+
"value": item.value,
|
| 84 |
+
"active": item.active,
|
| 85 |
+
"satisfied": item.satisfied,
|
| 86 |
+
"source_turn_id": item.source_turn_id,
|
| 87 |
+
"priority": item.priority,
|
| 88 |
+
"supersedes": item.supersedes,
|
| 89 |
+
}
|
| 90 |
+
for item in sorted(
|
| 91 |
+
contract.constraints,
|
| 92 |
+
key=lambda value: (value.priority, value.constraint_id),
|
| 93 |
+
)
|
| 94 |
+
],
|
| 95 |
+
}
|
| 96 |
+
canonical = json.dumps(
|
| 97 |
+
payload,
|
| 98 |
+
sort_keys=True,
|
| 99 |
+
ensure_ascii=False,
|
| 100 |
+
separators=(",", ":"),
|
| 101 |
+
).encode("utf-8")
|
| 102 |
+
return hashlib.sha256(canonical).hexdigest()
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def evaluate_conversation_contract(
|
| 106 |
+
contract: ConversationContract,
|
| 107 |
+
) -> ConversationGateResult:
|
| 108 |
+
if not isinstance(contract, ConversationContract):
|
| 109 |
+
raise TypeError("contract must be ConversationContract")
|
| 110 |
+
|
| 111 |
+
digest = canonical_conversation_contract_sha256(contract)
|
| 112 |
+
|
| 113 |
+
if contract.candidate_goal_id != contract.goal_id:
|
| 114 |
+
return ConversationGateResult(
|
| 115 |
+
approved=False,
|
| 116 |
+
reason="goal_mismatch",
|
| 117 |
+
failed_constraint_ids=(),
|
| 118 |
+
contract_sha256=digest,
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
if contract.candidate_referent_id != contract.referent_id:
|
| 122 |
+
return ConversationGateResult(
|
| 123 |
+
approved=False,
|
| 124 |
+
reason="referent_mismatch",
|
| 125 |
+
failed_constraint_ids=(),
|
| 126 |
+
contract_sha256=digest,
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
failed = tuple(
|
| 130 |
+
item.constraint_id
|
| 131 |
+
for item in sorted(
|
| 132 |
+
contract.constraints,
|
| 133 |
+
key=lambda value: (-value.priority, value.constraint_id),
|
| 134 |
+
)
|
| 135 |
+
if item.active and not item.satisfied
|
| 136 |
+
)
|
| 137 |
+
if failed:
|
| 138 |
+
return ConversationGateResult(
|
| 139 |
+
approved=False,
|
| 140 |
+
reason="active_constraint_failed",
|
| 141 |
+
failed_constraint_ids=failed,
|
| 142 |
+
contract_sha256=digest,
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
return ConversationGateResult(
|
| 146 |
+
approved=True,
|
| 147 |
+
reason="conversation_contract_satisfied",
|
| 148 |
+
failed_constraint_ids=(),
|
| 149 |
+
contract_sha256=digest,
|
| 150 |
)
|
bodulskihal_core/engine.py
CHANGED
|
@@ -1,689 +1,689 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
import hashlib
|
| 4 |
-
import json
|
| 5 |
-
from dataclasses import dataclass
|
| 6 |
-
from datetime import datetime
|
| 7 |
-
from enum import Enum
|
| 8 |
-
from typing import Iterable, Mapping, Sequence
|
| 9 |
-
|
| 10 |
-
from .runtime_provenance import RuntimeProvenance, current_runtime_provenance
|
| 11 |
-
|
| 12 |
-
ALGORITHM_VERSION = "2.1.2"
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
class ClaimType(str, Enum):
|
| 16 |
-
UNCLASSIFIED = "unclassified"
|
| 17 |
-
OBSERVATION = "observation"
|
| 18 |
-
INFERENCE = "inference"
|
| 19 |
-
ESTIMATE = "estimate"
|
| 20 |
-
ACTION_STATE = "action_state"
|
| 21 |
-
RISK_SENSITIVE = "risk_sensitive"
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
class Classification(str, Enum):
|
| 25 |
-
FACT = "FACT"
|
| 26 |
-
INFERENCE = "INFERENCE"
|
| 27 |
-
ESTIMATE = "ESTIMATE"
|
| 28 |
-
UNKNOWN = "UNKNOWN"
|
| 29 |
-
REFUTED = "REFUTED"
|
| 30 |
-
CONFLICT = "CONFLICT"
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
class Verdict(str, Enum):
|
| 34 |
-
APPROVE = "APPROVE"
|
| 35 |
-
BLOCK = "BLOCK"
|
| 36 |
-
ESCALATE = "ESCALATE"
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
class RiskLevel(str, Enum):
|
| 40 |
-
LOW = "low"
|
| 41 |
-
MEDIUM = "medium"
|
| 42 |
-
HIGH = "high"
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
class Directness(str, Enum):
|
| 46 |
-
DIRECT = "direct"
|
| 47 |
-
INDIRECT = "indirect"
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
class SourceRole(str, Enum):
|
| 51 |
-
EXECUTOR = "executor"
|
| 52 |
-
VERIFIER = "verifier"
|
| 53 |
-
EXTERNAL = "external"
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
class EvidenceOrigin(str, Enum):
|
| 57 |
-
EXTERNAL_SOURCE = "external_source"
|
| 58 |
-
TOOL_OBSERVATION = "tool_observation"
|
| 59 |
-
COMPUTED_RESULT = "computed_result"
|
| 60 |
-
MEMORY = "memory"
|
| 61 |
-
USER_ASSERTION = "user_assertion"
|
| 62 |
-
USER_SELF_REPORT = "user_self_report"
|
| 63 |
-
USER_PREFERENCE = "user_preference"
|
| 64 |
-
USER_INSTRUCTION = "user_instruction"
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
class AuthorityScope(str, Enum):
|
| 68 |
-
EXTERNAL_FACT = "external_fact"
|
| 69 |
-
SYSTEM_STATE = "system_state"
|
| 70 |
-
USER_SELF_REPORT = "user_self_report"
|
| 71 |
-
USER_PREFERENCE = "user_preference"
|
| 72 |
-
USER_INSTRUCTION = "user_instruction"
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
class AnswerabilityStatus(str, Enum):
|
| 76 |
-
ANSWERABLE = "answerable"
|
| 77 |
-
PARTIALLY_ANSWERABLE = "partially_answerable"
|
| 78 |
-
UNDERSPECIFIED = "underspecified"
|
| 79 |
-
FALSE_PREMISE = "false_premise"
|
| 80 |
-
CONTRADICTORY_INPUT = "contradictory_input"
|
| 81 |
-
NEEDS_CLARIFICATION = "needs_clarification"
|
| 82 |
-
OUT_OF_SCOPE = "out_of_scope"
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
@dataclass(frozen=True)
|
| 86 |
-
class Claim:
|
| 87 |
-
claim_id: str
|
| 88 |
-
text: str
|
| 89 |
-
claim_type: ClaimType
|
| 90 |
-
risk: RiskLevel = RiskLevel.LOW
|
| 91 |
-
consequential: bool = True
|
| 92 |
-
authority_scope: AuthorityScope = AuthorityScope.EXTERNAL_FACT
|
| 93 |
-
answerability: AnswerabilityStatus = AnswerabilityStatus.ANSWERABLE
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
@dataclass(frozen=True)
|
| 97 |
-
class Evidence:
|
| 98 |
-
source_id: str
|
| 99 |
-
supports: bool
|
| 100 |
-
observed_at: datetime
|
| 101 |
-
directness: Directness
|
| 102 |
-
post_action: bool = False
|
| 103 |
-
eligible: bool = True
|
| 104 |
-
evidence_id: str | None = None
|
| 105 |
-
source_role: SourceRole = SourceRole.EXTERNAL
|
| 106 |
-
source_ref: str | None = None
|
| 107 |
-
independence_group: str | None = None
|
| 108 |
-
content_sha256: str | None = None
|
| 109 |
-
origin: EvidenceOrigin = EvidenceOrigin.EXTERNAL_SOURCE
|
| 110 |
-
authority_scope: AuthorityScope = AuthorityScope.EXTERNAL_FACT
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
@dataclass(frozen=True)
|
| 114 |
-
class Policy:
|
| 115 |
-
version: str
|
| 116 |
-
max_age_seconds: int
|
| 117 |
-
reject_future_evidence: bool = True
|
| 118 |
-
require_independent_action_evidence: bool = True
|
| 119 |
-
medium_risk_min_independent_sources: int = 2
|
| 120 |
-
require_runtime_provenance: bool = False
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
@dataclass(frozen=True)
|
| 124 |
-
class VerificationResult:
|
| 125 |
-
claim: Claim
|
| 126 |
-
classification: Classification
|
| 127 |
-
verdict: Verdict
|
| 128 |
-
reason: str
|
| 129 |
-
receipt: dict
|
| 130 |
-
failure_record: dict | None
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
@dataclass(frozen=True)
|
| 134 |
-
class CompoundResult:
|
| 135 |
-
results: tuple[VerificationResult, ...]
|
| 136 |
-
verdict: Verdict
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
def _is_aware(value: datetime) -> bool:
|
| 140 |
-
return value.tzinfo is not None and value.utcoffset() is not None
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
def _effective_evidence_id(item: Evidence) -> str:
|
| 144 |
-
return item.evidence_id or item.source_id
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
def _validate_human_approval(value: bool, *, field_name: str = "human_approved") -> None:
|
| 148 |
-
if type(value) is not bool:
|
| 149 |
-
raise TypeError(f"{field_name} must be bool")
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
def _validate_non_empty_string(value: object, field_name: str) -> None:
|
| 153 |
-
if not isinstance(value, str) or not value.strip():
|
| 154 |
-
raise ValueError(f"{field_name} must be non-empty string")
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
def _validate_provenance_inputs(
|
| 158 |
-
claim: Claim, evidence: Sequence[Evidence], policy: Policy
|
| 159 |
-
) -> None:
|
| 160 |
-
_validate_non_empty_string(claim.claim_id, "claim_id")
|
| 161 |
-
_validate_non_empty_string(claim.text, "claim.text")
|
| 162 |
-
_validate_non_empty_string(policy.version, "policy.version")
|
| 163 |
-
if not isinstance(claim.authority_scope, AuthorityScope):
|
| 164 |
-
raise TypeError("claim.authority_scope must be AuthorityScope")
|
| 165 |
-
if not isinstance(claim.answerability, AnswerabilityStatus):
|
| 166 |
-
raise TypeError("claim.answerability must be AnswerabilityStatus")
|
| 167 |
-
for item in evidence:
|
| 168 |
-
_validate_non_empty_string(item.source_id, "evidence.source_id")
|
| 169 |
-
if not isinstance(item.origin, EvidenceOrigin):
|
| 170 |
-
raise TypeError("evidence.origin must be EvidenceOrigin")
|
| 171 |
-
if not isinstance(item.authority_scope, AuthorityScope):
|
| 172 |
-
raise TypeError("evidence.authority_scope must be AuthorityScope")
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
def _validate_common_inputs(
|
| 177 |
-
evidence: Sequence[Evidence], *, policy: Policy, evaluation_time: datetime
|
| 178 |
-
) -> None:
|
| 179 |
-
if not _is_aware(evaluation_time):
|
| 180 |
-
raise ValueError("evaluation_time must be timezone-aware")
|
| 181 |
-
if policy.max_age_seconds < 0:
|
| 182 |
-
raise ValueError("max_age_seconds must be non-negative")
|
| 183 |
-
if policy.medium_risk_min_independent_sources < 1:
|
| 184 |
-
raise ValueError("medium_risk_min_independent_sources must be at least 1")
|
| 185 |
-
if type(policy.require_runtime_provenance) is not bool:
|
| 186 |
-
raise TypeError("require_runtime_provenance must be bool")
|
| 187 |
-
for item in evidence:
|
| 188 |
-
if not _is_aware(item.observed_at):
|
| 189 |
-
raise ValueError("observed_at must be timezone-aware")
|
| 190 |
-
if item.evidence_id is not None and not item.evidence_id.strip():
|
| 191 |
-
raise ValueError("evidence_id must be non-empty when supplied")
|
| 192 |
-
if item.source_ref is not None and not item.source_ref.strip():
|
| 193 |
-
raise ValueError("source_ref must be non-empty when supplied")
|
| 194 |
-
if item.independence_group is not None and not item.independence_group.strip():
|
| 195 |
-
raise ValueError("independence_group must be non-empty when supplied")
|
| 196 |
-
if item.content_sha256 is not None:
|
| 197 |
-
digest = item.content_sha256.lower()
|
| 198 |
-
if len(digest) != 64 or any(ch not in "0123456789abcdef" for ch in digest):
|
| 199 |
-
raise ValueError("content_sha256 must be a 64-character hexadecimal SHA-256")
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
def _validate_evidence_gate_types(evidence: Sequence[Evidence]) -> None:
|
| 203 |
-
for item in evidence:
|
| 204 |
-
if type(item.supports) is not bool:
|
| 205 |
-
raise TypeError("evidence.supports must be bool")
|
| 206 |
-
if type(item.post_action) is not bool:
|
| 207 |
-
raise TypeError("evidence.post_action must be bool")
|
| 208 |
-
if type(item.eligible) is not bool:
|
| 209 |
-
raise TypeError("evidence.eligible must be bool")
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
def _validate_evidence_identity(evidence: Sequence[Evidence]) -> None:
|
| 213 |
-
ids = [_effective_evidence_id(item) for item in evidence]
|
| 214 |
-
if len(ids) != len(set(ids)):
|
| 215 |
-
raise ValueError(
|
| 216 |
-
"duplicate evidence_id; duplicate source_id fallback identity is not allowed"
|
| 217 |
-
)
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
def _is_fresh(item: Evidence, *, policy: Policy, evaluation_time: datetime) -> bool:
|
| 221 |
-
age = (evaluation_time - item.observed_at).total_seconds()
|
| 222 |
-
if policy.reject_future_evidence and age < 0:
|
| 223 |
-
return False
|
| 224 |
-
return 0 <= age <= policy.max_age_seconds
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
def _eligible_evidence(
|
| 228 |
-
evidence: Sequence[Evidence], *, policy: Policy, evaluation_time: datetime
|
| 229 |
-
) -> tuple[Evidence, ...]:
|
| 230 |
-
return tuple(
|
| 231 |
-
item
|
| 232 |
-
for item in evidence
|
| 233 |
-
if item.eligible and _is_fresh(item, policy=policy, evaluation_time=evaluation_time)
|
| 234 |
-
)
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
_USER_ORIGINS = {
|
| 238 |
-
EvidenceOrigin.USER_ASSERTION,
|
| 239 |
-
EvidenceOrigin.USER_SELF_REPORT,
|
| 240 |
-
EvidenceOrigin.USER_PREFERENCE,
|
| 241 |
-
EvidenceOrigin.USER_INSTRUCTION,
|
| 242 |
-
}
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
def _authority_allows(claim: Claim, item: Evidence) -> bool:
|
| 246 |
-
if item.authority_scope is not claim.authority_scope:
|
| 247 |
-
return False
|
| 248 |
-
|
| 249 |
-
if claim.authority_scope in {AuthorityScope.EXTERNAL_FACT, AuthorityScope.SYSTEM_STATE}:
|
| 250 |
-
return item.origin in {
|
| 251 |
-
EvidenceOrigin.EXTERNAL_SOURCE,
|
| 252 |
-
EvidenceOrigin.TOOL_OBSERVATION,
|
| 253 |
-
EvidenceOrigin.COMPUTED_RESULT,
|
| 254 |
-
}
|
| 255 |
-
if claim.authority_scope is AuthorityScope.USER_PREFERENCE:
|
| 256 |
-
return item.origin in {
|
| 257 |
-
EvidenceOrigin.USER_PREFERENCE,
|
| 258 |
-
EvidenceOrigin.USER_SELF_REPORT,
|
| 259 |
-
EvidenceOrigin.TOOL_OBSERVATION,
|
| 260 |
-
}
|
| 261 |
-
if claim.authority_scope is AuthorityScope.USER_INSTRUCTION:
|
| 262 |
-
return item.origin in {
|
| 263 |
-
EvidenceOrigin.USER_INSTRUCTION,
|
| 264 |
-
EvidenceOrigin.TOOL_OBSERVATION,
|
| 265 |
-
}
|
| 266 |
-
if claim.authority_scope is AuthorityScope.USER_SELF_REPORT:
|
| 267 |
-
return item.origin in {
|
| 268 |
-
EvidenceOrigin.USER_SELF_REPORT,
|
| 269 |
-
EvidenceOrigin.TOOL_OBSERVATION,
|
| 270 |
-
}
|
| 271 |
-
return False
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
def _authorized_evidence(
|
| 275 |
-
claim: Claim, evidence: Sequence[Evidence]
|
| 276 |
-
) -> tuple[Evidence, ...]:
|
| 277 |
-
return tuple(item for item in evidence if _authority_allows(claim, item))
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
def _classify(claim: Claim, eligible: Sequence[Evidence]) -> Classification:
|
| 281 |
-
if claim.answerability is AnswerabilityStatus.CONTRADICTORY_INPUT:
|
| 282 |
-
return Classification.CONFLICT
|
| 283 |
-
if claim.answerability is not AnswerabilityStatus.ANSWERABLE:
|
| 284 |
-
return Classification.UNKNOWN
|
| 285 |
-
if claim.claim_type is ClaimType.UNCLASSIFIED:
|
| 286 |
-
return Classification.UNKNOWN
|
| 287 |
-
|
| 288 |
-
supporting = tuple(item for item in eligible if item.supports)
|
| 289 |
-
contradicting = tuple(item for item in eligible if not item.supports)
|
| 290 |
-
if supporting and contradicting:
|
| 291 |
-
return Classification.CONFLICT
|
| 292 |
-
if contradicting:
|
| 293 |
-
return Classification.REFUTED
|
| 294 |
-
if not supporting:
|
| 295 |
-
return Classification.UNKNOWN
|
| 296 |
-
|
| 297 |
-
if claim.claim_type is ClaimType.INFERENCE:
|
| 298 |
-
return Classification.INFERENCE
|
| 299 |
-
if claim.claim_type is ClaimType.ESTIMATE:
|
| 300 |
-
return Classification.ESTIMATE
|
| 301 |
-
|
| 302 |
-
if not any(item.directness is Directness.DIRECT for item in supporting):
|
| 303 |
-
return Classification.INFERENCE
|
| 304 |
-
|
| 305 |
-
return Classification.FACT
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
def _has_direct_post_action_evidence(eligible: Sequence[Evidence]) -> bool:
|
| 309 |
-
return any(
|
| 310 |
-
item.supports
|
| 311 |
-
and item.post_action
|
| 312 |
-
and item.directness is Directness.DIRECT
|
| 313 |
-
for item in eligible
|
| 314 |
-
)
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
def _has_independent_post_action_evidence(eligible: Sequence[Evidence]) -> bool:
|
| 318 |
-
return any(
|
| 319 |
-
item.supports
|
| 320 |
-
and item.post_action
|
| 321 |
-
and item.directness is Directness.DIRECT
|
| 322 |
-
and item.source_role in {SourceRole.VERIFIER, SourceRole.EXTERNAL}
|
| 323 |
-
for item in eligible
|
| 324 |
-
)
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
def _independent_supporting_source_count(eligible: Sequence[Evidence]) -> int:
|
| 328 |
-
# Extra metadata may join known identities, never split the same provenance.
|
| 329 |
-
# Connected components preserve overlap transitively, regardless of input order.
|
| 330 |
-
parents: list[int] = []
|
| 331 |
-
seen: dict[tuple[str, str], int] = {}
|
| 332 |
-
|
| 333 |
-
def root(index: int) -> int:
|
| 334 |
-
while parents[index] != index:
|
| 335 |
-
parents[index] = parents[parents[index]]
|
| 336 |
-
index = parents[index]
|
| 337 |
-
return index
|
| 338 |
-
|
| 339 |
-
for item in eligible:
|
| 340 |
-
if not (
|
| 341 |
-
item.supports
|
| 342 |
-
and item.directness is Directness.DIRECT
|
| 343 |
-
and item.source_role in {SourceRole.VERIFIER, SourceRole.EXTERNAL}
|
| 344 |
-
):
|
| 345 |
-
continue
|
| 346 |
-
index = len(parents)
|
| 347 |
-
parents.append(index)
|
| 348 |
-
identities = [("source", item.source_id.strip())]
|
| 349 |
-
if item.source_ref is not None:
|
| 350 |
-
identities.append(("ref", item.source_ref.strip()))
|
| 351 |
-
if item.independence_group is not None:
|
| 352 |
-
identities.append(("group", item.independence_group.strip()))
|
| 353 |
-
if item.content_sha256 is not None:
|
| 354 |
-
identities.append(("content", item.content_sha256.lower()))
|
| 355 |
-
for identity in identities:
|
| 356 |
-
previous = seen.setdefault(identity, index)
|
| 357 |
-
parents[root(index)] = root(previous)
|
| 358 |
-
|
| 359 |
-
return len({root(index) for index in range(len(parents))})
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
def _decide_verdict(
|
| 363 |
-
claim: Claim,
|
| 364 |
-
classification: Classification,
|
| 365 |
-
eligible: Sequence[Evidence],
|
| 366 |
-
*,
|
| 367 |
-
policy: Policy,
|
| 368 |
-
human_approved: bool,
|
| 369 |
-
authority_rejected: bool = False,
|
| 370 |
-
runtime_provenance_verified: bool = False,
|
| 371 |
-
) -> tuple[Verdict, str]:
|
| 372 |
-
if policy.require_runtime_provenance and not runtime_provenance_verified:
|
| 373 |
-
return Verdict.BLOCK, "runtime_provenance_unverified"
|
| 374 |
-
|
| 375 |
-
if claim.answerability is AnswerabilityStatus.NEEDS_CLARIFICATION:
|
| 376 |
-
return Verdict.ESCALATE, "answerability_needs_clarification"
|
| 377 |
-
if claim.answerability is not AnswerabilityStatus.ANSWERABLE:
|
| 378 |
-
return Verdict.BLOCK, f"answerability_{claim.answerability.value}"
|
| 379 |
-
if claim.claim_type is ClaimType.UNCLASSIFIED:
|
| 380 |
-
return Verdict.BLOCK, "unclassified_claim"
|
| 381 |
-
|
| 382 |
-
if classification is Classification.CONFLICT:
|
| 383 |
-
return Verdict.BLOCK, "eligible_contradiction"
|
| 384 |
-
if classification is Classification.REFUTED:
|
| 385 |
-
return Verdict.BLOCK, "claim_refuted_by_eligible_evidence"
|
| 386 |
-
if classification is Classification.UNKNOWN:
|
| 387 |
-
if authority_rejected:
|
| 388 |
-
return Verdict.BLOCK, "insufficient_authorized_evidence"
|
| 389 |
-
return Verdict.BLOCK, "insufficient_eligible_evidence"
|
| 390 |
-
if classification is Classification.INFERENCE:
|
| 391 |
-
return Verdict.BLOCK, "inference_not_fact"
|
| 392 |
-
if classification is Classification.ESTIMATE:
|
| 393 |
-
return Verdict.BLOCK, "estimate_not_fact"
|
| 394 |
-
|
| 395 |
-
if claim.claim_type is ClaimType.ACTION_STATE:
|
| 396 |
-
if not _has_direct_post_action_evidence(eligible):
|
| 397 |
-
return Verdict.BLOCK, "missing_post_action_final_state_evidence"
|
| 398 |
-
if (
|
| 399 |
-
policy.require_independent_action_evidence
|
| 400 |
-
and not _has_independent_post_action_evidence(eligible)
|
| 401 |
-
):
|
| 402 |
-
return Verdict.BLOCK, "missing_independent_post_action_evidence"
|
| 403 |
-
|
| 404 |
-
if (
|
| 405 |
-
claim.risk is RiskLevel.MEDIUM
|
| 406 |
-
and _independent_supporting_source_count(eligible)
|
| 407 |
-
< policy.medium_risk_min_independent_sources
|
| 408 |
-
):
|
| 409 |
-
return Verdict.ESCALATE, "additional_independent_evidence_required"
|
| 410 |
-
|
| 411 |
-
if claim.risk is RiskLevel.HIGH and not human_approved:
|
| 412 |
-
return Verdict.ESCALATE, "human_approval_required"
|
| 413 |
-
|
| 414 |
-
return Verdict.APPROVE, "fact_with_required_gates"
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
def _iso(value: datetime) -> str:
|
| 418 |
-
return value.isoformat()
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
def _make_failure_record(claim: Claim, verdict: Verdict, reason: str) -> dict | None:
|
| 422 |
-
if verdict is Verdict.APPROVE:
|
| 423 |
-
return None
|
| 424 |
-
return {
|
| 425 |
-
"record_type": "expected_gate",
|
| 426 |
-
"claim_id": claim.claim_id,
|
| 427 |
-
"failure_mode": reason,
|
| 428 |
-
"root_cause": "verification_gate_not_satisfied",
|
| 429 |
-
"guardrail": f"preserve_{verdict.value.lower()}_until_new_eligible_evidence_or_required_approval",
|
| 430 |
-
"regression_test_required": False,
|
| 431 |
-
"version_change_required": False,
|
| 432 |
-
}
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
def make_failure_evolution_record(
|
| 436 |
-
*,
|
| 437 |
-
failure_mode: str,
|
| 438 |
-
root_cause: str,
|
| 439 |
-
guardrail: str,
|
| 440 |
-
regression_test: str,
|
| 441 |
-
) -> dict:
|
| 442 |
-
fields = {
|
| 443 |
-
"failure_mode": failure_mode,
|
| 444 |
-
"root_cause": root_cause,
|
| 445 |
-
"guardrail": guardrail,
|
| 446 |
-
"regression_test": regression_test,
|
| 447 |
-
}
|
| 448 |
-
for name, value in fields.items():
|
| 449 |
-
if not isinstance(value, str) or not value.strip():
|
| 450 |
-
raise ValueError(f"{name} must be non-empty")
|
| 451 |
-
return {
|
| 452 |
-
"record_type": "failure_evolution",
|
| 453 |
-
"failure_mode": failure_mode.strip(),
|
| 454 |
-
"root_cause": root_cause.strip(),
|
| 455 |
-
"guardrail": guardrail.strip(),
|
| 456 |
-
"regression_test": regression_test.strip(),
|
| 457 |
-
"regression_test_required": True,
|
| 458 |
-
"version_change_required": True,
|
| 459 |
-
}
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
def canonical_receipt_sha256(receipt: Mapping) -> str:
|
| 463 |
-
payload = {
|
| 464 |
-
key: value
|
| 465 |
-
for key, value in receipt.items()
|
| 466 |
-
if key != "receipt_sha256"
|
| 467 |
-
}
|
| 468 |
-
canonical = json.dumps(
|
| 469 |
-
payload,
|
| 470 |
-
sort_keys=True,
|
| 471 |
-
separators=(",", ":"),
|
| 472 |
-
ensure_ascii=False,
|
| 473 |
-
).encode("utf-8")
|
| 474 |
-
return hashlib.sha256(canonical).hexdigest()
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
def _make_receipt(
|
| 478 |
-
*,
|
| 479 |
-
claim: Claim,
|
| 480 |
-
policy: Policy,
|
| 481 |
-
evaluation_time: datetime,
|
| 482 |
-
all_evidence: Sequence[Evidence],
|
| 483 |
-
eligible: Sequence[Evidence],
|
| 484 |
-
classification: Classification,
|
| 485 |
-
verdict: Verdict,
|
| 486 |
-
reason: str,
|
| 487 |
-
human_approved: bool,
|
| 488 |
-
authority_rejected: Sequence[Evidence] = (),
|
| 489 |
-
runtime_provenance: RuntimeProvenance | None = None,
|
| 490 |
-
) -> dict:
|
| 491 |
-
eligible_ids = {_effective_evidence_id(item) for item in eligible}
|
| 492 |
-
sorted_all = sorted(
|
| 493 |
-
all_evidence,
|
| 494 |
-
key=lambda item: (_effective_evidence_id(item), item.source_id),
|
| 495 |
-
)
|
| 496 |
-
|
| 497 |
-
supporting_sources = sorted(item.source_id for item in eligible if item.supports)
|
| 498 |
-
contradicting_sources = sorted(item.source_id for item in eligible if not item.supports)
|
| 499 |
-
supporting_evidence_ids = sorted(
|
| 500 |
-
_effective_evidence_id(item) for item in eligible if item.supports
|
| 501 |
-
)
|
| 502 |
-
contradicting_evidence_ids = sorted(
|
| 503 |
-
_effective_evidence_id(item) for item in eligible if not item.supports
|
| 504 |
-
)
|
| 505 |
-
if claim.risk is RiskLevel.HIGH:
|
| 506 |
-
human_gate = "approved" if human_approved else "required"
|
| 507 |
-
else:
|
| 508 |
-
human_gate = "not_required"
|
| 509 |
-
|
| 510 |
-
receipt = {
|
| 511 |
-
"algorithm_version": ALGORITHM_VERSION,
|
| 512 |
-
"policy_version": policy.version,
|
| 513 |
-
"evaluation_time": _iso(evaluation_time),
|
| 514 |
-
"claim": {
|
| 515 |
-
"id": claim.claim_id,
|
| 516 |
-
"text": claim.text,
|
| 517 |
-
"type": claim.claim_type.value,
|
| 518 |
-
"risk": claim.risk.value,
|
| 519 |
-
"consequential": claim.consequential,
|
| 520 |
-
"authority_scope": claim.authority_scope.value,
|
| 521 |
-
"answerability": claim.answerability.value,
|
| 522 |
-
},
|
| 523 |
-
"classification": classification.value,
|
| 524 |
-
"verdict": verdict.value,
|
| 525 |
-
"reason": reason,
|
| 526 |
-
"human_approved": human_approved,
|
| 527 |
-
"runtime_provenance": (
|
| 528 |
-
runtime_provenance.as_dict()
|
| 529 |
-
if runtime_provenance is not None
|
| 530 |
-
else current_runtime_provenance().as_dict()
|
| 531 |
-
),
|
| 532 |
-
"gates": {
|
| 533 |
-
"eligible_source_count": len(eligible),
|
| 534 |
-
"supporting_sources": supporting_sources,
|
| 535 |
-
"contradicting_sources": contradicting_sources,
|
| 536 |
-
"supporting_evidence_ids": supporting_evidence_ids,
|
| 537 |
-
"contradicting_evidence_ids": contradicting_evidence_ids,
|
| 538 |
-
"contradiction_count": len(contradicting_evidence_ids),
|
| 539 |
-
"authority_rejected_evidence_ids": sorted(
|
| 540 |
-
_effective_evidence_id(item) for item in authority_rejected
|
| 541 |
-
),
|
| 542 |
-
"post_action_verified": (
|
| 543 |
-
_has_direct_post_action_evidence(eligible)
|
| 544 |
-
if claim.claim_type is ClaimType.ACTION_STATE
|
| 545 |
-
else None
|
| 546 |
-
),
|
| 547 |
-
"independent_post_action_verified": (
|
| 548 |
-
_has_independent_post_action_evidence(eligible)
|
| 549 |
-
if claim.claim_type is ClaimType.ACTION_STATE
|
| 550 |
-
else None
|
| 551 |
-
),
|
| 552 |
-
"independent_supporting_source_count": _independent_supporting_source_count(eligible),
|
| 553 |
-
"independent_supporting_group_count": _independent_supporting_source_count(eligible),
|
| 554 |
-
"medium_risk_required_sources": (
|
| 555 |
-
policy.medium_risk_min_independent_sources
|
| 556 |
-
if claim.risk is RiskLevel.MEDIUM
|
| 557 |
-
else None
|
| 558 |
-
),
|
| 559 |
-
"human_gate": human_gate,
|
| 560 |
-
"runtime_provenance_required": policy.require_runtime_provenance,
|
| 561 |
-
},
|
| 562 |
-
"evidence": [
|
| 563 |
-
{
|
| 564 |
-
"evidence_id": _effective_evidence_id(item),
|
| 565 |
-
"source_id": item.source_id,
|
| 566 |
-
"source_role": item.source_role.value,
|
| 567 |
-
"source_ref": item.source_ref,
|
| 568 |
-
"independence_group": item.independence_group,
|
| 569 |
-
"content_sha256": (
|
| 570 |
-
item.content_sha256.lower()
|
| 571 |
-
if item.content_sha256 is not None
|
| 572 |
-
else None
|
| 573 |
-
),
|
| 574 |
-
"origin": item.origin.value,
|
| 575 |
-
"authority_scope": item.authority_scope.value,
|
| 576 |
-
"supports": item.supports,
|
| 577 |
-
"observed_at": _iso(item.observed_at),
|
| 578 |
-
"directness": item.directness.value,
|
| 579 |
-
"post_action": item.post_action,
|
| 580 |
-
"caller_eligible": item.eligible,
|
| 581 |
-
"eligible_at_evaluation": _effective_evidence_id(item) in eligible_ids,
|
| 582 |
-
}
|
| 583 |
-
for item in sorted_all
|
| 584 |
-
],
|
| 585 |
-
}
|
| 586 |
-
receipt["receipt_sha256"] = canonical_receipt_sha256(receipt)
|
| 587 |
-
return receipt
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
def verify_claim(
|
| 591 |
-
claim: Claim,
|
| 592 |
-
evidence: Iterable[Evidence],
|
| 593 |
-
*,
|
| 594 |
-
policy: Policy,
|
| 595 |
-
evaluation_time: datetime,
|
| 596 |
-
human_approved: bool = False,
|
| 597 |
-
) -> VerificationResult:
|
| 598 |
-
_validate_human_approval(human_approved)
|
| 599 |
-
evidence_seq = tuple(evidence)
|
| 600 |
-
_validate_provenance_inputs(claim, evidence_seq, policy)
|
| 601 |
-
_validate_evidence_gate_types(evidence_seq)
|
| 602 |
-
_validate_evidence_identity(evidence_seq)
|
| 603 |
-
_validate_common_inputs(evidence_seq, policy=policy, evaluation_time=evaluation_time)
|
| 604 |
-
|
| 605 |
-
fresh_eligible = _eligible_evidence(
|
| 606 |
-
evidence_seq,
|
| 607 |
-
policy=policy,
|
| 608 |
-
evaluation_time=evaluation_time,
|
| 609 |
-
)
|
| 610 |
-
eligible = _authorized_evidence(claim, fresh_eligible)
|
| 611 |
-
authority_rejected = tuple(
|
| 612 |
-
item for item in fresh_eligible if item not in eligible
|
| 613 |
-
)
|
| 614 |
-
runtime_provenance = current_runtime_provenance()
|
| 615 |
-
classification = _classify(claim, eligible)
|
| 616 |
-
verdict, reason = _decide_verdict(
|
| 617 |
-
claim,
|
| 618 |
-
classification,
|
| 619 |
-
eligible,
|
| 620 |
-
policy=policy,
|
| 621 |
-
human_approved=human_approved,
|
| 622 |
-
authority_rejected=bool(authority_rejected),
|
| 623 |
-
runtime_provenance_verified=runtime_provenance.verified,
|
| 624 |
-
)
|
| 625 |
-
receipt = _make_receipt(
|
| 626 |
-
claim=claim,
|
| 627 |
-
policy=policy,
|
| 628 |
-
evaluation_time=evaluation_time,
|
| 629 |
-
all_evidence=evidence_seq,
|
| 630 |
-
eligible=eligible,
|
| 631 |
-
classification=classification,
|
| 632 |
-
verdict=verdict,
|
| 633 |
-
reason=reason,
|
| 634 |
-
human_approved=human_approved,
|
| 635 |
-
authority_rejected=authority_rejected,
|
| 636 |
-
runtime_provenance=runtime_provenance,
|
| 637 |
-
)
|
| 638 |
-
failure_record = _make_failure_record(claim, verdict, reason)
|
| 639 |
-
return VerificationResult(
|
| 640 |
-
claim=claim,
|
| 641 |
-
classification=classification,
|
| 642 |
-
verdict=verdict,
|
| 643 |
-
reason=reason,
|
| 644 |
-
receipt=receipt,
|
| 645 |
-
failure_record=failure_record,
|
| 646 |
-
)
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
def verify_compound(
|
| 650 |
-
claims: Sequence[Claim],
|
| 651 |
-
evidence_by_claim: Mapping[str, Iterable[Evidence]],
|
| 652 |
-
*,
|
| 653 |
-
policy: Policy,
|
| 654 |
-
evaluation_time: datetime,
|
| 655 |
-
human_approvals: Mapping[str, bool] | None = None,
|
| 656 |
-
) -> CompoundResult:
|
| 657 |
-
if not claims:
|
| 658 |
-
raise ValueError("at least one claim is required")
|
| 659 |
-
claim_ids = [claim.claim_id for claim in claims]
|
| 660 |
-
if len(claim_ids) != len(set(claim_ids)):
|
| 661 |
-
raise ValueError("duplicate claim_id")
|
| 662 |
-
|
| 663 |
-
approvals = human_approvals or {}
|
| 664 |
-
for approval_claim_id, approval in approvals.items():
|
| 665 |
-
_validate_human_approval(
|
| 666 |
-
approval,
|
| 667 |
-
field_name=f"human approval for {approval_claim_id}",
|
| 668 |
-
)
|
| 669 |
-
|
| 670 |
-
results = tuple(
|
| 671 |
-
verify_claim(
|
| 672 |
-
claim,
|
| 673 |
-
evidence_by_claim.get(claim.claim_id, ()),
|
| 674 |
-
policy=policy,
|
| 675 |
-
evaluation_time=evaluation_time,
|
| 676 |
-
human_approved=approvals.get(claim.claim_id, False),
|
| 677 |
-
)
|
| 678 |
-
for claim in claims
|
| 679 |
-
)
|
| 680 |
-
|
| 681 |
-
consequential = [result for result in results if result.claim.consequential]
|
| 682 |
-
if any(result.verdict is Verdict.ESCALATE for result in consequential):
|
| 683 |
-
overall = Verdict.ESCALATE
|
| 684 |
-
elif any(result.verdict is not Verdict.APPROVE for result in consequential):
|
| 685 |
-
overall = Verdict.BLOCK
|
| 686 |
-
else:
|
| 687 |
-
overall = Verdict.APPROVE
|
| 688 |
-
|
| 689 |
return CompoundResult(results=results, verdict=overall)
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import hashlib
|
| 4 |
+
import json
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
from datetime import datetime
|
| 7 |
+
from enum import Enum
|
| 8 |
+
from typing import Iterable, Mapping, Sequence
|
| 9 |
+
|
| 10 |
+
from .runtime_provenance import RuntimeProvenance, current_runtime_provenance
|
| 11 |
+
|
| 12 |
+
ALGORITHM_VERSION = "2.1.2"
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class ClaimType(str, Enum):
|
| 16 |
+
UNCLASSIFIED = "unclassified"
|
| 17 |
+
OBSERVATION = "observation"
|
| 18 |
+
INFERENCE = "inference"
|
| 19 |
+
ESTIMATE = "estimate"
|
| 20 |
+
ACTION_STATE = "action_state"
|
| 21 |
+
RISK_SENSITIVE = "risk_sensitive"
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class Classification(str, Enum):
|
| 25 |
+
FACT = "FACT"
|
| 26 |
+
INFERENCE = "INFERENCE"
|
| 27 |
+
ESTIMATE = "ESTIMATE"
|
| 28 |
+
UNKNOWN = "UNKNOWN"
|
| 29 |
+
REFUTED = "REFUTED"
|
| 30 |
+
CONFLICT = "CONFLICT"
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class Verdict(str, Enum):
|
| 34 |
+
APPROVE = "APPROVE"
|
| 35 |
+
BLOCK = "BLOCK"
|
| 36 |
+
ESCALATE = "ESCALATE"
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class RiskLevel(str, Enum):
|
| 40 |
+
LOW = "low"
|
| 41 |
+
MEDIUM = "medium"
|
| 42 |
+
HIGH = "high"
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class Directness(str, Enum):
|
| 46 |
+
DIRECT = "direct"
|
| 47 |
+
INDIRECT = "indirect"
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class SourceRole(str, Enum):
|
| 51 |
+
EXECUTOR = "executor"
|
| 52 |
+
VERIFIER = "verifier"
|
| 53 |
+
EXTERNAL = "external"
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class EvidenceOrigin(str, Enum):
|
| 57 |
+
EXTERNAL_SOURCE = "external_source"
|
| 58 |
+
TOOL_OBSERVATION = "tool_observation"
|
| 59 |
+
COMPUTED_RESULT = "computed_result"
|
| 60 |
+
MEMORY = "memory"
|
| 61 |
+
USER_ASSERTION = "user_assertion"
|
| 62 |
+
USER_SELF_REPORT = "user_self_report"
|
| 63 |
+
USER_PREFERENCE = "user_preference"
|
| 64 |
+
USER_INSTRUCTION = "user_instruction"
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class AuthorityScope(str, Enum):
|
| 68 |
+
EXTERNAL_FACT = "external_fact"
|
| 69 |
+
SYSTEM_STATE = "system_state"
|
| 70 |
+
USER_SELF_REPORT = "user_self_report"
|
| 71 |
+
USER_PREFERENCE = "user_preference"
|
| 72 |
+
USER_INSTRUCTION = "user_instruction"
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class AnswerabilityStatus(str, Enum):
|
| 76 |
+
ANSWERABLE = "answerable"
|
| 77 |
+
PARTIALLY_ANSWERABLE = "partially_answerable"
|
| 78 |
+
UNDERSPECIFIED = "underspecified"
|
| 79 |
+
FALSE_PREMISE = "false_premise"
|
| 80 |
+
CONTRADICTORY_INPUT = "contradictory_input"
|
| 81 |
+
NEEDS_CLARIFICATION = "needs_clarification"
|
| 82 |
+
OUT_OF_SCOPE = "out_of_scope"
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
@dataclass(frozen=True)
|
| 86 |
+
class Claim:
|
| 87 |
+
claim_id: str
|
| 88 |
+
text: str
|
| 89 |
+
claim_type: ClaimType
|
| 90 |
+
risk: RiskLevel = RiskLevel.LOW
|
| 91 |
+
consequential: bool = True
|
| 92 |
+
authority_scope: AuthorityScope = AuthorityScope.EXTERNAL_FACT
|
| 93 |
+
answerability: AnswerabilityStatus = AnswerabilityStatus.ANSWERABLE
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
@dataclass(frozen=True)
|
| 97 |
+
class Evidence:
|
| 98 |
+
source_id: str
|
| 99 |
+
supports: bool
|
| 100 |
+
observed_at: datetime
|
| 101 |
+
directness: Directness
|
| 102 |
+
post_action: bool = False
|
| 103 |
+
eligible: bool = True
|
| 104 |
+
evidence_id: str | None = None
|
| 105 |
+
source_role: SourceRole = SourceRole.EXTERNAL
|
| 106 |
+
source_ref: str | None = None
|
| 107 |
+
independence_group: str | None = None
|
| 108 |
+
content_sha256: str | None = None
|
| 109 |
+
origin: EvidenceOrigin = EvidenceOrigin.EXTERNAL_SOURCE
|
| 110 |
+
authority_scope: AuthorityScope = AuthorityScope.EXTERNAL_FACT
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
@dataclass(frozen=True)
|
| 114 |
+
class Policy:
|
| 115 |
+
version: str
|
| 116 |
+
max_age_seconds: int
|
| 117 |
+
reject_future_evidence: bool = True
|
| 118 |
+
require_independent_action_evidence: bool = True
|
| 119 |
+
medium_risk_min_independent_sources: int = 2
|
| 120 |
+
require_runtime_provenance: bool = False
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
@dataclass(frozen=True)
|
| 124 |
+
class VerificationResult:
|
| 125 |
+
claim: Claim
|
| 126 |
+
classification: Classification
|
| 127 |
+
verdict: Verdict
|
| 128 |
+
reason: str
|
| 129 |
+
receipt: dict
|
| 130 |
+
failure_record: dict | None
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
@dataclass(frozen=True)
|
| 134 |
+
class CompoundResult:
|
| 135 |
+
results: tuple[VerificationResult, ...]
|
| 136 |
+
verdict: Verdict
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def _is_aware(value: datetime) -> bool:
|
| 140 |
+
return value.tzinfo is not None and value.utcoffset() is not None
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def _effective_evidence_id(item: Evidence) -> str:
|
| 144 |
+
return item.evidence_id or item.source_id
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def _validate_human_approval(value: bool, *, field_name: str = "human_approved") -> None:
|
| 148 |
+
if type(value) is not bool:
|
| 149 |
+
raise TypeError(f"{field_name} must be bool")
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def _validate_non_empty_string(value: object, field_name: str) -> None:
|
| 153 |
+
if not isinstance(value, str) or not value.strip():
|
| 154 |
+
raise ValueError(f"{field_name} must be non-empty string")
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def _validate_provenance_inputs(
|
| 158 |
+
claim: Claim, evidence: Sequence[Evidence], policy: Policy
|
| 159 |
+
) -> None:
|
| 160 |
+
_validate_non_empty_string(claim.claim_id, "claim_id")
|
| 161 |
+
_validate_non_empty_string(claim.text, "claim.text")
|
| 162 |
+
_validate_non_empty_string(policy.version, "policy.version")
|
| 163 |
+
if not isinstance(claim.authority_scope, AuthorityScope):
|
| 164 |
+
raise TypeError("claim.authority_scope must be AuthorityScope")
|
| 165 |
+
if not isinstance(claim.answerability, AnswerabilityStatus):
|
| 166 |
+
raise TypeError("claim.answerability must be AnswerabilityStatus")
|
| 167 |
+
for item in evidence:
|
| 168 |
+
_validate_non_empty_string(item.source_id, "evidence.source_id")
|
| 169 |
+
if not isinstance(item.origin, EvidenceOrigin):
|
| 170 |
+
raise TypeError("evidence.origin must be EvidenceOrigin")
|
| 171 |
+
if not isinstance(item.authority_scope, AuthorityScope):
|
| 172 |
+
raise TypeError("evidence.authority_scope must be AuthorityScope")
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def _validate_common_inputs(
|
| 177 |
+
evidence: Sequence[Evidence], *, policy: Policy, evaluation_time: datetime
|
| 178 |
+
) -> None:
|
| 179 |
+
if not _is_aware(evaluation_time):
|
| 180 |
+
raise ValueError("evaluation_time must be timezone-aware")
|
| 181 |
+
if policy.max_age_seconds < 0:
|
| 182 |
+
raise ValueError("max_age_seconds must be non-negative")
|
| 183 |
+
if policy.medium_risk_min_independent_sources < 1:
|
| 184 |
+
raise ValueError("medium_risk_min_independent_sources must be at least 1")
|
| 185 |
+
if type(policy.require_runtime_provenance) is not bool:
|
| 186 |
+
raise TypeError("require_runtime_provenance must be bool")
|
| 187 |
+
for item in evidence:
|
| 188 |
+
if not _is_aware(item.observed_at):
|
| 189 |
+
raise ValueError("observed_at must be timezone-aware")
|
| 190 |
+
if item.evidence_id is not None and not item.evidence_id.strip():
|
| 191 |
+
raise ValueError("evidence_id must be non-empty when supplied")
|
| 192 |
+
if item.source_ref is not None and not item.source_ref.strip():
|
| 193 |
+
raise ValueError("source_ref must be non-empty when supplied")
|
| 194 |
+
if item.independence_group is not None and not item.independence_group.strip():
|
| 195 |
+
raise ValueError("independence_group must be non-empty when supplied")
|
| 196 |
+
if item.content_sha256 is not None:
|
| 197 |
+
digest = item.content_sha256.lower()
|
| 198 |
+
if len(digest) != 64 or any(ch not in "0123456789abcdef" for ch in digest):
|
| 199 |
+
raise ValueError("content_sha256 must be a 64-character hexadecimal SHA-256")
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def _validate_evidence_gate_types(evidence: Sequence[Evidence]) -> None:
|
| 203 |
+
for item in evidence:
|
| 204 |
+
if type(item.supports) is not bool:
|
| 205 |
+
raise TypeError("evidence.supports must be bool")
|
| 206 |
+
if type(item.post_action) is not bool:
|
| 207 |
+
raise TypeError("evidence.post_action must be bool")
|
| 208 |
+
if type(item.eligible) is not bool:
|
| 209 |
+
raise TypeError("evidence.eligible must be bool")
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def _validate_evidence_identity(evidence: Sequence[Evidence]) -> None:
|
| 213 |
+
ids = [_effective_evidence_id(item) for item in evidence]
|
| 214 |
+
if len(ids) != len(set(ids)):
|
| 215 |
+
raise ValueError(
|
| 216 |
+
"duplicate evidence_id; duplicate source_id fallback identity is not allowed"
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def _is_fresh(item: Evidence, *, policy: Policy, evaluation_time: datetime) -> bool:
|
| 221 |
+
age = (evaluation_time - item.observed_at).total_seconds()
|
| 222 |
+
if policy.reject_future_evidence and age < 0:
|
| 223 |
+
return False
|
| 224 |
+
return 0 <= age <= policy.max_age_seconds
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def _eligible_evidence(
|
| 228 |
+
evidence: Sequence[Evidence], *, policy: Policy, evaluation_time: datetime
|
| 229 |
+
) -> tuple[Evidence, ...]:
|
| 230 |
+
return tuple(
|
| 231 |
+
item
|
| 232 |
+
for item in evidence
|
| 233 |
+
if item.eligible and _is_fresh(item, policy=policy, evaluation_time=evaluation_time)
|
| 234 |
+
)
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
_USER_ORIGINS = {
|
| 238 |
+
EvidenceOrigin.USER_ASSERTION,
|
| 239 |
+
EvidenceOrigin.USER_SELF_REPORT,
|
| 240 |
+
EvidenceOrigin.USER_PREFERENCE,
|
| 241 |
+
EvidenceOrigin.USER_INSTRUCTION,
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def _authority_allows(claim: Claim, item: Evidence) -> bool:
|
| 246 |
+
if item.authority_scope is not claim.authority_scope:
|
| 247 |
+
return False
|
| 248 |
+
|
| 249 |
+
if claim.authority_scope in {AuthorityScope.EXTERNAL_FACT, AuthorityScope.SYSTEM_STATE}:
|
| 250 |
+
return item.origin in {
|
| 251 |
+
EvidenceOrigin.EXTERNAL_SOURCE,
|
| 252 |
+
EvidenceOrigin.TOOL_OBSERVATION,
|
| 253 |
+
EvidenceOrigin.COMPUTED_RESULT,
|
| 254 |
+
}
|
| 255 |
+
if claim.authority_scope is AuthorityScope.USER_PREFERENCE:
|
| 256 |
+
return item.origin in {
|
| 257 |
+
EvidenceOrigin.USER_PREFERENCE,
|
| 258 |
+
EvidenceOrigin.USER_SELF_REPORT,
|
| 259 |
+
EvidenceOrigin.TOOL_OBSERVATION,
|
| 260 |
+
}
|
| 261 |
+
if claim.authority_scope is AuthorityScope.USER_INSTRUCTION:
|
| 262 |
+
return item.origin in {
|
| 263 |
+
EvidenceOrigin.USER_INSTRUCTION,
|
| 264 |
+
EvidenceOrigin.TOOL_OBSERVATION,
|
| 265 |
+
}
|
| 266 |
+
if claim.authority_scope is AuthorityScope.USER_SELF_REPORT:
|
| 267 |
+
return item.origin in {
|
| 268 |
+
EvidenceOrigin.USER_SELF_REPORT,
|
| 269 |
+
EvidenceOrigin.TOOL_OBSERVATION,
|
| 270 |
+
}
|
| 271 |
+
return False
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
def _authorized_evidence(
|
| 275 |
+
claim: Claim, evidence: Sequence[Evidence]
|
| 276 |
+
) -> tuple[Evidence, ...]:
|
| 277 |
+
return tuple(item for item in evidence if _authority_allows(claim, item))
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
def _classify(claim: Claim, eligible: Sequence[Evidence]) -> Classification:
|
| 281 |
+
if claim.answerability is AnswerabilityStatus.CONTRADICTORY_INPUT:
|
| 282 |
+
return Classification.CONFLICT
|
| 283 |
+
if claim.answerability is not AnswerabilityStatus.ANSWERABLE:
|
| 284 |
+
return Classification.UNKNOWN
|
| 285 |
+
if claim.claim_type is ClaimType.UNCLASSIFIED:
|
| 286 |
+
return Classification.UNKNOWN
|
| 287 |
+
|
| 288 |
+
supporting = tuple(item for item in eligible if item.supports)
|
| 289 |
+
contradicting = tuple(item for item in eligible if not item.supports)
|
| 290 |
+
if supporting and contradicting:
|
| 291 |
+
return Classification.CONFLICT
|
| 292 |
+
if contradicting:
|
| 293 |
+
return Classification.REFUTED
|
| 294 |
+
if not supporting:
|
| 295 |
+
return Classification.UNKNOWN
|
| 296 |
+
|
| 297 |
+
if claim.claim_type is ClaimType.INFERENCE:
|
| 298 |
+
return Classification.INFERENCE
|
| 299 |
+
if claim.claim_type is ClaimType.ESTIMATE:
|
| 300 |
+
return Classification.ESTIMATE
|
| 301 |
+
|
| 302 |
+
if not any(item.directness is Directness.DIRECT for item in supporting):
|
| 303 |
+
return Classification.INFERENCE
|
| 304 |
+
|
| 305 |
+
return Classification.FACT
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
def _has_direct_post_action_evidence(eligible: Sequence[Evidence]) -> bool:
|
| 309 |
+
return any(
|
| 310 |
+
item.supports
|
| 311 |
+
and item.post_action
|
| 312 |
+
and item.directness is Directness.DIRECT
|
| 313 |
+
for item in eligible
|
| 314 |
+
)
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
def _has_independent_post_action_evidence(eligible: Sequence[Evidence]) -> bool:
|
| 318 |
+
return any(
|
| 319 |
+
item.supports
|
| 320 |
+
and item.post_action
|
| 321 |
+
and item.directness is Directness.DIRECT
|
| 322 |
+
and item.source_role in {SourceRole.VERIFIER, SourceRole.EXTERNAL}
|
| 323 |
+
for item in eligible
|
| 324 |
+
)
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
def _independent_supporting_source_count(eligible: Sequence[Evidence]) -> int:
|
| 328 |
+
# Extra metadata may join known identities, never split the same provenance.
|
| 329 |
+
# Connected components preserve overlap transitively, regardless of input order.
|
| 330 |
+
parents: list[int] = []
|
| 331 |
+
seen: dict[tuple[str, str], int] = {}
|
| 332 |
+
|
| 333 |
+
def root(index: int) -> int:
|
| 334 |
+
while parents[index] != index:
|
| 335 |
+
parents[index] = parents[parents[index]]
|
| 336 |
+
index = parents[index]
|
| 337 |
+
return index
|
| 338 |
+
|
| 339 |
+
for item in eligible:
|
| 340 |
+
if not (
|
| 341 |
+
item.supports
|
| 342 |
+
and item.directness is Directness.DIRECT
|
| 343 |
+
and item.source_role in {SourceRole.VERIFIER, SourceRole.EXTERNAL}
|
| 344 |
+
):
|
| 345 |
+
continue
|
| 346 |
+
index = len(parents)
|
| 347 |
+
parents.append(index)
|
| 348 |
+
identities = [("source", item.source_id.strip())]
|
| 349 |
+
if item.source_ref is not None:
|
| 350 |
+
identities.append(("ref", item.source_ref.strip()))
|
| 351 |
+
if item.independence_group is not None:
|
| 352 |
+
identities.append(("group", item.independence_group.strip()))
|
| 353 |
+
if item.content_sha256 is not None:
|
| 354 |
+
identities.append(("content", item.content_sha256.lower()))
|
| 355 |
+
for identity in identities:
|
| 356 |
+
previous = seen.setdefault(identity, index)
|
| 357 |
+
parents[root(index)] = root(previous)
|
| 358 |
+
|
| 359 |
+
return len({root(index) for index in range(len(parents))})
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
def _decide_verdict(
|
| 363 |
+
claim: Claim,
|
| 364 |
+
classification: Classification,
|
| 365 |
+
eligible: Sequence[Evidence],
|
| 366 |
+
*,
|
| 367 |
+
policy: Policy,
|
| 368 |
+
human_approved: bool,
|
| 369 |
+
authority_rejected: bool = False,
|
| 370 |
+
runtime_provenance_verified: bool = False,
|
| 371 |
+
) -> tuple[Verdict, str]:
|
| 372 |
+
if policy.require_runtime_provenance and not runtime_provenance_verified:
|
| 373 |
+
return Verdict.BLOCK, "runtime_provenance_unverified"
|
| 374 |
+
|
| 375 |
+
if claim.answerability is AnswerabilityStatus.NEEDS_CLARIFICATION:
|
| 376 |
+
return Verdict.ESCALATE, "answerability_needs_clarification"
|
| 377 |
+
if claim.answerability is not AnswerabilityStatus.ANSWERABLE:
|
| 378 |
+
return Verdict.BLOCK, f"answerability_{claim.answerability.value}"
|
| 379 |
+
if claim.claim_type is ClaimType.UNCLASSIFIED:
|
| 380 |
+
return Verdict.BLOCK, "unclassified_claim"
|
| 381 |
+
|
| 382 |
+
if classification is Classification.CONFLICT:
|
| 383 |
+
return Verdict.BLOCK, "eligible_contradiction"
|
| 384 |
+
if classification is Classification.REFUTED:
|
| 385 |
+
return Verdict.BLOCK, "claim_refuted_by_eligible_evidence"
|
| 386 |
+
if classification is Classification.UNKNOWN:
|
| 387 |
+
if authority_rejected:
|
| 388 |
+
return Verdict.BLOCK, "insufficient_authorized_evidence"
|
| 389 |
+
return Verdict.BLOCK, "insufficient_eligible_evidence"
|
| 390 |
+
if classification is Classification.INFERENCE:
|
| 391 |
+
return Verdict.BLOCK, "inference_not_fact"
|
| 392 |
+
if classification is Classification.ESTIMATE:
|
| 393 |
+
return Verdict.BLOCK, "estimate_not_fact"
|
| 394 |
+
|
| 395 |
+
if claim.claim_type is ClaimType.ACTION_STATE:
|
| 396 |
+
if not _has_direct_post_action_evidence(eligible):
|
| 397 |
+
return Verdict.BLOCK, "missing_post_action_final_state_evidence"
|
| 398 |
+
if (
|
| 399 |
+
policy.require_independent_action_evidence
|
| 400 |
+
and not _has_independent_post_action_evidence(eligible)
|
| 401 |
+
):
|
| 402 |
+
return Verdict.BLOCK, "missing_independent_post_action_evidence"
|
| 403 |
+
|
| 404 |
+
if (
|
| 405 |
+
claim.risk is RiskLevel.MEDIUM
|
| 406 |
+
and _independent_supporting_source_count(eligible)
|
| 407 |
+
< policy.medium_risk_min_independent_sources
|
| 408 |
+
):
|
| 409 |
+
return Verdict.ESCALATE, "additional_independent_evidence_required"
|
| 410 |
+
|
| 411 |
+
if claim.risk is RiskLevel.HIGH and not human_approved:
|
| 412 |
+
return Verdict.ESCALATE, "human_approval_required"
|
| 413 |
+
|
| 414 |
+
return Verdict.APPROVE, "fact_with_required_gates"
|
| 415 |
+
|
| 416 |
+
|
| 417 |
+
def _iso(value: datetime) -> str:
|
| 418 |
+
return value.isoformat()
|
| 419 |
+
|
| 420 |
+
|
| 421 |
+
def _make_failure_record(claim: Claim, verdict: Verdict, reason: str) -> dict | None:
|
| 422 |
+
if verdict is Verdict.APPROVE:
|
| 423 |
+
return None
|
| 424 |
+
return {
|
| 425 |
+
"record_type": "expected_gate",
|
| 426 |
+
"claim_id": claim.claim_id,
|
| 427 |
+
"failure_mode": reason,
|
| 428 |
+
"root_cause": "verification_gate_not_satisfied",
|
| 429 |
+
"guardrail": f"preserve_{verdict.value.lower()}_until_new_eligible_evidence_or_required_approval",
|
| 430 |
+
"regression_test_required": False,
|
| 431 |
+
"version_change_required": False,
|
| 432 |
+
}
|
| 433 |
+
|
| 434 |
+
|
| 435 |
+
def make_failure_evolution_record(
|
| 436 |
+
*,
|
| 437 |
+
failure_mode: str,
|
| 438 |
+
root_cause: str,
|
| 439 |
+
guardrail: str,
|
| 440 |
+
regression_test: str,
|
| 441 |
+
) -> dict:
|
| 442 |
+
fields = {
|
| 443 |
+
"failure_mode": failure_mode,
|
| 444 |
+
"root_cause": root_cause,
|
| 445 |
+
"guardrail": guardrail,
|
| 446 |
+
"regression_test": regression_test,
|
| 447 |
+
}
|
| 448 |
+
for name, value in fields.items():
|
| 449 |
+
if not isinstance(value, str) or not value.strip():
|
| 450 |
+
raise ValueError(f"{name} must be non-empty")
|
| 451 |
+
return {
|
| 452 |
+
"record_type": "failure_evolution",
|
| 453 |
+
"failure_mode": failure_mode.strip(),
|
| 454 |
+
"root_cause": root_cause.strip(),
|
| 455 |
+
"guardrail": guardrail.strip(),
|
| 456 |
+
"regression_test": regression_test.strip(),
|
| 457 |
+
"regression_test_required": True,
|
| 458 |
+
"version_change_required": True,
|
| 459 |
+
}
|
| 460 |
+
|
| 461 |
+
|
| 462 |
+
def canonical_receipt_sha256(receipt: Mapping) -> str:
|
| 463 |
+
payload = {
|
| 464 |
+
key: value
|
| 465 |
+
for key, value in receipt.items()
|
| 466 |
+
if key != "receipt_sha256"
|
| 467 |
+
}
|
| 468 |
+
canonical = json.dumps(
|
| 469 |
+
payload,
|
| 470 |
+
sort_keys=True,
|
| 471 |
+
separators=(",", ":"),
|
| 472 |
+
ensure_ascii=False,
|
| 473 |
+
).encode("utf-8")
|
| 474 |
+
return hashlib.sha256(canonical).hexdigest()
|
| 475 |
+
|
| 476 |
+
|
| 477 |
+
def _make_receipt(
|
| 478 |
+
*,
|
| 479 |
+
claim: Claim,
|
| 480 |
+
policy: Policy,
|
| 481 |
+
evaluation_time: datetime,
|
| 482 |
+
all_evidence: Sequence[Evidence],
|
| 483 |
+
eligible: Sequence[Evidence],
|
| 484 |
+
classification: Classification,
|
| 485 |
+
verdict: Verdict,
|
| 486 |
+
reason: str,
|
| 487 |
+
human_approved: bool,
|
| 488 |
+
authority_rejected: Sequence[Evidence] = (),
|
| 489 |
+
runtime_provenance: RuntimeProvenance | None = None,
|
| 490 |
+
) -> dict:
|
| 491 |
+
eligible_ids = {_effective_evidence_id(item) for item in eligible}
|
| 492 |
+
sorted_all = sorted(
|
| 493 |
+
all_evidence,
|
| 494 |
+
key=lambda item: (_effective_evidence_id(item), item.source_id),
|
| 495 |
+
)
|
| 496 |
+
|
| 497 |
+
supporting_sources = sorted(item.source_id for item in eligible if item.supports)
|
| 498 |
+
contradicting_sources = sorted(item.source_id for item in eligible if not item.supports)
|
| 499 |
+
supporting_evidence_ids = sorted(
|
| 500 |
+
_effective_evidence_id(item) for item in eligible if item.supports
|
| 501 |
+
)
|
| 502 |
+
contradicting_evidence_ids = sorted(
|
| 503 |
+
_effective_evidence_id(item) for item in eligible if not item.supports
|
| 504 |
+
)
|
| 505 |
+
if claim.risk is RiskLevel.HIGH:
|
| 506 |
+
human_gate = "approved" if human_approved else "required"
|
| 507 |
+
else:
|
| 508 |
+
human_gate = "not_required"
|
| 509 |
+
|
| 510 |
+
receipt = {
|
| 511 |
+
"algorithm_version": ALGORITHM_VERSION,
|
| 512 |
+
"policy_version": policy.version,
|
| 513 |
+
"evaluation_time": _iso(evaluation_time),
|
| 514 |
+
"claim": {
|
| 515 |
+
"id": claim.claim_id,
|
| 516 |
+
"text": claim.text,
|
| 517 |
+
"type": claim.claim_type.value,
|
| 518 |
+
"risk": claim.risk.value,
|
| 519 |
+
"consequential": claim.consequential,
|
| 520 |
+
"authority_scope": claim.authority_scope.value,
|
| 521 |
+
"answerability": claim.answerability.value,
|
| 522 |
+
},
|
| 523 |
+
"classification": classification.value,
|
| 524 |
+
"verdict": verdict.value,
|
| 525 |
+
"reason": reason,
|
| 526 |
+
"human_approved": human_approved,
|
| 527 |
+
"runtime_provenance": (
|
| 528 |
+
runtime_provenance.as_dict()
|
| 529 |
+
if runtime_provenance is not None
|
| 530 |
+
else current_runtime_provenance().as_dict()
|
| 531 |
+
),
|
| 532 |
+
"gates": {
|
| 533 |
+
"eligible_source_count": len(eligible),
|
| 534 |
+
"supporting_sources": supporting_sources,
|
| 535 |
+
"contradicting_sources": contradicting_sources,
|
| 536 |
+
"supporting_evidence_ids": supporting_evidence_ids,
|
| 537 |
+
"contradicting_evidence_ids": contradicting_evidence_ids,
|
| 538 |
+
"contradiction_count": len(contradicting_evidence_ids),
|
| 539 |
+
"authority_rejected_evidence_ids": sorted(
|
| 540 |
+
_effective_evidence_id(item) for item in authority_rejected
|
| 541 |
+
),
|
| 542 |
+
"post_action_verified": (
|
| 543 |
+
_has_direct_post_action_evidence(eligible)
|
| 544 |
+
if claim.claim_type is ClaimType.ACTION_STATE
|
| 545 |
+
else None
|
| 546 |
+
),
|
| 547 |
+
"independent_post_action_verified": (
|
| 548 |
+
_has_independent_post_action_evidence(eligible)
|
| 549 |
+
if claim.claim_type is ClaimType.ACTION_STATE
|
| 550 |
+
else None
|
| 551 |
+
),
|
| 552 |
+
"independent_supporting_source_count": _independent_supporting_source_count(eligible),
|
| 553 |
+
"independent_supporting_group_count": _independent_supporting_source_count(eligible),
|
| 554 |
+
"medium_risk_required_sources": (
|
| 555 |
+
policy.medium_risk_min_independent_sources
|
| 556 |
+
if claim.risk is RiskLevel.MEDIUM
|
| 557 |
+
else None
|
| 558 |
+
),
|
| 559 |
+
"human_gate": human_gate,
|
| 560 |
+
"runtime_provenance_required": policy.require_runtime_provenance,
|
| 561 |
+
},
|
| 562 |
+
"evidence": [
|
| 563 |
+
{
|
| 564 |
+
"evidence_id": _effective_evidence_id(item),
|
| 565 |
+
"source_id": item.source_id,
|
| 566 |
+
"source_role": item.source_role.value,
|
| 567 |
+
"source_ref": item.source_ref,
|
| 568 |
+
"independence_group": item.independence_group,
|
| 569 |
+
"content_sha256": (
|
| 570 |
+
item.content_sha256.lower()
|
| 571 |
+
if item.content_sha256 is not None
|
| 572 |
+
else None
|
| 573 |
+
),
|
| 574 |
+
"origin": item.origin.value,
|
| 575 |
+
"authority_scope": item.authority_scope.value,
|
| 576 |
+
"supports": item.supports,
|
| 577 |
+
"observed_at": _iso(item.observed_at),
|
| 578 |
+
"directness": item.directness.value,
|
| 579 |
+
"post_action": item.post_action,
|
| 580 |
+
"caller_eligible": item.eligible,
|
| 581 |
+
"eligible_at_evaluation": _effective_evidence_id(item) in eligible_ids,
|
| 582 |
+
}
|
| 583 |
+
for item in sorted_all
|
| 584 |
+
],
|
| 585 |
+
}
|
| 586 |
+
receipt["receipt_sha256"] = canonical_receipt_sha256(receipt)
|
| 587 |
+
return receipt
|
| 588 |
+
|
| 589 |
+
|
| 590 |
+
def verify_claim(
|
| 591 |
+
claim: Claim,
|
| 592 |
+
evidence: Iterable[Evidence],
|
| 593 |
+
*,
|
| 594 |
+
policy: Policy,
|
| 595 |
+
evaluation_time: datetime,
|
| 596 |
+
human_approved: bool = False,
|
| 597 |
+
) -> VerificationResult:
|
| 598 |
+
_validate_human_approval(human_approved)
|
| 599 |
+
evidence_seq = tuple(evidence)
|
| 600 |
+
_validate_provenance_inputs(claim, evidence_seq, policy)
|
| 601 |
+
_validate_evidence_gate_types(evidence_seq)
|
| 602 |
+
_validate_evidence_identity(evidence_seq)
|
| 603 |
+
_validate_common_inputs(evidence_seq, policy=policy, evaluation_time=evaluation_time)
|
| 604 |
+
|
| 605 |
+
fresh_eligible = _eligible_evidence(
|
| 606 |
+
evidence_seq,
|
| 607 |
+
policy=policy,
|
| 608 |
+
evaluation_time=evaluation_time,
|
| 609 |
+
)
|
| 610 |
+
eligible = _authorized_evidence(claim, fresh_eligible)
|
| 611 |
+
authority_rejected = tuple(
|
| 612 |
+
item for item in fresh_eligible if item not in eligible
|
| 613 |
+
)
|
| 614 |
+
runtime_provenance = current_runtime_provenance()
|
| 615 |
+
classification = _classify(claim, eligible)
|
| 616 |
+
verdict, reason = _decide_verdict(
|
| 617 |
+
claim,
|
| 618 |
+
classification,
|
| 619 |
+
eligible,
|
| 620 |
+
policy=policy,
|
| 621 |
+
human_approved=human_approved,
|
| 622 |
+
authority_rejected=bool(authority_rejected),
|
| 623 |
+
runtime_provenance_verified=runtime_provenance.verified,
|
| 624 |
+
)
|
| 625 |
+
receipt = _make_receipt(
|
| 626 |
+
claim=claim,
|
| 627 |
+
policy=policy,
|
| 628 |
+
evaluation_time=evaluation_time,
|
| 629 |
+
all_evidence=evidence_seq,
|
| 630 |
+
eligible=eligible,
|
| 631 |
+
classification=classification,
|
| 632 |
+
verdict=verdict,
|
| 633 |
+
reason=reason,
|
| 634 |
+
human_approved=human_approved,
|
| 635 |
+
authority_rejected=authority_rejected,
|
| 636 |
+
runtime_provenance=runtime_provenance,
|
| 637 |
+
)
|
| 638 |
+
failure_record = _make_failure_record(claim, verdict, reason)
|
| 639 |
+
return VerificationResult(
|
| 640 |
+
claim=claim,
|
| 641 |
+
classification=classification,
|
| 642 |
+
verdict=verdict,
|
| 643 |
+
reason=reason,
|
| 644 |
+
receipt=receipt,
|
| 645 |
+
failure_record=failure_record,
|
| 646 |
+
)
|
| 647 |
+
|
| 648 |
+
|
| 649 |
+
def verify_compound(
|
| 650 |
+
claims: Sequence[Claim],
|
| 651 |
+
evidence_by_claim: Mapping[str, Iterable[Evidence]],
|
| 652 |
+
*,
|
| 653 |
+
policy: Policy,
|
| 654 |
+
evaluation_time: datetime,
|
| 655 |
+
human_approvals: Mapping[str, bool] | None = None,
|
| 656 |
+
) -> CompoundResult:
|
| 657 |
+
if not claims:
|
| 658 |
+
raise ValueError("at least one claim is required")
|
| 659 |
+
claim_ids = [claim.claim_id for claim in claims]
|
| 660 |
+
if len(claim_ids) != len(set(claim_ids)):
|
| 661 |
+
raise ValueError("duplicate claim_id")
|
| 662 |
+
|
| 663 |
+
approvals = human_approvals or {}
|
| 664 |
+
for approval_claim_id, approval in approvals.items():
|
| 665 |
+
_validate_human_approval(
|
| 666 |
+
approval,
|
| 667 |
+
field_name=f"human approval for {approval_claim_id}",
|
| 668 |
+
)
|
| 669 |
+
|
| 670 |
+
results = tuple(
|
| 671 |
+
verify_claim(
|
| 672 |
+
claim,
|
| 673 |
+
evidence_by_claim.get(claim.claim_id, ()),
|
| 674 |
+
policy=policy,
|
| 675 |
+
evaluation_time=evaluation_time,
|
| 676 |
+
human_approved=approvals.get(claim.claim_id, False),
|
| 677 |
+
)
|
| 678 |
+
for claim in claims
|
| 679 |
+
)
|
| 680 |
+
|
| 681 |
+
consequential = [result for result in results if result.claim.consequential]
|
| 682 |
+
if any(result.verdict is Verdict.ESCALATE for result in consequential):
|
| 683 |
+
overall = Verdict.ESCALATE
|
| 684 |
+
elif any(result.verdict is not Verdict.APPROVE for result in consequential):
|
| 685 |
+
overall = Verdict.BLOCK
|
| 686 |
+
else:
|
| 687 |
+
overall = Verdict.APPROVE
|
| 688 |
+
|
| 689 |
return CompoundResult(results=results, verdict=overall)
|
bodulskihal_core/evaluation.py
CHANGED
|
@@ -1,192 +1,192 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
from typing import Any, Mapping
|
| 4 |
-
|
| 5 |
-
from .research_demo import run_research_demo
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
_ALLOWED_LABELS = {
|
| 9 |
-
"supported",
|
| 10 |
-
"unsupported",
|
| 11 |
-
"contradiction",
|
| 12 |
-
"action_without_post_state",
|
| 13 |
-
"high_risk_without_human_approval",
|
| 14 |
-
}
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
def _mapping(value: Any, path: str) -> Mapping[str, Any]:
|
| 18 |
-
if not isinstance(value, Mapping):
|
| 19 |
-
raise TypeError(f"{path} must be object")
|
| 20 |
-
return value
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
def _rate(numerator: int, denominator: int) -> float:
|
| 24 |
-
return (numerator / denominator) if denominator else 0.0
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
def _validate_labels(
|
| 28 |
-
fixture: Mapping[str, Any],
|
| 29 |
-
benchmark: Mapping[str, Any],
|
| 30 |
-
) -> dict[str, str]:
|
| 31 |
-
claims = fixture.get("claims")
|
| 32 |
-
if not isinstance(claims, list):
|
| 33 |
-
raise TypeError("fixture.claims must be list")
|
| 34 |
-
|
| 35 |
-
claim_ids: list[str] = []
|
| 36 |
-
for index, raw in enumerate(claims):
|
| 37 |
-
claim = _mapping(raw, f"fixture.claims[{index}]")
|
| 38 |
-
claim_id = claim.get("id")
|
| 39 |
-
if not isinstance(claim_id, str) or not claim_id:
|
| 40 |
-
raise TypeError(f"fixture.claims[{index}].id must be non-empty string")
|
| 41 |
-
claim_ids.append(claim_id)
|
| 42 |
-
|
| 43 |
-
if len(claim_ids) != len(set(claim_ids)):
|
| 44 |
-
raise ValueError("fixture contains duplicate claim id")
|
| 45 |
-
|
| 46 |
-
labels_raw = _mapping(benchmark.get("labels"), "benchmark.labels")
|
| 47 |
-
unknown_ids = sorted(set(labels_raw) - set(claim_ids))
|
| 48 |
-
if unknown_ids:
|
| 49 |
-
raise ValueError("benchmark label references unknown claim: " + ", ".join(unknown_ids))
|
| 50 |
-
|
| 51 |
-
missing_ids = [claim_id for claim_id in claim_ids if claim_id not in labels_raw]
|
| 52 |
-
if missing_ids:
|
| 53 |
-
raise ValueError("missing benchmark label for claim: " + ", ".join(missing_ids))
|
| 54 |
-
|
| 55 |
-
labels: dict[str, str] = {}
|
| 56 |
-
for claim_id in claim_ids:
|
| 57 |
-
label = labels_raw[claim_id]
|
| 58 |
-
if not isinstance(label, str) or label not in _ALLOWED_LABELS:
|
| 59 |
-
raise ValueError(f"invalid benchmark label kind for {claim_id}: {label}")
|
| 60 |
-
labels[claim_id] = label
|
| 61 |
-
|
| 62 |
-
missing_kinds = sorted(_ALLOWED_LABELS - set(labels.values()))
|
| 63 |
-
if missing_kinds:
|
| 64 |
-
raise ValueError(
|
| 65 |
-
"benchmark must contain every benchmark label kind: "
|
| 66 |
-
+ ", ".join(missing_kinds)
|
| 67 |
-
)
|
| 68 |
-
|
| 69 |
-
return labels
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
def _claim_ids_with_label(labels: Mapping[str, str], kind: str) -> list[str]:
|
| 73 |
-
return [claim_id for claim_id, label in labels.items() if label == kind]
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
def _summary(metrics: Mapping[str, Any]) -> str:
|
| 77 |
-
return (
|
| 78 |
-
"BENCHMARK SUMMARY\n"
|
| 79 |
-
f"- total claims: {metrics['total_claims']}\n"
|
| 80 |
-
f"- automatic disposition rate: {metrics['automatic_disposition_rate']:.2f}\n"
|
| 81 |
-
f"- human attention queue: {metrics['human_attention_queue_count']} "
|
| 82 |
-
f"({metrics['human_attention_rate']:.2f})\n"
|
| 83 |
-
f"- evidence coverage: {metrics['evidence_coverage']:.2f}\n"
|
| 84 |
-
f"- unsupported-claim block rate: {metrics['unsupported_claim_block_rate']:.2f}\n"
|
| 85 |
-
f"- contradiction detection rate: {metrics['contradiction_detection_rate']:.2f}\n"
|
| 86 |
-
f"- action-completion false-positive rate: "
|
| 87 |
-
f"{metrics['action_completion_false_positive_rate']:.2f}\n"
|
| 88 |
-
f"- high-risk unauthorized approve rate: "
|
| 89 |
-
f"{metrics['high_risk_without_human_approval_approve_rate']:.2f}\n"
|
| 90 |
-
"Human attention is reported only as a routing count/rate; no timed human-effort result has been measured."
|
| 91 |
-
)
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
def evaluate_benchmark(
|
| 95 |
-
fixture: Mapping[str, Any],
|
| 96 |
-
benchmark: Mapping[str, Any],
|
| 97 |
-
) -> dict[str, Any]:
|
| 98 |
-
fixture_obj = _mapping(fixture, "fixture")
|
| 99 |
-
benchmark_obj = _mapping(benchmark, "benchmark")
|
| 100 |
-
labels = _validate_labels(fixture_obj, benchmark_obj)
|
| 101 |
-
|
| 102 |
-
demo = run_research_demo(fixture_obj)
|
| 103 |
-
results = demo["results"]
|
| 104 |
-
by_id = {
|
| 105 |
-
item["receipt"]["claim"]["id"]: item
|
| 106 |
-
for item in results
|
| 107 |
-
}
|
| 108 |
-
|
| 109 |
-
total = len(results)
|
| 110 |
-
auto_approved_count = sum(
|
| 111 |
-
1 for item in results if item["verdict"] == "APPROVE"
|
| 112 |
-
)
|
| 113 |
-
auto_blocked_count = sum(
|
| 114 |
-
1 for item in results if item["verdict"] == "BLOCK"
|
| 115 |
-
)
|
| 116 |
-
escalated_count = sum(
|
| 117 |
-
1 for item in results if item["verdict"] == "ESCALATE"
|
| 118 |
-
)
|
| 119 |
-
|
| 120 |
-
human_attention_claim_ids = [
|
| 121 |
-
item["receipt"]["claim"]["id"]
|
| 122 |
-
for item in results
|
| 123 |
-
if item["classification"] == "CONFLICT"
|
| 124 |
-
or item["verdict"] == "ESCALATE"
|
| 125 |
-
]
|
| 126 |
-
human_attention_queue_count = len(human_attention_claim_ids)
|
| 127 |
-
automatic_disposition_count = total - human_attention_queue_count
|
| 128 |
-
|
| 129 |
-
unsupported_ids = _claim_ids_with_label(labels, "unsupported")
|
| 130 |
-
contradiction_ids = _claim_ids_with_label(labels, "contradiction")
|
| 131 |
-
action_ids = _claim_ids_with_label(labels, "action_without_post_state")
|
| 132 |
-
high_risk_ids = _claim_ids_with_label(
|
| 133 |
-
labels,
|
| 134 |
-
"high_risk_without_human_approval",
|
| 135 |
-
)
|
| 136 |
-
|
| 137 |
-
unsupported_blocked = sum(
|
| 138 |
-
1 for claim_id in unsupported_ids if by_id[claim_id]["verdict"] == "BLOCK"
|
| 139 |
-
)
|
| 140 |
-
contradiction_detected = sum(
|
| 141 |
-
1
|
| 142 |
-
for claim_id in contradiction_ids
|
| 143 |
-
if by_id[claim_id]["classification"] == "CONFLICT"
|
| 144 |
-
)
|
| 145 |
-
action_false_positive_approves = sum(
|
| 146 |
-
1 for claim_id in action_ids if by_id[claim_id]["verdict"] == "APPROVE"
|
| 147 |
-
)
|
| 148 |
-
high_risk_unauthorized_approves = sum(
|
| 149 |
-
1
|
| 150 |
-
for claim_id in high_risk_ids
|
| 151 |
-
if by_id[claim_id]["verdict"] == "APPROVE"
|
| 152 |
-
)
|
| 153 |
-
|
| 154 |
-
metrics = {
|
| 155 |
-
"total_claims": total,
|
| 156 |
-
"auto_approved_count": auto_approved_count,
|
| 157 |
-
"auto_blocked_count": auto_blocked_count,
|
| 158 |
-
"escalated_count": escalated_count,
|
| 159 |
-
"human_attention_queue_count": human_attention_queue_count,
|
| 160 |
-
"human_attention_rate": _rate(human_attention_queue_count, total),
|
| 161 |
-
"automatic_disposition_count": automatic_disposition_count,
|
| 162 |
-
"automatic_disposition_rate": _rate(automatic_disposition_count, total),
|
| 163 |
-
"evidence_coverage": demo["metrics"]["evidence_coverage"],
|
| 164 |
-
"unsupported_claim_block_rate": _rate(
|
| 165 |
-
unsupported_blocked,
|
| 166 |
-
len(unsupported_ids),
|
| 167 |
-
),
|
| 168 |
-
"contradiction_detection_rate": _rate(
|
| 169 |
-
contradiction_detected,
|
| 170 |
-
len(contradiction_ids),
|
| 171 |
-
),
|
| 172 |
-
"action_completion_false_positive_rate": _rate(
|
| 173 |
-
action_false_positive_approves,
|
| 174 |
-
len(action_ids),
|
| 175 |
-
),
|
| 176 |
-
"high_risk_without_human_approval_approve_rate": _rate(
|
| 177 |
-
high_risk_unauthorized_approves,
|
| 178 |
-
len(high_risk_ids),
|
| 179 |
-
),
|
| 180 |
-
}
|
| 181 |
-
|
| 182 |
-
return {
|
| 183 |
-
"benchmark_version": benchmark_obj.get("version"),
|
| 184 |
-
"research_request": demo["research_request"],
|
| 185 |
-
"algorithm_version": demo["algorithm_version"],
|
| 186 |
-
"schema_version": demo["schema_version"],
|
| 187 |
-
"policy_version": demo["policy_version"],
|
| 188 |
-
"labels": labels,
|
| 189 |
-
"metrics": metrics,
|
| 190 |
-
"human_attention_claim_ids": human_attention_claim_ids,
|
| 191 |
-
"human_summary": _summary(metrics),
|
| 192 |
}
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any, Mapping
|
| 4 |
+
|
| 5 |
+
from .research_demo import run_research_demo
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
_ALLOWED_LABELS = {
|
| 9 |
+
"supported",
|
| 10 |
+
"unsupported",
|
| 11 |
+
"contradiction",
|
| 12 |
+
"action_without_post_state",
|
| 13 |
+
"high_risk_without_human_approval",
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _mapping(value: Any, path: str) -> Mapping[str, Any]:
|
| 18 |
+
if not isinstance(value, Mapping):
|
| 19 |
+
raise TypeError(f"{path} must be object")
|
| 20 |
+
return value
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _rate(numerator: int, denominator: int) -> float:
|
| 24 |
+
return (numerator / denominator) if denominator else 0.0
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _validate_labels(
|
| 28 |
+
fixture: Mapping[str, Any],
|
| 29 |
+
benchmark: Mapping[str, Any],
|
| 30 |
+
) -> dict[str, str]:
|
| 31 |
+
claims = fixture.get("claims")
|
| 32 |
+
if not isinstance(claims, list):
|
| 33 |
+
raise TypeError("fixture.claims must be list")
|
| 34 |
+
|
| 35 |
+
claim_ids: list[str] = []
|
| 36 |
+
for index, raw in enumerate(claims):
|
| 37 |
+
claim = _mapping(raw, f"fixture.claims[{index}]")
|
| 38 |
+
claim_id = claim.get("id")
|
| 39 |
+
if not isinstance(claim_id, str) or not claim_id:
|
| 40 |
+
raise TypeError(f"fixture.claims[{index}].id must be non-empty string")
|
| 41 |
+
claim_ids.append(claim_id)
|
| 42 |
+
|
| 43 |
+
if len(claim_ids) != len(set(claim_ids)):
|
| 44 |
+
raise ValueError("fixture contains duplicate claim id")
|
| 45 |
+
|
| 46 |
+
labels_raw = _mapping(benchmark.get("labels"), "benchmark.labels")
|
| 47 |
+
unknown_ids = sorted(set(labels_raw) - set(claim_ids))
|
| 48 |
+
if unknown_ids:
|
| 49 |
+
raise ValueError("benchmark label references unknown claim: " + ", ".join(unknown_ids))
|
| 50 |
+
|
| 51 |
+
missing_ids = [claim_id for claim_id in claim_ids if claim_id not in labels_raw]
|
| 52 |
+
if missing_ids:
|
| 53 |
+
raise ValueError("missing benchmark label for claim: " + ", ".join(missing_ids))
|
| 54 |
+
|
| 55 |
+
labels: dict[str, str] = {}
|
| 56 |
+
for claim_id in claim_ids:
|
| 57 |
+
label = labels_raw[claim_id]
|
| 58 |
+
if not isinstance(label, str) or label not in _ALLOWED_LABELS:
|
| 59 |
+
raise ValueError(f"invalid benchmark label kind for {claim_id}: {label}")
|
| 60 |
+
labels[claim_id] = label
|
| 61 |
+
|
| 62 |
+
missing_kinds = sorted(_ALLOWED_LABELS - set(labels.values()))
|
| 63 |
+
if missing_kinds:
|
| 64 |
+
raise ValueError(
|
| 65 |
+
"benchmark must contain every benchmark label kind: "
|
| 66 |
+
+ ", ".join(missing_kinds)
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
return labels
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _claim_ids_with_label(labels: Mapping[str, str], kind: str) -> list[str]:
|
| 73 |
+
return [claim_id for claim_id, label in labels.items() if label == kind]
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _summary(metrics: Mapping[str, Any]) -> str:
|
| 77 |
+
return (
|
| 78 |
+
"BENCHMARK SUMMARY\n"
|
| 79 |
+
f"- total claims: {metrics['total_claims']}\n"
|
| 80 |
+
f"- automatic disposition rate: {metrics['automatic_disposition_rate']:.2f}\n"
|
| 81 |
+
f"- human attention queue: {metrics['human_attention_queue_count']} "
|
| 82 |
+
f"({metrics['human_attention_rate']:.2f})\n"
|
| 83 |
+
f"- evidence coverage: {metrics['evidence_coverage']:.2f}\n"
|
| 84 |
+
f"- unsupported-claim block rate: {metrics['unsupported_claim_block_rate']:.2f}\n"
|
| 85 |
+
f"- contradiction detection rate: {metrics['contradiction_detection_rate']:.2f}\n"
|
| 86 |
+
f"- action-completion false-positive rate: "
|
| 87 |
+
f"{metrics['action_completion_false_positive_rate']:.2f}\n"
|
| 88 |
+
f"- high-risk unauthorized approve rate: "
|
| 89 |
+
f"{metrics['high_risk_without_human_approval_approve_rate']:.2f}\n"
|
| 90 |
+
"Human attention is reported only as a routing count/rate; no timed human-effort result has been measured."
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def evaluate_benchmark(
|
| 95 |
+
fixture: Mapping[str, Any],
|
| 96 |
+
benchmark: Mapping[str, Any],
|
| 97 |
+
) -> dict[str, Any]:
|
| 98 |
+
fixture_obj = _mapping(fixture, "fixture")
|
| 99 |
+
benchmark_obj = _mapping(benchmark, "benchmark")
|
| 100 |
+
labels = _validate_labels(fixture_obj, benchmark_obj)
|
| 101 |
+
|
| 102 |
+
demo = run_research_demo(fixture_obj)
|
| 103 |
+
results = demo["results"]
|
| 104 |
+
by_id = {
|
| 105 |
+
item["receipt"]["claim"]["id"]: item
|
| 106 |
+
for item in results
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
total = len(results)
|
| 110 |
+
auto_approved_count = sum(
|
| 111 |
+
1 for item in results if item["verdict"] == "APPROVE"
|
| 112 |
+
)
|
| 113 |
+
auto_blocked_count = sum(
|
| 114 |
+
1 for item in results if item["verdict"] == "BLOCK"
|
| 115 |
+
)
|
| 116 |
+
escalated_count = sum(
|
| 117 |
+
1 for item in results if item["verdict"] == "ESCALATE"
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
human_attention_claim_ids = [
|
| 121 |
+
item["receipt"]["claim"]["id"]
|
| 122 |
+
for item in results
|
| 123 |
+
if item["classification"] == "CONFLICT"
|
| 124 |
+
or item["verdict"] == "ESCALATE"
|
| 125 |
+
]
|
| 126 |
+
human_attention_queue_count = len(human_attention_claim_ids)
|
| 127 |
+
automatic_disposition_count = total - human_attention_queue_count
|
| 128 |
+
|
| 129 |
+
unsupported_ids = _claim_ids_with_label(labels, "unsupported")
|
| 130 |
+
contradiction_ids = _claim_ids_with_label(labels, "contradiction")
|
| 131 |
+
action_ids = _claim_ids_with_label(labels, "action_without_post_state")
|
| 132 |
+
high_risk_ids = _claim_ids_with_label(
|
| 133 |
+
labels,
|
| 134 |
+
"high_risk_without_human_approval",
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
unsupported_blocked = sum(
|
| 138 |
+
1 for claim_id in unsupported_ids if by_id[claim_id]["verdict"] == "BLOCK"
|
| 139 |
+
)
|
| 140 |
+
contradiction_detected = sum(
|
| 141 |
+
1
|
| 142 |
+
for claim_id in contradiction_ids
|
| 143 |
+
if by_id[claim_id]["classification"] == "CONFLICT"
|
| 144 |
+
)
|
| 145 |
+
action_false_positive_approves = sum(
|
| 146 |
+
1 for claim_id in action_ids if by_id[claim_id]["verdict"] == "APPROVE"
|
| 147 |
+
)
|
| 148 |
+
high_risk_unauthorized_approves = sum(
|
| 149 |
+
1
|
| 150 |
+
for claim_id in high_risk_ids
|
| 151 |
+
if by_id[claim_id]["verdict"] == "APPROVE"
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
metrics = {
|
| 155 |
+
"total_claims": total,
|
| 156 |
+
"auto_approved_count": auto_approved_count,
|
| 157 |
+
"auto_blocked_count": auto_blocked_count,
|
| 158 |
+
"escalated_count": escalated_count,
|
| 159 |
+
"human_attention_queue_count": human_attention_queue_count,
|
| 160 |
+
"human_attention_rate": _rate(human_attention_queue_count, total),
|
| 161 |
+
"automatic_disposition_count": automatic_disposition_count,
|
| 162 |
+
"automatic_disposition_rate": _rate(automatic_disposition_count, total),
|
| 163 |
+
"evidence_coverage": demo["metrics"]["evidence_coverage"],
|
| 164 |
+
"unsupported_claim_block_rate": _rate(
|
| 165 |
+
unsupported_blocked,
|
| 166 |
+
len(unsupported_ids),
|
| 167 |
+
),
|
| 168 |
+
"contradiction_detection_rate": _rate(
|
| 169 |
+
contradiction_detected,
|
| 170 |
+
len(contradiction_ids),
|
| 171 |
+
),
|
| 172 |
+
"action_completion_false_positive_rate": _rate(
|
| 173 |
+
action_false_positive_approves,
|
| 174 |
+
len(action_ids),
|
| 175 |
+
),
|
| 176 |
+
"high_risk_without_human_approval_approve_rate": _rate(
|
| 177 |
+
high_risk_unauthorized_approves,
|
| 178 |
+
len(high_risk_ids),
|
| 179 |
+
),
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
return {
|
| 183 |
+
"benchmark_version": benchmark_obj.get("version"),
|
| 184 |
+
"research_request": demo["research_request"],
|
| 185 |
+
"algorithm_version": demo["algorithm_version"],
|
| 186 |
+
"schema_version": demo["schema_version"],
|
| 187 |
+
"policy_version": demo["policy_version"],
|
| 188 |
+
"labels": labels,
|
| 189 |
+
"metrics": metrics,
|
| 190 |
+
"human_attention_claim_ids": human_attention_claim_ids,
|
| 191 |
+
"human_summary": _summary(metrics),
|
| 192 |
}
|
bodulskihal_core/guard_loop.py
CHANGED
|
@@ -1,255 +1,255 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
from dataclasses import dataclass
|
| 4 |
-
from datetime import datetime
|
| 5 |
-
from enum import Enum
|
| 6 |
-
from typing import Iterable
|
| 7 |
-
|
| 8 |
-
from .engine import (
|
| 9 |
-
Claim,
|
| 10 |
-
Classification,
|
| 11 |
-
Evidence,
|
| 12 |
-
Policy,
|
| 13 |
-
VerificationResult,
|
| 14 |
-
Verdict,
|
| 15 |
-
verify_claim,
|
| 16 |
-
)
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
GUARD_VERSION = "2.0.0"
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
class Realism(str, Enum):
|
| 23 |
-
POSSIBLE = "possible"
|
| 24 |
-
UNCERTAIN = "uncertain"
|
| 25 |
-
IMPOSSIBLE = "impossible"
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
class GuardStage(str, Enum):
|
| 29 |
-
SELF_CHECK = "self_check"
|
| 30 |
-
VERIFICATION = "verification"
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
class UnknownSeverity(str, Enum):
|
| 34 |
-
LOW = "low"
|
| 35 |
-
MEDIUM = "medium"
|
| 36 |
-
HIGH = "high"
|
| 37 |
-
CRITICAL = "critical"
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
@dataclass(frozen=True)
|
| 41 |
-
class UnknownRecord:
|
| 42 |
-
description: str
|
| 43 |
-
severity: UnknownSeverity = UnknownSeverity.MEDIUM
|
| 44 |
-
reviewed: bool = False
|
| 45 |
-
resolved: bool = False
|
| 46 |
-
blocking: bool = False
|
| 47 |
-
resolution_reference: str | None = None
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
@dataclass(frozen=True)
|
| 51 |
-
class SelfCheck:
|
| 52 |
-
realism: Realism
|
| 53 |
-
known: tuple[str, ...]
|
| 54 |
-
assumptions: tuple[str, ...]
|
| 55 |
-
unknowns: tuple[str, ...]
|
| 56 |
-
assumptions_reviewed: bool
|
| 57 |
-
unknowns_reviewed: bool
|
| 58 |
-
unknown_records: tuple[UnknownRecord, ...] = ()
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
@dataclass(frozen=True)
|
| 62 |
-
class GuardResult:
|
| 63 |
-
stage: GuardStage
|
| 64 |
-
classification: Classification
|
| 65 |
-
verdict: Verdict
|
| 66 |
-
reason: str
|
| 67 |
-
verification: VerificationResult | None
|
| 68 |
-
receipt: dict
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
def _validate_self_check(check: SelfCheck) -> None:
|
| 72 |
-
if not isinstance(check, SelfCheck):
|
| 73 |
-
raise TypeError("self_check must be SelfCheck")
|
| 74 |
-
if not isinstance(check.realism, Realism):
|
| 75 |
-
raise TypeError("self_check.realism must be Realism")
|
| 76 |
-
if type(check.assumptions_reviewed) is not bool:
|
| 77 |
-
raise TypeError("assumptions_reviewed must be bool")
|
| 78 |
-
if type(check.unknowns_reviewed) is not bool:
|
| 79 |
-
raise TypeError("unknowns_reviewed must be bool")
|
| 80 |
-
|
| 81 |
-
for field_name, values in (
|
| 82 |
-
("known", check.known),
|
| 83 |
-
("assumptions", check.assumptions),
|
| 84 |
-
("unknowns", check.unknowns),
|
| 85 |
-
):
|
| 86 |
-
if not isinstance(values, tuple):
|
| 87 |
-
raise TypeError(f"{field_name} must be tuple")
|
| 88 |
-
if any(not isinstance(item, str) or not item.strip() for item in values):
|
| 89 |
-
raise ValueError(f"{field_name} items must be non-empty strings")
|
| 90 |
-
|
| 91 |
-
if not isinstance(check.unknown_records, tuple):
|
| 92 |
-
raise TypeError("unknown_records must be tuple")
|
| 93 |
-
for item in check.unknown_records:
|
| 94 |
-
if not isinstance(item, UnknownRecord):
|
| 95 |
-
raise TypeError("unknown_records must contain UnknownRecord values")
|
| 96 |
-
if not isinstance(item.description, str) or not item.description.strip():
|
| 97 |
-
raise ValueError("unknown record description must be non-empty")
|
| 98 |
-
if not isinstance(item.severity, UnknownSeverity):
|
| 99 |
-
raise TypeError("unknown record severity must be UnknownSeverity")
|
| 100 |
-
if type(item.reviewed) is not bool:
|
| 101 |
-
raise TypeError("unknown record reviewed must be bool")
|
| 102 |
-
if type(item.resolved) is not bool:
|
| 103 |
-
raise TypeError("unknown record resolved must be bool")
|
| 104 |
-
if type(item.blocking) is not bool:
|
| 105 |
-
raise TypeError("unknown record blocking must be bool")
|
| 106 |
-
if item.resolution_reference is not None and (
|
| 107 |
-
not isinstance(item.resolution_reference, str)
|
| 108 |
-
or not item.resolution_reference.strip()
|
| 109 |
-
):
|
| 110 |
-
raise ValueError("resolution_reference must be non-empty when supplied")
|
| 111 |
-
if item.resolved and (item.blocking or item.severity is UnknownSeverity.CRITICAL):
|
| 112 |
-
if item.resolution_reference is None:
|
| 113 |
-
raise ValueError(
|
| 114 |
-
"resolved blocking or critical unknown requires resolution_reference"
|
| 115 |
-
)
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
def _self_check_payload(check: SelfCheck) -> dict:
|
| 119 |
-
return {
|
| 120 |
-
"realism": check.realism.value,
|
| 121 |
-
"known": list(check.known),
|
| 122 |
-
"assumptions": list(check.assumptions),
|
| 123 |
-
"unknowns": list(check.unknowns),
|
| 124 |
-
"assumptions_reviewed": check.assumptions_reviewed,
|
| 125 |
-
"unknowns_reviewed": check.unknowns_reviewed,
|
| 126 |
-
"unknown_records": [
|
| 127 |
-
{
|
| 128 |
-
"description": item.description,
|
| 129 |
-
"severity": item.severity.value,
|
| 130 |
-
"reviewed": item.reviewed,
|
| 131 |
-
"resolved": item.resolved,
|
| 132 |
-
"blocking": item.blocking,
|
| 133 |
-
"resolution_reference": item.resolution_reference,
|
| 134 |
-
}
|
| 135 |
-
for item in check.unknown_records
|
| 136 |
-
],
|
| 137 |
-
}
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
def _self_check_result(
|
| 141 |
-
*,
|
| 142 |
-
claim: Claim,
|
| 143 |
-
check: SelfCheck,
|
| 144 |
-
verdict: Verdict,
|
| 145 |
-
reason: str,
|
| 146 |
-
) -> GuardResult:
|
| 147 |
-
receipt = {
|
| 148 |
-
"guard_version": GUARD_VERSION,
|
| 149 |
-
"claim_id": claim.claim_id,
|
| 150 |
-
"stage": GuardStage.SELF_CHECK.value,
|
| 151 |
-
"self_check": _self_check_payload(check),
|
| 152 |
-
"verification": None,
|
| 153 |
-
"classification": Classification.UNKNOWN.value,
|
| 154 |
-
"verdict": verdict.value,
|
| 155 |
-
"reason": reason,
|
| 156 |
-
}
|
| 157 |
-
return GuardResult(
|
| 158 |
-
stage=GuardStage.SELF_CHECK,
|
| 159 |
-
classification=Classification.UNKNOWN,
|
| 160 |
-
verdict=verdict,
|
| 161 |
-
reason=reason,
|
| 162 |
-
verification=None,
|
| 163 |
-
receipt=receipt,
|
| 164 |
-
)
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
def run_guard_loop(
|
| 168 |
-
*,
|
| 169 |
-
claim: Claim,
|
| 170 |
-
evidence: Iterable[Evidence],
|
| 171 |
-
self_check: SelfCheck,
|
| 172 |
-
policy: Policy,
|
| 173 |
-
evaluation_time: datetime,
|
| 174 |
-
human_approved: bool = False,
|
| 175 |
-
) -> GuardResult:
|
| 176 |
-
_validate_self_check(self_check)
|
| 177 |
-
|
| 178 |
-
if self_check.realism is Realism.IMPOSSIBLE:
|
| 179 |
-
return _self_check_result(
|
| 180 |
-
claim=claim,
|
| 181 |
-
check=self_check,
|
| 182 |
-
verdict=Verdict.BLOCK,
|
| 183 |
-
reason="self_check_impossible",
|
| 184 |
-
)
|
| 185 |
-
|
| 186 |
-
if self_check.realism is Realism.UNCERTAIN:
|
| 187 |
-
return _self_check_result(
|
| 188 |
-
claim=claim,
|
| 189 |
-
check=self_check,
|
| 190 |
-
verdict=Verdict.ESCALATE,
|
| 191 |
-
reason="self_check_uncertain",
|
| 192 |
-
)
|
| 193 |
-
|
| 194 |
-
unresolved_blocking = tuple(
|
| 195 |
-
item
|
| 196 |
-
for item in self_check.unknown_records
|
| 197 |
-
if not item.resolved
|
| 198 |
-
and (item.blocking or item.severity is UnknownSeverity.CRITICAL)
|
| 199 |
-
)
|
| 200 |
-
if unresolved_blocking:
|
| 201 |
-
return _self_check_result(
|
| 202 |
-
claim=claim,
|
| 203 |
-
check=self_check,
|
| 204 |
-
verdict=Verdict.ESCALATE,
|
| 205 |
-
reason="blocking_unknown_unresolved",
|
| 206 |
-
)
|
| 207 |
-
|
| 208 |
-
if not self_check.assumptions_reviewed:
|
| 209 |
-
return _self_check_result(
|
| 210 |
-
claim=claim,
|
| 211 |
-
check=self_check,
|
| 212 |
-
verdict=Verdict.ESCALATE,
|
| 213 |
-
reason="assumptions_not_reviewed",
|
| 214 |
-
)
|
| 215 |
-
|
| 216 |
-
if not self_check.unknowns_reviewed:
|
| 217 |
-
return _self_check_result(
|
| 218 |
-
claim=claim,
|
| 219 |
-
check=self_check,
|
| 220 |
-
verdict=Verdict.ESCALATE,
|
| 221 |
-
reason="unknowns_not_reviewed",
|
| 222 |
-
)
|
| 223 |
-
|
| 224 |
-
verification = verify_claim(
|
| 225 |
-
claim,
|
| 226 |
-
evidence,
|
| 227 |
-
policy=policy,
|
| 228 |
-
evaluation_time=evaluation_time,
|
| 229 |
-
human_approved=human_approved,
|
| 230 |
-
)
|
| 231 |
-
|
| 232 |
-
receipt = {
|
| 233 |
-
"guard_version": GUARD_VERSION,
|
| 234 |
-
"claim_id": claim.claim_id,
|
| 235 |
-
"stage": GuardStage.VERIFICATION.value,
|
| 236 |
-
"self_check": _self_check_payload(self_check),
|
| 237 |
-
"verification": {
|
| 238 |
-
"classification": verification.classification.value,
|
| 239 |
-
"verdict": verification.verdict.value,
|
| 240 |
-
"reason": verification.reason,
|
| 241 |
-
"receipt": verification.receipt,
|
| 242 |
-
},
|
| 243 |
-
"classification": verification.classification.value,
|
| 244 |
-
"verdict": verification.verdict.value,
|
| 245 |
-
"reason": verification.reason,
|
| 246 |
-
}
|
| 247 |
-
|
| 248 |
-
return GuardResult(
|
| 249 |
-
stage=GuardStage.VERIFICATION,
|
| 250 |
-
classification=verification.classification,
|
| 251 |
-
verdict=verification.verdict,
|
| 252 |
-
reason=verification.reason,
|
| 253 |
-
verification=verification,
|
| 254 |
-
receipt=receipt,
|
| 255 |
)
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
from enum import Enum
|
| 6 |
+
from typing import Iterable
|
| 7 |
+
|
| 8 |
+
from .engine import (
|
| 9 |
+
Claim,
|
| 10 |
+
Classification,
|
| 11 |
+
Evidence,
|
| 12 |
+
Policy,
|
| 13 |
+
VerificationResult,
|
| 14 |
+
Verdict,
|
| 15 |
+
verify_claim,
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
GUARD_VERSION = "2.0.0"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class Realism(str, Enum):
|
| 23 |
+
POSSIBLE = "possible"
|
| 24 |
+
UNCERTAIN = "uncertain"
|
| 25 |
+
IMPOSSIBLE = "impossible"
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class GuardStage(str, Enum):
|
| 29 |
+
SELF_CHECK = "self_check"
|
| 30 |
+
VERIFICATION = "verification"
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class UnknownSeverity(str, Enum):
|
| 34 |
+
LOW = "low"
|
| 35 |
+
MEDIUM = "medium"
|
| 36 |
+
HIGH = "high"
|
| 37 |
+
CRITICAL = "critical"
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@dataclass(frozen=True)
|
| 41 |
+
class UnknownRecord:
|
| 42 |
+
description: str
|
| 43 |
+
severity: UnknownSeverity = UnknownSeverity.MEDIUM
|
| 44 |
+
reviewed: bool = False
|
| 45 |
+
resolved: bool = False
|
| 46 |
+
blocking: bool = False
|
| 47 |
+
resolution_reference: str | None = None
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@dataclass(frozen=True)
|
| 51 |
+
class SelfCheck:
|
| 52 |
+
realism: Realism
|
| 53 |
+
known: tuple[str, ...]
|
| 54 |
+
assumptions: tuple[str, ...]
|
| 55 |
+
unknowns: tuple[str, ...]
|
| 56 |
+
assumptions_reviewed: bool
|
| 57 |
+
unknowns_reviewed: bool
|
| 58 |
+
unknown_records: tuple[UnknownRecord, ...] = ()
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@dataclass(frozen=True)
|
| 62 |
+
class GuardResult:
|
| 63 |
+
stage: GuardStage
|
| 64 |
+
classification: Classification
|
| 65 |
+
verdict: Verdict
|
| 66 |
+
reason: str
|
| 67 |
+
verification: VerificationResult | None
|
| 68 |
+
receipt: dict
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _validate_self_check(check: SelfCheck) -> None:
|
| 72 |
+
if not isinstance(check, SelfCheck):
|
| 73 |
+
raise TypeError("self_check must be SelfCheck")
|
| 74 |
+
if not isinstance(check.realism, Realism):
|
| 75 |
+
raise TypeError("self_check.realism must be Realism")
|
| 76 |
+
if type(check.assumptions_reviewed) is not bool:
|
| 77 |
+
raise TypeError("assumptions_reviewed must be bool")
|
| 78 |
+
if type(check.unknowns_reviewed) is not bool:
|
| 79 |
+
raise TypeError("unknowns_reviewed must be bool")
|
| 80 |
+
|
| 81 |
+
for field_name, values in (
|
| 82 |
+
("known", check.known),
|
| 83 |
+
("assumptions", check.assumptions),
|
| 84 |
+
("unknowns", check.unknowns),
|
| 85 |
+
):
|
| 86 |
+
if not isinstance(values, tuple):
|
| 87 |
+
raise TypeError(f"{field_name} must be tuple")
|
| 88 |
+
if any(not isinstance(item, str) or not item.strip() for item in values):
|
| 89 |
+
raise ValueError(f"{field_name} items must be non-empty strings")
|
| 90 |
+
|
| 91 |
+
if not isinstance(check.unknown_records, tuple):
|
| 92 |
+
raise TypeError("unknown_records must be tuple")
|
| 93 |
+
for item in check.unknown_records:
|
| 94 |
+
if not isinstance(item, UnknownRecord):
|
| 95 |
+
raise TypeError("unknown_records must contain UnknownRecord values")
|
| 96 |
+
if not isinstance(item.description, str) or not item.description.strip():
|
| 97 |
+
raise ValueError("unknown record description must be non-empty")
|
| 98 |
+
if not isinstance(item.severity, UnknownSeverity):
|
| 99 |
+
raise TypeError("unknown record severity must be UnknownSeverity")
|
| 100 |
+
if type(item.reviewed) is not bool:
|
| 101 |
+
raise TypeError("unknown record reviewed must be bool")
|
| 102 |
+
if type(item.resolved) is not bool:
|
| 103 |
+
raise TypeError("unknown record resolved must be bool")
|
| 104 |
+
if type(item.blocking) is not bool:
|
| 105 |
+
raise TypeError("unknown record blocking must be bool")
|
| 106 |
+
if item.resolution_reference is not None and (
|
| 107 |
+
not isinstance(item.resolution_reference, str)
|
| 108 |
+
or not item.resolution_reference.strip()
|
| 109 |
+
):
|
| 110 |
+
raise ValueError("resolution_reference must be non-empty when supplied")
|
| 111 |
+
if item.resolved and (item.blocking or item.severity is UnknownSeverity.CRITICAL):
|
| 112 |
+
if item.resolution_reference is None:
|
| 113 |
+
raise ValueError(
|
| 114 |
+
"resolved blocking or critical unknown requires resolution_reference"
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def _self_check_payload(check: SelfCheck) -> dict:
|
| 119 |
+
return {
|
| 120 |
+
"realism": check.realism.value,
|
| 121 |
+
"known": list(check.known),
|
| 122 |
+
"assumptions": list(check.assumptions),
|
| 123 |
+
"unknowns": list(check.unknowns),
|
| 124 |
+
"assumptions_reviewed": check.assumptions_reviewed,
|
| 125 |
+
"unknowns_reviewed": check.unknowns_reviewed,
|
| 126 |
+
"unknown_records": [
|
| 127 |
+
{
|
| 128 |
+
"description": item.description,
|
| 129 |
+
"severity": item.severity.value,
|
| 130 |
+
"reviewed": item.reviewed,
|
| 131 |
+
"resolved": item.resolved,
|
| 132 |
+
"blocking": item.blocking,
|
| 133 |
+
"resolution_reference": item.resolution_reference,
|
| 134 |
+
}
|
| 135 |
+
for item in check.unknown_records
|
| 136 |
+
],
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def _self_check_result(
|
| 141 |
+
*,
|
| 142 |
+
claim: Claim,
|
| 143 |
+
check: SelfCheck,
|
| 144 |
+
verdict: Verdict,
|
| 145 |
+
reason: str,
|
| 146 |
+
) -> GuardResult:
|
| 147 |
+
receipt = {
|
| 148 |
+
"guard_version": GUARD_VERSION,
|
| 149 |
+
"claim_id": claim.claim_id,
|
| 150 |
+
"stage": GuardStage.SELF_CHECK.value,
|
| 151 |
+
"self_check": _self_check_payload(check),
|
| 152 |
+
"verification": None,
|
| 153 |
+
"classification": Classification.UNKNOWN.value,
|
| 154 |
+
"verdict": verdict.value,
|
| 155 |
+
"reason": reason,
|
| 156 |
+
}
|
| 157 |
+
return GuardResult(
|
| 158 |
+
stage=GuardStage.SELF_CHECK,
|
| 159 |
+
classification=Classification.UNKNOWN,
|
| 160 |
+
verdict=verdict,
|
| 161 |
+
reason=reason,
|
| 162 |
+
verification=None,
|
| 163 |
+
receipt=receipt,
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def run_guard_loop(
|
| 168 |
+
*,
|
| 169 |
+
claim: Claim,
|
| 170 |
+
evidence: Iterable[Evidence],
|
| 171 |
+
self_check: SelfCheck,
|
| 172 |
+
policy: Policy,
|
| 173 |
+
evaluation_time: datetime,
|
| 174 |
+
human_approved: bool = False,
|
| 175 |
+
) -> GuardResult:
|
| 176 |
+
_validate_self_check(self_check)
|
| 177 |
+
|
| 178 |
+
if self_check.realism is Realism.IMPOSSIBLE:
|
| 179 |
+
return _self_check_result(
|
| 180 |
+
claim=claim,
|
| 181 |
+
check=self_check,
|
| 182 |
+
verdict=Verdict.BLOCK,
|
| 183 |
+
reason="self_check_impossible",
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
if self_check.realism is Realism.UNCERTAIN:
|
| 187 |
+
return _self_check_result(
|
| 188 |
+
claim=claim,
|
| 189 |
+
check=self_check,
|
| 190 |
+
verdict=Verdict.ESCALATE,
|
| 191 |
+
reason="self_check_uncertain",
|
| 192 |
+
)
|
| 193 |
+
|
| 194 |
+
unresolved_blocking = tuple(
|
| 195 |
+
item
|
| 196 |
+
for item in self_check.unknown_records
|
| 197 |
+
if not item.resolved
|
| 198 |
+
and (item.blocking or item.severity is UnknownSeverity.CRITICAL)
|
| 199 |
+
)
|
| 200 |
+
if unresolved_blocking:
|
| 201 |
+
return _self_check_result(
|
| 202 |
+
claim=claim,
|
| 203 |
+
check=self_check,
|
| 204 |
+
verdict=Verdict.ESCALATE,
|
| 205 |
+
reason="blocking_unknown_unresolved",
|
| 206 |
+
)
|
| 207 |
+
|
| 208 |
+
if not self_check.assumptions_reviewed:
|
| 209 |
+
return _self_check_result(
|
| 210 |
+
claim=claim,
|
| 211 |
+
check=self_check,
|
| 212 |
+
verdict=Verdict.ESCALATE,
|
| 213 |
+
reason="assumptions_not_reviewed",
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
if not self_check.unknowns_reviewed:
|
| 217 |
+
return _self_check_result(
|
| 218 |
+
claim=claim,
|
| 219 |
+
check=self_check,
|
| 220 |
+
verdict=Verdict.ESCALATE,
|
| 221 |
+
reason="unknowns_not_reviewed",
|
| 222 |
+
)
|
| 223 |
+
|
| 224 |
+
verification = verify_claim(
|
| 225 |
+
claim,
|
| 226 |
+
evidence,
|
| 227 |
+
policy=policy,
|
| 228 |
+
evaluation_time=evaluation_time,
|
| 229 |
+
human_approved=human_approved,
|
| 230 |
+
)
|
| 231 |
+
|
| 232 |
+
receipt = {
|
| 233 |
+
"guard_version": GUARD_VERSION,
|
| 234 |
+
"claim_id": claim.claim_id,
|
| 235 |
+
"stage": GuardStage.VERIFICATION.value,
|
| 236 |
+
"self_check": _self_check_payload(self_check),
|
| 237 |
+
"verification": {
|
| 238 |
+
"classification": verification.classification.value,
|
| 239 |
+
"verdict": verification.verdict.value,
|
| 240 |
+
"reason": verification.reason,
|
| 241 |
+
"receipt": verification.receipt,
|
| 242 |
+
},
|
| 243 |
+
"classification": verification.classification.value,
|
| 244 |
+
"verdict": verification.verdict.value,
|
| 245 |
+
"reason": verification.reason,
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
return GuardResult(
|
| 249 |
+
stage=GuardStage.VERIFICATION,
|
| 250 |
+
classification=verification.classification,
|
| 251 |
+
verdict=verification.verdict,
|
| 252 |
+
reason=verification.reason,
|
| 253 |
+
verification=verification,
|
| 254 |
+
receipt=receipt,
|
| 255 |
)
|
bodulskihal_core/ingestion.py
CHANGED
|
@@ -1,557 +1,557 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
import hashlib
|
| 4 |
-
import re
|
| 5 |
-
import unicodedata
|
| 6 |
-
from dataclasses import dataclass
|
| 7 |
-
from datetime import datetime
|
| 8 |
-
from enum import Enum
|
| 9 |
-
from typing import Any, Mapping
|
| 10 |
-
|
| 11 |
-
from .engine import (
|
| 12 |
-
AuthorityScope,
|
| 13 |
-
Claim,
|
| 14 |
-
ClaimType,
|
| 15 |
-
Directness,
|
| 16 |
-
Evidence,
|
| 17 |
-
EvidenceOrigin,
|
| 18 |
-
Policy,
|
| 19 |
-
RiskLevel,
|
| 20 |
-
SourceRole,
|
| 21 |
-
VerificationResult,
|
| 22 |
-
Verdict,
|
| 23 |
-
verify_compound,
|
| 24 |
-
)
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
class TypeAuthority(str, Enum):
|
| 28 |
-
EXECUTOR = "executor"
|
| 29 |
-
TRUSTED_POLICY = "trusted_policy"
|
| 30 |
-
HUMAN = "human"
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
@dataclass(frozen=True)
|
| 34 |
-
class NormalizedResponse:
|
| 35 |
-
text: str
|
| 36 |
-
sha256: str
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
@dataclass(frozen=True)
|
| 40 |
-
class ClaimAtom:
|
| 41 |
-
atom_id: str
|
| 42 |
-
text: str
|
| 43 |
-
start: int
|
| 44 |
-
end: int
|
| 45 |
-
source_response_sha256: str
|
| 46 |
-
claim_type: ClaimType
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
@dataclass(frozen=True)
|
| 50 |
-
class EvidenceContract:
|
| 51 |
-
atom_id: str
|
| 52 |
-
claim_type: ClaimType
|
| 53 |
-
verification_ready: bool
|
| 54 |
-
required_directness: Directness | None
|
| 55 |
-
post_action_required: bool
|
| 56 |
-
independent_post_action_required: bool
|
| 57 |
-
minimum_independent_sources: int
|
| 58 |
-
human_approval_required: bool
|
| 59 |
-
reason: str
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
@dataclass(frozen=True)
|
| 63 |
-
class IngestionResult:
|
| 64 |
-
normalized: NormalizedResponse
|
| 65 |
-
atoms: tuple[ClaimAtom, ...]
|
| 66 |
-
contracts: tuple[EvidenceContract, ...]
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
@dataclass(frozen=True)
|
| 70 |
-
class IngestedVerificationResult:
|
| 71 |
-
ingestion: IngestionResult
|
| 72 |
-
results: tuple[VerificationResult, ...]
|
| 73 |
-
verdict: Verdict
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
_TYPE_LABEL = re.compile(r"^\[(?P<label>[A-Za-z_]+)\](?:\s+|$)")
|
| 77 |
-
|
| 78 |
-
_LABEL_TYPES = {
|
| 79 |
-
"INFERENCE": ClaimType.INFERENCE,
|
| 80 |
-
"ESTIMATE": ClaimType.ESTIMATE,
|
| 81 |
-
"ACTION_STATE": ClaimType.ACTION_STATE,
|
| 82 |
-
"RISK_SENSITIVE": ClaimType.RISK_SENSITIVE,
|
| 83 |
-
"OBSERVATION": ClaimType.OBSERVATION,
|
| 84 |
-
}
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
def normalize_model_response(raw: str) -> NormalizedResponse:
|
| 88 |
-
if not isinstance(raw, str):
|
| 89 |
-
raise TypeError("model response must be string")
|
| 90 |
-
|
| 91 |
-
normalized = raw.replace("\r\n", "\n").replace("\r", "\n")
|
| 92 |
-
normalized = unicodedata.normalize("NFC", normalized).strip()
|
| 93 |
-
if not normalized:
|
| 94 |
-
raise ValueError("model response must be non-empty")
|
| 95 |
-
|
| 96 |
-
return NormalizedResponse(
|
| 97 |
-
text=normalized,
|
| 98 |
-
sha256=hashlib.sha256(normalized.encode("utf-8")).hexdigest(),
|
| 99 |
-
)
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
def type_clause(
|
| 103 |
-
text: str,
|
| 104 |
-
*,
|
| 105 |
-
authority: TypeAuthority = TypeAuthority.EXECUTOR,
|
| 106 |
-
) -> ClaimType:
|
| 107 |
-
if not isinstance(authority, TypeAuthority):
|
| 108 |
-
raise TypeError("authority must be TypeAuthority")
|
| 109 |
-
if not isinstance(text, str) or not text.strip():
|
| 110 |
-
raise ValueError("clause text must be non-empty")
|
| 111 |
-
|
| 112 |
-
match = _TYPE_LABEL.match(text)
|
| 113 |
-
if match is None:
|
| 114 |
-
return ClaimType.UNCLASSIFIED
|
| 115 |
-
|
| 116 |
-
declared = _LABEL_TYPES.get(match.group("label").upper())
|
| 117 |
-
if declared is None:
|
| 118 |
-
return ClaimType.UNCLASSIFIED
|
| 119 |
-
|
| 120 |
-
if (
|
| 121 |
-
declared is ClaimType.OBSERVATION
|
| 122 |
-
and authority is TypeAuthority.EXECUTOR
|
| 123 |
-
):
|
| 124 |
-
return ClaimType.UNCLASSIFIED
|
| 125 |
-
|
| 126 |
-
return declared
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
def _span_segments(text: str) -> list[tuple[int, int]]:
|
| 130 |
-
spans: list[tuple[int, int]] = []
|
| 131 |
-
length = len(text)
|
| 132 |
-
start = 0
|
| 133 |
-
|
| 134 |
-
while start < length:
|
| 135 |
-
while start < length and text[start].isspace():
|
| 136 |
-
start += 1
|
| 137 |
-
if start >= length:
|
| 138 |
-
break
|
| 139 |
-
|
| 140 |
-
index = start
|
| 141 |
-
emitted = False
|
| 142 |
-
while index < length:
|
| 143 |
-
char = text[index]
|
| 144 |
-
|
| 145 |
-
if char == "\n":
|
| 146 |
-
end = index
|
| 147 |
-
while end > start and text[end - 1].isspace():
|
| 148 |
-
end -= 1
|
| 149 |
-
if end > start:
|
| 150 |
-
spans.append((start, end))
|
| 151 |
-
start = index + 1
|
| 152 |
-
emitted = True
|
| 153 |
-
break
|
| 154 |
-
|
| 155 |
-
if char in ".?!":
|
| 156 |
-
next_index = index + 1
|
| 157 |
-
if next_index == length or text[next_index].isspace():
|
| 158 |
-
spans.append((start, next_index))
|
| 159 |
-
start = next_index
|
| 160 |
-
emitted = True
|
| 161 |
-
break
|
| 162 |
-
|
| 163 |
-
index += 1
|
| 164 |
-
|
| 165 |
-
if not emitted:
|
| 166 |
-
end = length
|
| 167 |
-
while end > start and text[end - 1].isspace():
|
| 168 |
-
end -= 1
|
| 169 |
-
if end > start:
|
| 170 |
-
spans.append((start, end))
|
| 171 |
-
break
|
| 172 |
-
|
| 173 |
-
return spans
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
def atomize_model_response(
|
| 177 |
-
normalized: NormalizedResponse,
|
| 178 |
-
*,
|
| 179 |
-
authority: TypeAuthority = TypeAuthority.EXECUTOR,
|
| 180 |
-
) -> tuple[ClaimAtom, ...]:
|
| 181 |
-
if not isinstance(normalized, NormalizedResponse):
|
| 182 |
-
raise TypeError("normalized must be NormalizedResponse")
|
| 183 |
-
|
| 184 |
-
atoms = []
|
| 185 |
-
for index, (start, end) in enumerate(_span_segments(normalized.text), start=1):
|
| 186 |
-
text = normalized.text[start:end]
|
| 187 |
-
atoms.append(
|
| 188 |
-
ClaimAtom(
|
| 189 |
-
atom_id=f"atom-{index:04d}",
|
| 190 |
-
text=text,
|
| 191 |
-
start=start,
|
| 192 |
-
end=end,
|
| 193 |
-
source_response_sha256=normalized.sha256,
|
| 194 |
-
claim_type=type_clause(text, authority=authority),
|
| 195 |
-
)
|
| 196 |
-
)
|
| 197 |
-
|
| 198 |
-
if not atoms:
|
| 199 |
-
raise ValueError("model response produced no claim atoms")
|
| 200 |
-
|
| 201 |
-
return tuple(atoms)
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
def build_evidence_contract(
|
| 205 |
-
*,
|
| 206 |
-
atom_id: str,
|
| 207 |
-
claim_type: ClaimType,
|
| 208 |
-
) -> EvidenceContract:
|
| 209 |
-
if not isinstance(atom_id, str) or not atom_id.strip():
|
| 210 |
-
raise ValueError("atom_id must be non-empty")
|
| 211 |
-
|
| 212 |
-
if claim_type is ClaimType.UNCLASSIFIED:
|
| 213 |
-
return EvidenceContract(
|
| 214 |
-
atom_id=atom_id,
|
| 215 |
-
claim_type=claim_type,
|
| 216 |
-
verification_ready=False,
|
| 217 |
-
required_directness=None,
|
| 218 |
-
post_action_required=False,
|
| 219 |
-
independent_post_action_required=False,
|
| 220 |
-
minimum_independent_sources=0,
|
| 221 |
-
human_approval_required=False,
|
| 222 |
-
reason="trusted_typing_required",
|
| 223 |
-
)
|
| 224 |
-
|
| 225 |
-
if claim_type is ClaimType.ACTION_STATE:
|
| 226 |
-
return EvidenceContract(
|
| 227 |
-
atom_id=atom_id,
|
| 228 |
-
claim_type=claim_type,
|
| 229 |
-
verification_ready=True,
|
| 230 |
-
required_directness=Directness.DIRECT,
|
| 231 |
-
post_action_required=True,
|
| 232 |
-
independent_post_action_required=True,
|
| 233 |
-
minimum_independent_sources=1,
|
| 234 |
-
human_approval_required=False,
|
| 235 |
-
reason="post_action_final_state_required",
|
| 236 |
-
)
|
| 237 |
-
|
| 238 |
-
if claim_type is ClaimType.RISK_SENSITIVE:
|
| 239 |
-
return EvidenceContract(
|
| 240 |
-
atom_id=atom_id,
|
| 241 |
-
claim_type=claim_type,
|
| 242 |
-
verification_ready=True,
|
| 243 |
-
required_directness=Directness.DIRECT,
|
| 244 |
-
post_action_required=False,
|
| 245 |
-
independent_post_action_required=False,
|
| 246 |
-
minimum_independent_sources=1,
|
| 247 |
-
human_approval_required=True,
|
| 248 |
-
reason="human_approval_required",
|
| 249 |
-
)
|
| 250 |
-
|
| 251 |
-
if claim_type is ClaimType.OBSERVATION:
|
| 252 |
-
return EvidenceContract(
|
| 253 |
-
atom_id=atom_id,
|
| 254 |
-
claim_type=claim_type,
|
| 255 |
-
verification_ready=True,
|
| 256 |
-
required_directness=Directness.DIRECT,
|
| 257 |
-
post_action_required=False,
|
| 258 |
-
independent_post_action_required=False,
|
| 259 |
-
minimum_independent_sources=1,
|
| 260 |
-
human_approval_required=False,
|
| 261 |
-
reason="direct_evidence_required",
|
| 262 |
-
)
|
| 263 |
-
|
| 264 |
-
return EvidenceContract(
|
| 265 |
-
atom_id=atom_id,
|
| 266 |
-
claim_type=claim_type,
|
| 267 |
-
verification_ready=True,
|
| 268 |
-
required_directness=None,
|
| 269 |
-
post_action_required=False,
|
| 270 |
-
independent_post_action_required=False,
|
| 271 |
-
minimum_independent_sources=1,
|
| 272 |
-
human_approval_required=False,
|
| 273 |
-
reason="typed_nonfact_claim",
|
| 274 |
-
)
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
def ingest_model_response(
|
| 278 |
-
raw: str,
|
| 279 |
-
*,
|
| 280 |
-
authority: TypeAuthority = TypeAuthority.EXECUTOR,
|
| 281 |
-
) -> IngestionResult:
|
| 282 |
-
normalized = normalize_model_response(raw)
|
| 283 |
-
atoms = atomize_model_response(normalized, authority=authority)
|
| 284 |
-
contracts = tuple(
|
| 285 |
-
build_evidence_contract(
|
| 286 |
-
atom_id=atom.atom_id,
|
| 287 |
-
claim_type=atom.claim_type,
|
| 288 |
-
)
|
| 289 |
-
for atom in atoms
|
| 290 |
-
)
|
| 291 |
-
return IngestionResult(
|
| 292 |
-
normalized=normalized,
|
| 293 |
-
atoms=atoms,
|
| 294 |
-
contracts=contracts,
|
| 295 |
-
)
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
_ALLOWED_EVIDENCE_KEYS = {
|
| 300 |
-
"source_id",
|
| 301 |
-
"supports",
|
| 302 |
-
"observed_at",
|
| 303 |
-
"directness",
|
| 304 |
-
"post_action",
|
| 305 |
-
"eligible",
|
| 306 |
-
"evidence_id",
|
| 307 |
-
"source_role",
|
| 308 |
-
"source_ref",
|
| 309 |
-
"independence_group",
|
| 310 |
-
"content_sha256",
|
| 311 |
-
"origin",
|
| 312 |
-
"authority_scope",
|
| 313 |
-
}
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
def _record_mapping(value: Any, path: str) -> Mapping[str, Any]:
|
| 317 |
-
if not isinstance(value, Mapping):
|
| 318 |
-
raise TypeError(f"{path} must be object")
|
| 319 |
-
return value
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
def _record_string(value: Any, path: str) -> str:
|
| 323 |
-
if not isinstance(value, str) or not value.strip():
|
| 324 |
-
raise TypeError(f"{path} must be non-empty string")
|
| 325 |
-
return value.strip()
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
def _record_optional_string(value: Any, path: str) -> str | None:
|
| 329 |
-
if value is None:
|
| 330 |
-
return None
|
| 331 |
-
return _record_string(value, path)
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
def _record_bool(value: Any, path: str) -> bool:
|
| 335 |
-
if type(value) is not bool:
|
| 336 |
-
raise TypeError(f"{path} must be bool")
|
| 337 |
-
return value
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
def _record_datetime(value: Any, path: str) -> datetime:
|
| 341 |
-
raw = _record_string(value, path)
|
| 342 |
-
if raw.endswith("Z"):
|
| 343 |
-
raw = raw[:-1] + "+00:00"
|
| 344 |
-
try:
|
| 345 |
-
parsed = datetime.fromisoformat(raw)
|
| 346 |
-
except ValueError as exc:
|
| 347 |
-
raise ValueError(f"{path} must be ISO 8601 datetime") from exc
|
| 348 |
-
if parsed.tzinfo is None or parsed.utcoffset() is None:
|
| 349 |
-
raise ValueError(f"{path} must be timezone-aware")
|
| 350 |
-
return parsed
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
def _record_directness(value: Any, path: str) -> Directness:
|
| 354 |
-
raw = _record_string(value, path)
|
| 355 |
-
try:
|
| 356 |
-
return Directness(raw)
|
| 357 |
-
except ValueError as exc:
|
| 358 |
-
raise ValueError(f"invalid {path}: {raw}") from exc
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
def _record_source_role(value: Any, path: str) -> SourceRole:
|
| 362 |
-
raw = _record_string(value, path)
|
| 363 |
-
try:
|
| 364 |
-
return SourceRole(raw)
|
| 365 |
-
except ValueError as exc:
|
| 366 |
-
raise ValueError(f"invalid {path}: {raw}") from exc
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
def _record_evidence_origin(value: Any, path: str) -> EvidenceOrigin:
|
| 370 |
-
raw = _record_string(value, path)
|
| 371 |
-
try:
|
| 372 |
-
return EvidenceOrigin(raw)
|
| 373 |
-
except ValueError as exc:
|
| 374 |
-
raise ValueError(f"invalid {path}: {raw}") from exc
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
def _record_authority_scope(value: Any, path: str) -> AuthorityScope:
|
| 378 |
-
raw = _record_string(value, path)
|
| 379 |
-
try:
|
| 380 |
-
return AuthorityScope(raw)
|
| 381 |
-
except ValueError as exc:
|
| 382 |
-
raise ValueError(f"invalid {path}: {raw}") from exc
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
def collect_normalize_evidence(
|
| 386 |
-
records_by_atom: Mapping[str, list[Mapping[str, Any]]],
|
| 387 |
-
*,
|
| 388 |
-
known_atom_ids: set[str],
|
| 389 |
-
) -> dict[str, tuple[Evidence, ...]]:
|
| 390 |
-
if not isinstance(records_by_atom, Mapping):
|
| 391 |
-
raise TypeError("records_by_atom must be object")
|
| 392 |
-
if not isinstance(known_atom_ids, set):
|
| 393 |
-
raise TypeError("known_atom_ids must be set")
|
| 394 |
-
|
| 395 |
-
unknown = sorted(set(records_by_atom) - known_atom_ids)
|
| 396 |
-
if unknown:
|
| 397 |
-
raise ValueError("unknown atom_id: " + ", ".join(unknown))
|
| 398 |
-
|
| 399 |
-
normalized: dict[str, tuple[Evidence, ...]] = {}
|
| 400 |
-
for atom_id in sorted(known_atom_ids):
|
| 401 |
-
raw_items = records_by_atom.get(atom_id, [])
|
| 402 |
-
if not isinstance(raw_items, list):
|
| 403 |
-
raise TypeError(f"evidence for {atom_id} must be list")
|
| 404 |
-
|
| 405 |
-
items: list[Evidence] = []
|
| 406 |
-
seen_evidence_ids: set[str] = set()
|
| 407 |
-
|
| 408 |
-
for index, raw in enumerate(raw_items):
|
| 409 |
-
path = f"evidence_by_atom.{atom_id}[{index}]"
|
| 410 |
-
obj = _record_mapping(raw, path)
|
| 411 |
-
|
| 412 |
-
extra = sorted(set(obj) - _ALLOWED_EVIDENCE_KEYS)
|
| 413 |
-
if extra:
|
| 414 |
-
raise ValueError(
|
| 415 |
-
f"{path} contains unsupported fields: " + ", ".join(extra)
|
| 416 |
-
)
|
| 417 |
-
|
| 418 |
-
source_id = _record_string(obj.get("source_id"), f"{path}.source_id")
|
| 419 |
-
evidence_id = _record_optional_string(
|
| 420 |
-
obj.get("evidence_id"),
|
| 421 |
-
f"{path}.evidence_id",
|
| 422 |
-
)
|
| 423 |
-
effective_id = evidence_id or source_id
|
| 424 |
-
if effective_id in seen_evidence_ids:
|
| 425 |
-
if evidence_id is None:
|
| 426 |
-
raise ValueError(
|
| 427 |
-
f"duplicate source_id for {atom_id}: {source_id}"
|
| 428 |
-
)
|
| 429 |
-
raise ValueError(
|
| 430 |
-
f"duplicate evidence_id for {atom_id}: {effective_id}"
|
| 431 |
-
)
|
| 432 |
-
seen_evidence_ids.add(effective_id)
|
| 433 |
-
|
| 434 |
-
if "supports" not in obj:
|
| 435 |
-
raise ValueError(f"missing required field: {path}.supports")
|
| 436 |
-
if "observed_at" not in obj:
|
| 437 |
-
raise ValueError(f"missing required field: {path}.observed_at")
|
| 438 |
-
if "directness" not in obj:
|
| 439 |
-
raise ValueError(f"missing required field: {path}.directness")
|
| 440 |
-
|
| 441 |
-
items.append(
|
| 442 |
-
Evidence(
|
| 443 |
-
source_id=source_id,
|
| 444 |
-
supports=_record_bool(obj["supports"], f"{path}.supports"),
|
| 445 |
-
observed_at=_record_datetime(
|
| 446 |
-
obj["observed_at"],
|
| 447 |
-
f"{path}.observed_at",
|
| 448 |
-
),
|
| 449 |
-
directness=_record_directness(
|
| 450 |
-
obj["directness"],
|
| 451 |
-
f"{path}.directness",
|
| 452 |
-
),
|
| 453 |
-
post_action=_record_bool(
|
| 454 |
-
obj.get("post_action", False),
|
| 455 |
-
f"{path}.post_action",
|
| 456 |
-
),
|
| 457 |
-
eligible=_record_bool(
|
| 458 |
-
obj.get("eligible", True),
|
| 459 |
-
f"{path}.eligible",
|
| 460 |
-
),
|
| 461 |
-
evidence_id=evidence_id,
|
| 462 |
-
source_role=_record_source_role(
|
| 463 |
-
obj.get("source_role", SourceRole.EXTERNAL.value),
|
| 464 |
-
f"{path}.source_role",
|
| 465 |
-
),
|
| 466 |
-
source_ref=_record_optional_string(
|
| 467 |
-
obj.get("source_ref"),
|
| 468 |
-
f"{path}.source_ref",
|
| 469 |
-
),
|
| 470 |
-
independence_group=_record_optional_string(
|
| 471 |
-
obj.get("independence_group"),
|
| 472 |
-
f"{path}.independence_group",
|
| 473 |
-
),
|
| 474 |
-
content_sha256=_record_optional_string(
|
| 475 |
-
obj.get("content_sha256"),
|
| 476 |
-
f"{path}.content_sha256",
|
| 477 |
-
),
|
| 478 |
-
origin=_record_evidence_origin(
|
| 479 |
-
obj.get("origin", EvidenceOrigin.EXTERNAL_SOURCE.value),
|
| 480 |
-
f"{path}.origin",
|
| 481 |
-
),
|
| 482 |
-
authority_scope=_record_authority_scope(
|
| 483 |
-
obj.get("authority_scope", AuthorityScope.EXTERNAL_FACT.value),
|
| 484 |
-
f"{path}.authority_scope",
|
| 485 |
-
),
|
| 486 |
-
)
|
| 487 |
-
)
|
| 488 |
-
|
| 489 |
-
normalized[atom_id] = tuple(
|
| 490 |
-
sorted(
|
| 491 |
-
items,
|
| 492 |
-
key=lambda item: (
|
| 493 |
-
item.evidence_id or item.source_id,
|
| 494 |
-
item.source_id,
|
| 495 |
-
),
|
| 496 |
-
)
|
| 497 |
-
)
|
| 498 |
-
|
| 499 |
-
return normalized
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
def _claim_from_atom(atom: ClaimAtom) -> Claim:
|
| 503 |
-
risk = (
|
| 504 |
-
RiskLevel.HIGH
|
| 505 |
-
if atom.claim_type is ClaimType.RISK_SENSITIVE
|
| 506 |
-
else RiskLevel.LOW
|
| 507 |
-
)
|
| 508 |
-
return Claim(
|
| 509 |
-
claim_id=atom.atom_id,
|
| 510 |
-
text=atom.text,
|
| 511 |
-
claim_type=atom.claim_type,
|
| 512 |
-
risk=risk,
|
| 513 |
-
consequential=True,
|
| 514 |
-
)
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
def verify_model_response(
|
| 518 |
-
raw: str,
|
| 519 |
-
*,
|
| 520 |
-
evidence_by_atom: Mapping[str, list[Mapping[str, Any]]],
|
| 521 |
-
authority: TypeAuthority,
|
| 522 |
-
policy: Policy,
|
| 523 |
-
evaluation_time: datetime,
|
| 524 |
-
human_approvals: Mapping[str, bool] | None = None,
|
| 525 |
-
) -> IngestedVerificationResult:
|
| 526 |
-
ingestion = ingest_model_response(raw, authority=authority)
|
| 527 |
-
atom_ids = {atom.atom_id for atom in ingestion.atoms}
|
| 528 |
-
|
| 529 |
-
evidence = collect_normalize_evidence(
|
| 530 |
-
evidence_by_atom,
|
| 531 |
-
known_atom_ids=atom_ids,
|
| 532 |
-
)
|
| 533 |
-
|
| 534 |
-
approvals = human_approvals or {}
|
| 535 |
-
unknown_approvals = sorted(set(approvals) - atom_ids)
|
| 536 |
-
if unknown_approvals:
|
| 537 |
-
raise ValueError(
|
| 538 |
-
"human_approvals contains unknown atom_id: "
|
| 539 |
-
+ ", ".join(unknown_approvals)
|
| 540 |
-
)
|
| 541 |
-
for atom_id, value in approvals.items():
|
| 542 |
-
if type(value) is not bool:
|
| 543 |
-
raise TypeError(f"human_approvals.{atom_id} must be bool")
|
| 544 |
-
|
| 545 |
-
compound = verify_compound(
|
| 546 |
-
tuple(_claim_from_atom(atom) for atom in ingestion.atoms),
|
| 547 |
-
evidence,
|
| 548 |
-
policy=policy,
|
| 549 |
-
evaluation_time=evaluation_time,
|
| 550 |
-
human_approvals=approvals,
|
| 551 |
-
)
|
| 552 |
-
|
| 553 |
-
return IngestedVerificationResult(
|
| 554 |
-
ingestion=ingestion,
|
| 555 |
-
results=compound.results,
|
| 556 |
-
verdict=compound.verdict,
|
| 557 |
)
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import hashlib
|
| 4 |
+
import re
|
| 5 |
+
import unicodedata
|
| 6 |
+
from dataclasses import dataclass
|
| 7 |
+
from datetime import datetime
|
| 8 |
+
from enum import Enum
|
| 9 |
+
from typing import Any, Mapping
|
| 10 |
+
|
| 11 |
+
from .engine import (
|
| 12 |
+
AuthorityScope,
|
| 13 |
+
Claim,
|
| 14 |
+
ClaimType,
|
| 15 |
+
Directness,
|
| 16 |
+
Evidence,
|
| 17 |
+
EvidenceOrigin,
|
| 18 |
+
Policy,
|
| 19 |
+
RiskLevel,
|
| 20 |
+
SourceRole,
|
| 21 |
+
VerificationResult,
|
| 22 |
+
Verdict,
|
| 23 |
+
verify_compound,
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class TypeAuthority(str, Enum):
|
| 28 |
+
EXECUTOR = "executor"
|
| 29 |
+
TRUSTED_POLICY = "trusted_policy"
|
| 30 |
+
HUMAN = "human"
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@dataclass(frozen=True)
|
| 34 |
+
class NormalizedResponse:
|
| 35 |
+
text: str
|
| 36 |
+
sha256: str
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@dataclass(frozen=True)
|
| 40 |
+
class ClaimAtom:
|
| 41 |
+
atom_id: str
|
| 42 |
+
text: str
|
| 43 |
+
start: int
|
| 44 |
+
end: int
|
| 45 |
+
source_response_sha256: str
|
| 46 |
+
claim_type: ClaimType
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@dataclass(frozen=True)
|
| 50 |
+
class EvidenceContract:
|
| 51 |
+
atom_id: str
|
| 52 |
+
claim_type: ClaimType
|
| 53 |
+
verification_ready: bool
|
| 54 |
+
required_directness: Directness | None
|
| 55 |
+
post_action_required: bool
|
| 56 |
+
independent_post_action_required: bool
|
| 57 |
+
minimum_independent_sources: int
|
| 58 |
+
human_approval_required: bool
|
| 59 |
+
reason: str
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
@dataclass(frozen=True)
|
| 63 |
+
class IngestionResult:
|
| 64 |
+
normalized: NormalizedResponse
|
| 65 |
+
atoms: tuple[ClaimAtom, ...]
|
| 66 |
+
contracts: tuple[EvidenceContract, ...]
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
@dataclass(frozen=True)
|
| 70 |
+
class IngestedVerificationResult:
|
| 71 |
+
ingestion: IngestionResult
|
| 72 |
+
results: tuple[VerificationResult, ...]
|
| 73 |
+
verdict: Verdict
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
_TYPE_LABEL = re.compile(r"^\[(?P<label>[A-Za-z_]+)\](?:\s+|$)")
|
| 77 |
+
|
| 78 |
+
_LABEL_TYPES = {
|
| 79 |
+
"INFERENCE": ClaimType.INFERENCE,
|
| 80 |
+
"ESTIMATE": ClaimType.ESTIMATE,
|
| 81 |
+
"ACTION_STATE": ClaimType.ACTION_STATE,
|
| 82 |
+
"RISK_SENSITIVE": ClaimType.RISK_SENSITIVE,
|
| 83 |
+
"OBSERVATION": ClaimType.OBSERVATION,
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def normalize_model_response(raw: str) -> NormalizedResponse:
|
| 88 |
+
if not isinstance(raw, str):
|
| 89 |
+
raise TypeError("model response must be string")
|
| 90 |
+
|
| 91 |
+
normalized = raw.replace("\r\n", "\n").replace("\r", "\n")
|
| 92 |
+
normalized = unicodedata.normalize("NFC", normalized).strip()
|
| 93 |
+
if not normalized:
|
| 94 |
+
raise ValueError("model response must be non-empty")
|
| 95 |
+
|
| 96 |
+
return NormalizedResponse(
|
| 97 |
+
text=normalized,
|
| 98 |
+
sha256=hashlib.sha256(normalized.encode("utf-8")).hexdigest(),
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def type_clause(
|
| 103 |
+
text: str,
|
| 104 |
+
*,
|
| 105 |
+
authority: TypeAuthority = TypeAuthority.EXECUTOR,
|
| 106 |
+
) -> ClaimType:
|
| 107 |
+
if not isinstance(authority, TypeAuthority):
|
| 108 |
+
raise TypeError("authority must be TypeAuthority")
|
| 109 |
+
if not isinstance(text, str) or not text.strip():
|
| 110 |
+
raise ValueError("clause text must be non-empty")
|
| 111 |
+
|
| 112 |
+
match = _TYPE_LABEL.match(text)
|
| 113 |
+
if match is None:
|
| 114 |
+
return ClaimType.UNCLASSIFIED
|
| 115 |
+
|
| 116 |
+
declared = _LABEL_TYPES.get(match.group("label").upper())
|
| 117 |
+
if declared is None:
|
| 118 |
+
return ClaimType.UNCLASSIFIED
|
| 119 |
+
|
| 120 |
+
if (
|
| 121 |
+
declared is ClaimType.OBSERVATION
|
| 122 |
+
and authority is TypeAuthority.EXECUTOR
|
| 123 |
+
):
|
| 124 |
+
return ClaimType.UNCLASSIFIED
|
| 125 |
+
|
| 126 |
+
return declared
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def _span_segments(text: str) -> list[tuple[int, int]]:
|
| 130 |
+
spans: list[tuple[int, int]] = []
|
| 131 |
+
length = len(text)
|
| 132 |
+
start = 0
|
| 133 |
+
|
| 134 |
+
while start < length:
|
| 135 |
+
while start < length and text[start].isspace():
|
| 136 |
+
start += 1
|
| 137 |
+
if start >= length:
|
| 138 |
+
break
|
| 139 |
+
|
| 140 |
+
index = start
|
| 141 |
+
emitted = False
|
| 142 |
+
while index < length:
|
| 143 |
+
char = text[index]
|
| 144 |
+
|
| 145 |
+
if char == "\n":
|
| 146 |
+
end = index
|
| 147 |
+
while end > start and text[end - 1].isspace():
|
| 148 |
+
end -= 1
|
| 149 |
+
if end > start:
|
| 150 |
+
spans.append((start, end))
|
| 151 |
+
start = index + 1
|
| 152 |
+
emitted = True
|
| 153 |
+
break
|
| 154 |
+
|
| 155 |
+
if char in ".?!":
|
| 156 |
+
next_index = index + 1
|
| 157 |
+
if next_index == length or text[next_index].isspace():
|
| 158 |
+
spans.append((start, next_index))
|
| 159 |
+
start = next_index
|
| 160 |
+
emitted = True
|
| 161 |
+
break
|
| 162 |
+
|
| 163 |
+
index += 1
|
| 164 |
+
|
| 165 |
+
if not emitted:
|
| 166 |
+
end = length
|
| 167 |
+
while end > start and text[end - 1].isspace():
|
| 168 |
+
end -= 1
|
| 169 |
+
if end > start:
|
| 170 |
+
spans.append((start, end))
|
| 171 |
+
break
|
| 172 |
+
|
| 173 |
+
return spans
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def atomize_model_response(
|
| 177 |
+
normalized: NormalizedResponse,
|
| 178 |
+
*,
|
| 179 |
+
authority: TypeAuthority = TypeAuthority.EXECUTOR,
|
| 180 |
+
) -> tuple[ClaimAtom, ...]:
|
| 181 |
+
if not isinstance(normalized, NormalizedResponse):
|
| 182 |
+
raise TypeError("normalized must be NormalizedResponse")
|
| 183 |
+
|
| 184 |
+
atoms = []
|
| 185 |
+
for index, (start, end) in enumerate(_span_segments(normalized.text), start=1):
|
| 186 |
+
text = normalized.text[start:end]
|
| 187 |
+
atoms.append(
|
| 188 |
+
ClaimAtom(
|
| 189 |
+
atom_id=f"atom-{index:04d}",
|
| 190 |
+
text=text,
|
| 191 |
+
start=start,
|
| 192 |
+
end=end,
|
| 193 |
+
source_response_sha256=normalized.sha256,
|
| 194 |
+
claim_type=type_clause(text, authority=authority),
|
| 195 |
+
)
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
if not atoms:
|
| 199 |
+
raise ValueError("model response produced no claim atoms")
|
| 200 |
+
|
| 201 |
+
return tuple(atoms)
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def build_evidence_contract(
|
| 205 |
+
*,
|
| 206 |
+
atom_id: str,
|
| 207 |
+
claim_type: ClaimType,
|
| 208 |
+
) -> EvidenceContract:
|
| 209 |
+
if not isinstance(atom_id, str) or not atom_id.strip():
|
| 210 |
+
raise ValueError("atom_id must be non-empty")
|
| 211 |
+
|
| 212 |
+
if claim_type is ClaimType.UNCLASSIFIED:
|
| 213 |
+
return EvidenceContract(
|
| 214 |
+
atom_id=atom_id,
|
| 215 |
+
claim_type=claim_type,
|
| 216 |
+
verification_ready=False,
|
| 217 |
+
required_directness=None,
|
| 218 |
+
post_action_required=False,
|
| 219 |
+
independent_post_action_required=False,
|
| 220 |
+
minimum_independent_sources=0,
|
| 221 |
+
human_approval_required=False,
|
| 222 |
+
reason="trusted_typing_required",
|
| 223 |
+
)
|
| 224 |
+
|
| 225 |
+
if claim_type is ClaimType.ACTION_STATE:
|
| 226 |
+
return EvidenceContract(
|
| 227 |
+
atom_id=atom_id,
|
| 228 |
+
claim_type=claim_type,
|
| 229 |
+
verification_ready=True,
|
| 230 |
+
required_directness=Directness.DIRECT,
|
| 231 |
+
post_action_required=True,
|
| 232 |
+
independent_post_action_required=True,
|
| 233 |
+
minimum_independent_sources=1,
|
| 234 |
+
human_approval_required=False,
|
| 235 |
+
reason="post_action_final_state_required",
|
| 236 |
+
)
|
| 237 |
+
|
| 238 |
+
if claim_type is ClaimType.RISK_SENSITIVE:
|
| 239 |
+
return EvidenceContract(
|
| 240 |
+
atom_id=atom_id,
|
| 241 |
+
claim_type=claim_type,
|
| 242 |
+
verification_ready=True,
|
| 243 |
+
required_directness=Directness.DIRECT,
|
| 244 |
+
post_action_required=False,
|
| 245 |
+
independent_post_action_required=False,
|
| 246 |
+
minimum_independent_sources=1,
|
| 247 |
+
human_approval_required=True,
|
| 248 |
+
reason="human_approval_required",
|
| 249 |
+
)
|
| 250 |
+
|
| 251 |
+
if claim_type is ClaimType.OBSERVATION:
|
| 252 |
+
return EvidenceContract(
|
| 253 |
+
atom_id=atom_id,
|
| 254 |
+
claim_type=claim_type,
|
| 255 |
+
verification_ready=True,
|
| 256 |
+
required_directness=Directness.DIRECT,
|
| 257 |
+
post_action_required=False,
|
| 258 |
+
independent_post_action_required=False,
|
| 259 |
+
minimum_independent_sources=1,
|
| 260 |
+
human_approval_required=False,
|
| 261 |
+
reason="direct_evidence_required",
|
| 262 |
+
)
|
| 263 |
+
|
| 264 |
+
return EvidenceContract(
|
| 265 |
+
atom_id=atom_id,
|
| 266 |
+
claim_type=claim_type,
|
| 267 |
+
verification_ready=True,
|
| 268 |
+
required_directness=None,
|
| 269 |
+
post_action_required=False,
|
| 270 |
+
independent_post_action_required=False,
|
| 271 |
+
minimum_independent_sources=1,
|
| 272 |
+
human_approval_required=False,
|
| 273 |
+
reason="typed_nonfact_claim",
|
| 274 |
+
)
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
def ingest_model_response(
|
| 278 |
+
raw: str,
|
| 279 |
+
*,
|
| 280 |
+
authority: TypeAuthority = TypeAuthority.EXECUTOR,
|
| 281 |
+
) -> IngestionResult:
|
| 282 |
+
normalized = normalize_model_response(raw)
|
| 283 |
+
atoms = atomize_model_response(normalized, authority=authority)
|
| 284 |
+
contracts = tuple(
|
| 285 |
+
build_evidence_contract(
|
| 286 |
+
atom_id=atom.atom_id,
|
| 287 |
+
claim_type=atom.claim_type,
|
| 288 |
+
)
|
| 289 |
+
for atom in atoms
|
| 290 |
+
)
|
| 291 |
+
return IngestionResult(
|
| 292 |
+
normalized=normalized,
|
| 293 |
+
atoms=atoms,
|
| 294 |
+
contracts=contracts,
|
| 295 |
+
)
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
_ALLOWED_EVIDENCE_KEYS = {
|
| 300 |
+
"source_id",
|
| 301 |
+
"supports",
|
| 302 |
+
"observed_at",
|
| 303 |
+
"directness",
|
| 304 |
+
"post_action",
|
| 305 |
+
"eligible",
|
| 306 |
+
"evidence_id",
|
| 307 |
+
"source_role",
|
| 308 |
+
"source_ref",
|
| 309 |
+
"independence_group",
|
| 310 |
+
"content_sha256",
|
| 311 |
+
"origin",
|
| 312 |
+
"authority_scope",
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
def _record_mapping(value: Any, path: str) -> Mapping[str, Any]:
|
| 317 |
+
if not isinstance(value, Mapping):
|
| 318 |
+
raise TypeError(f"{path} must be object")
|
| 319 |
+
return value
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
def _record_string(value: Any, path: str) -> str:
|
| 323 |
+
if not isinstance(value, str) or not value.strip():
|
| 324 |
+
raise TypeError(f"{path} must be non-empty string")
|
| 325 |
+
return value.strip()
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
def _record_optional_string(value: Any, path: str) -> str | None:
|
| 329 |
+
if value is None:
|
| 330 |
+
return None
|
| 331 |
+
return _record_string(value, path)
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
def _record_bool(value: Any, path: str) -> bool:
|
| 335 |
+
if type(value) is not bool:
|
| 336 |
+
raise TypeError(f"{path} must be bool")
|
| 337 |
+
return value
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
def _record_datetime(value: Any, path: str) -> datetime:
|
| 341 |
+
raw = _record_string(value, path)
|
| 342 |
+
if raw.endswith("Z"):
|
| 343 |
+
raw = raw[:-1] + "+00:00"
|
| 344 |
+
try:
|
| 345 |
+
parsed = datetime.fromisoformat(raw)
|
| 346 |
+
except ValueError as exc:
|
| 347 |
+
raise ValueError(f"{path} must be ISO 8601 datetime") from exc
|
| 348 |
+
if parsed.tzinfo is None or parsed.utcoffset() is None:
|
| 349 |
+
raise ValueError(f"{path} must be timezone-aware")
|
| 350 |
+
return parsed
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
def _record_directness(value: Any, path: str) -> Directness:
|
| 354 |
+
raw = _record_string(value, path)
|
| 355 |
+
try:
|
| 356 |
+
return Directness(raw)
|
| 357 |
+
except ValueError as exc:
|
| 358 |
+
raise ValueError(f"invalid {path}: {raw}") from exc
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
def _record_source_role(value: Any, path: str) -> SourceRole:
|
| 362 |
+
raw = _record_string(value, path)
|
| 363 |
+
try:
|
| 364 |
+
return SourceRole(raw)
|
| 365 |
+
except ValueError as exc:
|
| 366 |
+
raise ValueError(f"invalid {path}: {raw}") from exc
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
def _record_evidence_origin(value: Any, path: str) -> EvidenceOrigin:
|
| 370 |
+
raw = _record_string(value, path)
|
| 371 |
+
try:
|
| 372 |
+
return EvidenceOrigin(raw)
|
| 373 |
+
except ValueError as exc:
|
| 374 |
+
raise ValueError(f"invalid {path}: {raw}") from exc
|
| 375 |
+
|
| 376 |
+
|
| 377 |
+
def _record_authority_scope(value: Any, path: str) -> AuthorityScope:
|
| 378 |
+
raw = _record_string(value, path)
|
| 379 |
+
try:
|
| 380 |
+
return AuthorityScope(raw)
|
| 381 |
+
except ValueError as exc:
|
| 382 |
+
raise ValueError(f"invalid {path}: {raw}") from exc
|
| 383 |
+
|
| 384 |
+
|
| 385 |
+
def collect_normalize_evidence(
|
| 386 |
+
records_by_atom: Mapping[str, list[Mapping[str, Any]]],
|
| 387 |
+
*,
|
| 388 |
+
known_atom_ids: set[str],
|
| 389 |
+
) -> dict[str, tuple[Evidence, ...]]:
|
| 390 |
+
if not isinstance(records_by_atom, Mapping):
|
| 391 |
+
raise TypeError("records_by_atom must be object")
|
| 392 |
+
if not isinstance(known_atom_ids, set):
|
| 393 |
+
raise TypeError("known_atom_ids must be set")
|
| 394 |
+
|
| 395 |
+
unknown = sorted(set(records_by_atom) - known_atom_ids)
|
| 396 |
+
if unknown:
|
| 397 |
+
raise ValueError("unknown atom_id: " + ", ".join(unknown))
|
| 398 |
+
|
| 399 |
+
normalized: dict[str, tuple[Evidence, ...]] = {}
|
| 400 |
+
for atom_id in sorted(known_atom_ids):
|
| 401 |
+
raw_items = records_by_atom.get(atom_id, [])
|
| 402 |
+
if not isinstance(raw_items, list):
|
| 403 |
+
raise TypeError(f"evidence for {atom_id} must be list")
|
| 404 |
+
|
| 405 |
+
items: list[Evidence] = []
|
| 406 |
+
seen_evidence_ids: set[str] = set()
|
| 407 |
+
|
| 408 |
+
for index, raw in enumerate(raw_items):
|
| 409 |
+
path = f"evidence_by_atom.{atom_id}[{index}]"
|
| 410 |
+
obj = _record_mapping(raw, path)
|
| 411 |
+
|
| 412 |
+
extra = sorted(set(obj) - _ALLOWED_EVIDENCE_KEYS)
|
| 413 |
+
if extra:
|
| 414 |
+
raise ValueError(
|
| 415 |
+
f"{path} contains unsupported fields: " + ", ".join(extra)
|
| 416 |
+
)
|
| 417 |
+
|
| 418 |
+
source_id = _record_string(obj.get("source_id"), f"{path}.source_id")
|
| 419 |
+
evidence_id = _record_optional_string(
|
| 420 |
+
obj.get("evidence_id"),
|
| 421 |
+
f"{path}.evidence_id",
|
| 422 |
+
)
|
| 423 |
+
effective_id = evidence_id or source_id
|
| 424 |
+
if effective_id in seen_evidence_ids:
|
| 425 |
+
if evidence_id is None:
|
| 426 |
+
raise ValueError(
|
| 427 |
+
f"duplicate source_id for {atom_id}: {source_id}"
|
| 428 |
+
)
|
| 429 |
+
raise ValueError(
|
| 430 |
+
f"duplicate evidence_id for {atom_id}: {effective_id}"
|
| 431 |
+
)
|
| 432 |
+
seen_evidence_ids.add(effective_id)
|
| 433 |
+
|
| 434 |
+
if "supports" not in obj:
|
| 435 |
+
raise ValueError(f"missing required field: {path}.supports")
|
| 436 |
+
if "observed_at" not in obj:
|
| 437 |
+
raise ValueError(f"missing required field: {path}.observed_at")
|
| 438 |
+
if "directness" not in obj:
|
| 439 |
+
raise ValueError(f"missing required field: {path}.directness")
|
| 440 |
+
|
| 441 |
+
items.append(
|
| 442 |
+
Evidence(
|
| 443 |
+
source_id=source_id,
|
| 444 |
+
supports=_record_bool(obj["supports"], f"{path}.supports"),
|
| 445 |
+
observed_at=_record_datetime(
|
| 446 |
+
obj["observed_at"],
|
| 447 |
+
f"{path}.observed_at",
|
| 448 |
+
),
|
| 449 |
+
directness=_record_directness(
|
| 450 |
+
obj["directness"],
|
| 451 |
+
f"{path}.directness",
|
| 452 |
+
),
|
| 453 |
+
post_action=_record_bool(
|
| 454 |
+
obj.get("post_action", False),
|
| 455 |
+
f"{path}.post_action",
|
| 456 |
+
),
|
| 457 |
+
eligible=_record_bool(
|
| 458 |
+
obj.get("eligible", True),
|
| 459 |
+
f"{path}.eligible",
|
| 460 |
+
),
|
| 461 |
+
evidence_id=evidence_id,
|
| 462 |
+
source_role=_record_source_role(
|
| 463 |
+
obj.get("source_role", SourceRole.EXTERNAL.value),
|
| 464 |
+
f"{path}.source_role",
|
| 465 |
+
),
|
| 466 |
+
source_ref=_record_optional_string(
|
| 467 |
+
obj.get("source_ref"),
|
| 468 |
+
f"{path}.source_ref",
|
| 469 |
+
),
|
| 470 |
+
independence_group=_record_optional_string(
|
| 471 |
+
obj.get("independence_group"),
|
| 472 |
+
f"{path}.independence_group",
|
| 473 |
+
),
|
| 474 |
+
content_sha256=_record_optional_string(
|
| 475 |
+
obj.get("content_sha256"),
|
| 476 |
+
f"{path}.content_sha256",
|
| 477 |
+
),
|
| 478 |
+
origin=_record_evidence_origin(
|
| 479 |
+
obj.get("origin", EvidenceOrigin.EXTERNAL_SOURCE.value),
|
| 480 |
+
f"{path}.origin",
|
| 481 |
+
),
|
| 482 |
+
authority_scope=_record_authority_scope(
|
| 483 |
+
obj.get("authority_scope", AuthorityScope.EXTERNAL_FACT.value),
|
| 484 |
+
f"{path}.authority_scope",
|
| 485 |
+
),
|
| 486 |
+
)
|
| 487 |
+
)
|
| 488 |
+
|
| 489 |
+
normalized[atom_id] = tuple(
|
| 490 |
+
sorted(
|
| 491 |
+
items,
|
| 492 |
+
key=lambda item: (
|
| 493 |
+
item.evidence_id or item.source_id,
|
| 494 |
+
item.source_id,
|
| 495 |
+
),
|
| 496 |
+
)
|
| 497 |
+
)
|
| 498 |
+
|
| 499 |
+
return normalized
|
| 500 |
+
|
| 501 |
+
|
| 502 |
+
def _claim_from_atom(atom: ClaimAtom) -> Claim:
|
| 503 |
+
risk = (
|
| 504 |
+
RiskLevel.HIGH
|
| 505 |
+
if atom.claim_type is ClaimType.RISK_SENSITIVE
|
| 506 |
+
else RiskLevel.LOW
|
| 507 |
+
)
|
| 508 |
+
return Claim(
|
| 509 |
+
claim_id=atom.atom_id,
|
| 510 |
+
text=atom.text,
|
| 511 |
+
claim_type=atom.claim_type,
|
| 512 |
+
risk=risk,
|
| 513 |
+
consequential=True,
|
| 514 |
+
)
|
| 515 |
+
|
| 516 |
+
|
| 517 |
+
def verify_model_response(
|
| 518 |
+
raw: str,
|
| 519 |
+
*,
|
| 520 |
+
evidence_by_atom: Mapping[str, list[Mapping[str, Any]]],
|
| 521 |
+
authority: TypeAuthority,
|
| 522 |
+
policy: Policy,
|
| 523 |
+
evaluation_time: datetime,
|
| 524 |
+
human_approvals: Mapping[str, bool] | None = None,
|
| 525 |
+
) -> IngestedVerificationResult:
|
| 526 |
+
ingestion = ingest_model_response(raw, authority=authority)
|
| 527 |
+
atom_ids = {atom.atom_id for atom in ingestion.atoms}
|
| 528 |
+
|
| 529 |
+
evidence = collect_normalize_evidence(
|
| 530 |
+
evidence_by_atom,
|
| 531 |
+
known_atom_ids=atom_ids,
|
| 532 |
+
)
|
| 533 |
+
|
| 534 |
+
approvals = human_approvals or {}
|
| 535 |
+
unknown_approvals = sorted(set(approvals) - atom_ids)
|
| 536 |
+
if unknown_approvals:
|
| 537 |
+
raise ValueError(
|
| 538 |
+
"human_approvals contains unknown atom_id: "
|
| 539 |
+
+ ", ".join(unknown_approvals)
|
| 540 |
+
)
|
| 541 |
+
for atom_id, value in approvals.items():
|
| 542 |
+
if type(value) is not bool:
|
| 543 |
+
raise TypeError(f"human_approvals.{atom_id} must be bool")
|
| 544 |
+
|
| 545 |
+
compound = verify_compound(
|
| 546 |
+
tuple(_claim_from_atom(atom) for atom in ingestion.atoms),
|
| 547 |
+
evidence,
|
| 548 |
+
policy=policy,
|
| 549 |
+
evaluation_time=evaluation_time,
|
| 550 |
+
human_approvals=approvals,
|
| 551 |
+
)
|
| 552 |
+
|
| 553 |
+
return IngestedVerificationResult(
|
| 554 |
+
ingestion=ingestion,
|
| 555 |
+
results=compound.results,
|
| 556 |
+
verdict=compound.verdict,
|
| 557 |
)
|
bodulskihal_core/lifecycle.py
CHANGED
|
@@ -1,280 +1,280 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
from dataclasses import dataclass
|
| 4 |
-
from enum import Enum
|
| 5 |
-
|
| 6 |
-
from .engine import Verdict, canonical_receipt_sha256
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
class ProjectState(str, Enum):
|
| 10 |
-
ACTIVE = "active"
|
| 11 |
-
FROZEN = "frozen"
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
@dataclass(frozen=True)
|
| 15 |
-
class LifecycleResult:
|
| 16 |
-
state: ProjectState
|
| 17 |
-
verdict: Verdict
|
| 18 |
-
reason: str
|
| 19 |
-
receipt: dict
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
def _non_empty(value: str, name: str) -> str:
|
| 23 |
-
if not isinstance(value, str) or not value.strip():
|
| 24 |
-
raise ValueError(f"{name} must be non-empty")
|
| 25 |
-
return value.strip()
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
def _validate_bool(value: bool, name: str) -> None:
|
| 29 |
-
if type(value) is not bool:
|
| 30 |
-
raise TypeError(f"{name} must be bool")
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
def _receipt(
|
| 34 |
-
*,
|
| 35 |
-
project_id: str,
|
| 36 |
-
transition: str,
|
| 37 |
-
from_state: ProjectState,
|
| 38 |
-
to_state: ProjectState,
|
| 39 |
-
checkpoint_digest: str | None,
|
| 40 |
-
checkpoint_verified: bool | None,
|
| 41 |
-
human_approved: bool | None,
|
| 42 |
-
policy_version: str,
|
| 43 |
-
reason: str,
|
| 44 |
-
reopen_reason: str | None = None,
|
| 45 |
-
) -> dict:
|
| 46 |
-
receipt = {
|
| 47 |
-
"subsystem": "project_lifecycle",
|
| 48 |
-
"policy_version": policy_version,
|
| 49 |
-
"project_id": project_id,
|
| 50 |
-
"transition": transition,
|
| 51 |
-
"from_state": from_state.value,
|
| 52 |
-
"to_state": to_state.value,
|
| 53 |
-
"checkpoint_digest": checkpoint_digest,
|
| 54 |
-
"checkpoint_verified": checkpoint_verified,
|
| 55 |
-
"human_approved": human_approved,
|
| 56 |
-
"reason": reason,
|
| 57 |
-
"reopen_reason": reopen_reason,
|
| 58 |
-
}
|
| 59 |
-
receipt["receipt_sha256"] = canonical_receipt_sha256(receipt)
|
| 60 |
-
return receipt
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
def freeze_project(
|
| 64 |
-
*,
|
| 65 |
-
project_id: str,
|
| 66 |
-
current_state: ProjectState,
|
| 67 |
-
checkpoint_digest: str,
|
| 68 |
-
checkpoint_verified: bool,
|
| 69 |
-
human_approved: bool,
|
| 70 |
-
policy_version: str,
|
| 71 |
-
current_checkpoint_digest: str | None = None,
|
| 72 |
-
) -> LifecycleResult:
|
| 73 |
-
project_id = _non_empty(project_id, "project_id")
|
| 74 |
-
checkpoint_digest = _non_empty(checkpoint_digest, "checkpoint_digest")
|
| 75 |
-
policy_version = _non_empty(policy_version, "policy_version")
|
| 76 |
-
_validate_bool(checkpoint_verified, "checkpoint_verified")
|
| 77 |
-
_validate_bool(human_approved, "human_approved")
|
| 78 |
-
|
| 79 |
-
if current_state is ProjectState.FROZEN:
|
| 80 |
-
if current_checkpoint_digest == checkpoint_digest:
|
| 81 |
-
reason = "already_frozen_same_checkpoint"
|
| 82 |
-
return LifecycleResult(
|
| 83 |
-
state=ProjectState.FROZEN,
|
| 84 |
-
verdict=Verdict.APPROVE,
|
| 85 |
-
reason=reason,
|
| 86 |
-
receipt=_receipt(
|
| 87 |
-
project_id=project_id,
|
| 88 |
-
transition="freeze",
|
| 89 |
-
from_state=ProjectState.FROZEN,
|
| 90 |
-
to_state=ProjectState.FROZEN,
|
| 91 |
-
checkpoint_digest=checkpoint_digest,
|
| 92 |
-
checkpoint_verified=checkpoint_verified,
|
| 93 |
-
human_approved=human_approved,
|
| 94 |
-
policy_version=policy_version,
|
| 95 |
-
reason=reason,
|
| 96 |
-
),
|
| 97 |
-
)
|
| 98 |
-
|
| 99 |
-
reason = "frozen_checkpoint_mismatch"
|
| 100 |
-
return LifecycleResult(
|
| 101 |
-
state=ProjectState.FROZEN,
|
| 102 |
-
verdict=Verdict.BLOCK,
|
| 103 |
-
reason=reason,
|
| 104 |
-
receipt=_receipt(
|
| 105 |
-
project_id=project_id,
|
| 106 |
-
transition="freeze",
|
| 107 |
-
from_state=ProjectState.FROZEN,
|
| 108 |
-
to_state=ProjectState.FROZEN,
|
| 109 |
-
checkpoint_digest=checkpoint_digest,
|
| 110 |
-
checkpoint_verified=checkpoint_verified,
|
| 111 |
-
human_approved=human_approved,
|
| 112 |
-
policy_version=policy_version,
|
| 113 |
-
reason=reason,
|
| 114 |
-
),
|
| 115 |
-
)
|
| 116 |
-
|
| 117 |
-
if not checkpoint_verified:
|
| 118 |
-
reason = "checkpoint_not_verified"
|
| 119 |
-
return LifecycleResult(
|
| 120 |
-
state=ProjectState.ACTIVE,
|
| 121 |
-
verdict=Verdict.BLOCK,
|
| 122 |
-
reason=reason,
|
| 123 |
-
receipt=_receipt(
|
| 124 |
-
project_id=project_id,
|
| 125 |
-
transition="freeze",
|
| 126 |
-
from_state=ProjectState.ACTIVE,
|
| 127 |
-
to_state=ProjectState.ACTIVE,
|
| 128 |
-
checkpoint_digest=checkpoint_digest,
|
| 129 |
-
checkpoint_verified=False,
|
| 130 |
-
human_approved=human_approved,
|
| 131 |
-
policy_version=policy_version,
|
| 132 |
-
reason=reason,
|
| 133 |
-
),
|
| 134 |
-
)
|
| 135 |
-
|
| 136 |
-
if not human_approved:
|
| 137 |
-
reason = "human_approval_required_for_freeze"
|
| 138 |
-
return LifecycleResult(
|
| 139 |
-
state=ProjectState.ACTIVE,
|
| 140 |
-
verdict=Verdict.ESCALATE,
|
| 141 |
-
reason=reason,
|
| 142 |
-
receipt=_receipt(
|
| 143 |
-
project_id=project_id,
|
| 144 |
-
transition="freeze",
|
| 145 |
-
from_state=ProjectState.ACTIVE,
|
| 146 |
-
to_state=ProjectState.ACTIVE,
|
| 147 |
-
checkpoint_digest=checkpoint_digest,
|
| 148 |
-
checkpoint_verified=True,
|
| 149 |
-
human_approved=False,
|
| 150 |
-
policy_version=policy_version,
|
| 151 |
-
reason=reason,
|
| 152 |
-
),
|
| 153 |
-
)
|
| 154 |
-
|
| 155 |
-
reason = "verified_checkpoint_frozen"
|
| 156 |
-
return LifecycleResult(
|
| 157 |
-
state=ProjectState.FROZEN,
|
| 158 |
-
verdict=Verdict.APPROVE,
|
| 159 |
-
reason=reason,
|
| 160 |
-
receipt=_receipt(
|
| 161 |
-
project_id=project_id,
|
| 162 |
-
transition="freeze",
|
| 163 |
-
from_state=ProjectState.ACTIVE,
|
| 164 |
-
to_state=ProjectState.FROZEN,
|
| 165 |
-
checkpoint_digest=checkpoint_digest,
|
| 166 |
-
checkpoint_verified=True,
|
| 167 |
-
human_approved=True,
|
| 168 |
-
policy_version=policy_version,
|
| 169 |
-
reason=reason,
|
| 170 |
-
),
|
| 171 |
-
)
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
def authorize_mutation(
|
| 175 |
-
*,
|
| 176 |
-
project_id: str,
|
| 177 |
-
current_state: ProjectState,
|
| 178 |
-
policy_version: str,
|
| 179 |
-
) -> LifecycleResult:
|
| 180 |
-
project_id = _non_empty(project_id, "project_id")
|
| 181 |
-
policy_version = _non_empty(policy_version, "policy_version")
|
| 182 |
-
|
| 183 |
-
if current_state is ProjectState.FROZEN:
|
| 184 |
-
reason = "project_frozen_controlled_reopen_required"
|
| 185 |
-
verdict = Verdict.BLOCK
|
| 186 |
-
else:
|
| 187 |
-
reason = "project_active"
|
| 188 |
-
verdict = Verdict.APPROVE
|
| 189 |
-
|
| 190 |
-
return LifecycleResult(
|
| 191 |
-
state=current_state,
|
| 192 |
-
verdict=verdict,
|
| 193 |
-
reason=reason,
|
| 194 |
-
receipt=_receipt(
|
| 195 |
-
project_id=project_id,
|
| 196 |
-
transition="mutation_gate",
|
| 197 |
-
from_state=current_state,
|
| 198 |
-
to_state=current_state,
|
| 199 |
-
checkpoint_digest=None,
|
| 200 |
-
checkpoint_verified=None,
|
| 201 |
-
human_approved=None,
|
| 202 |
-
policy_version=policy_version,
|
| 203 |
-
reason=reason,
|
| 204 |
-
),
|
| 205 |
-
)
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
def reopen_project(
|
| 209 |
-
*,
|
| 210 |
-
project_id: str,
|
| 211 |
-
current_state: ProjectState,
|
| 212 |
-
checkpoint_digest: str,
|
| 213 |
-
reopen_reason: str,
|
| 214 |
-
human_approved: bool,
|
| 215 |
-
policy_version: str,
|
| 216 |
-
) -> LifecycleResult:
|
| 217 |
-
project_id = _non_empty(project_id, "project_id")
|
| 218 |
-
checkpoint_digest = _non_empty(checkpoint_digest, "checkpoint_digest")
|
| 219 |
-
reopen_reason = _non_empty(reopen_reason, "reopen_reason")
|
| 220 |
-
policy_version = _non_empty(policy_version, "policy_version")
|
| 221 |
-
_validate_bool(human_approved, "human_approved")
|
| 222 |
-
|
| 223 |
-
if current_state is not ProjectState.FROZEN:
|
| 224 |
-
reason = "invalid_reopen_transition"
|
| 225 |
-
return LifecycleResult(
|
| 226 |
-
state=current_state,
|
| 227 |
-
verdict=Verdict.BLOCK,
|
| 228 |
-
reason=reason,
|
| 229 |
-
receipt=_receipt(
|
| 230 |
-
project_id=project_id,
|
| 231 |
-
transition="reopen",
|
| 232 |
-
from_state=current_state,
|
| 233 |
-
to_state=current_state,
|
| 234 |
-
checkpoint_digest=checkpoint_digest,
|
| 235 |
-
checkpoint_verified=True,
|
| 236 |
-
human_approved=human_approved,
|
| 237 |
-
policy_version=policy_version,
|
| 238 |
-
reason=reason,
|
| 239 |
-
reopen_reason=reopen_reason,
|
| 240 |
-
),
|
| 241 |
-
)
|
| 242 |
-
|
| 243 |
-
if not human_approved:
|
| 244 |
-
reason = "human_approval_required_for_reopen"
|
| 245 |
-
return LifecycleResult(
|
| 246 |
-
state=ProjectState.FROZEN,
|
| 247 |
-
verdict=Verdict.ESCALATE,
|
| 248 |
-
reason=reason,
|
| 249 |
-
receipt=_receipt(
|
| 250 |
-
project_id=project_id,
|
| 251 |
-
transition="reopen",
|
| 252 |
-
from_state=ProjectState.FROZEN,
|
| 253 |
-
to_state=ProjectState.FROZEN,
|
| 254 |
-
checkpoint_digest=checkpoint_digest,
|
| 255 |
-
checkpoint_verified=True,
|
| 256 |
-
human_approved=False,
|
| 257 |
-
policy_version=policy_version,
|
| 258 |
-
reason=reason,
|
| 259 |
-
reopen_reason=reopen_reason,
|
| 260 |
-
),
|
| 261 |
-
)
|
| 262 |
-
|
| 263 |
-
reason = "controlled_reopen_approved"
|
| 264 |
-
return LifecycleResult(
|
| 265 |
-
state=ProjectState.ACTIVE,
|
| 266 |
-
verdict=Verdict.APPROVE,
|
| 267 |
-
reason=reason,
|
| 268 |
-
receipt=_receipt(
|
| 269 |
-
project_id=project_id,
|
| 270 |
-
transition="reopen",
|
| 271 |
-
from_state=ProjectState.FROZEN,
|
| 272 |
-
to_state=ProjectState.ACTIVE,
|
| 273 |
-
checkpoint_digest=checkpoint_digest,
|
| 274 |
-
checkpoint_verified=True,
|
| 275 |
-
human_approved=True,
|
| 276 |
-
policy_version=policy_version,
|
| 277 |
-
reason=reason,
|
| 278 |
-
reopen_reason=reopen_reason,
|
| 279 |
-
),
|
| 280 |
)
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from enum import Enum
|
| 5 |
+
|
| 6 |
+
from .engine import Verdict, canonical_receipt_sha256
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class ProjectState(str, Enum):
|
| 10 |
+
ACTIVE = "active"
|
| 11 |
+
FROZEN = "frozen"
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@dataclass(frozen=True)
|
| 15 |
+
class LifecycleResult:
|
| 16 |
+
state: ProjectState
|
| 17 |
+
verdict: Verdict
|
| 18 |
+
reason: str
|
| 19 |
+
receipt: dict
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _non_empty(value: str, name: str) -> str:
|
| 23 |
+
if not isinstance(value, str) or not value.strip():
|
| 24 |
+
raise ValueError(f"{name} must be non-empty")
|
| 25 |
+
return value.strip()
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _validate_bool(value: bool, name: str) -> None:
|
| 29 |
+
if type(value) is not bool:
|
| 30 |
+
raise TypeError(f"{name} must be bool")
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _receipt(
|
| 34 |
+
*,
|
| 35 |
+
project_id: str,
|
| 36 |
+
transition: str,
|
| 37 |
+
from_state: ProjectState,
|
| 38 |
+
to_state: ProjectState,
|
| 39 |
+
checkpoint_digest: str | None,
|
| 40 |
+
checkpoint_verified: bool | None,
|
| 41 |
+
human_approved: bool | None,
|
| 42 |
+
policy_version: str,
|
| 43 |
+
reason: str,
|
| 44 |
+
reopen_reason: str | None = None,
|
| 45 |
+
) -> dict:
|
| 46 |
+
receipt = {
|
| 47 |
+
"subsystem": "project_lifecycle",
|
| 48 |
+
"policy_version": policy_version,
|
| 49 |
+
"project_id": project_id,
|
| 50 |
+
"transition": transition,
|
| 51 |
+
"from_state": from_state.value,
|
| 52 |
+
"to_state": to_state.value,
|
| 53 |
+
"checkpoint_digest": checkpoint_digest,
|
| 54 |
+
"checkpoint_verified": checkpoint_verified,
|
| 55 |
+
"human_approved": human_approved,
|
| 56 |
+
"reason": reason,
|
| 57 |
+
"reopen_reason": reopen_reason,
|
| 58 |
+
}
|
| 59 |
+
receipt["receipt_sha256"] = canonical_receipt_sha256(receipt)
|
| 60 |
+
return receipt
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def freeze_project(
|
| 64 |
+
*,
|
| 65 |
+
project_id: str,
|
| 66 |
+
current_state: ProjectState,
|
| 67 |
+
checkpoint_digest: str,
|
| 68 |
+
checkpoint_verified: bool,
|
| 69 |
+
human_approved: bool,
|
| 70 |
+
policy_version: str,
|
| 71 |
+
current_checkpoint_digest: str | None = None,
|
| 72 |
+
) -> LifecycleResult:
|
| 73 |
+
project_id = _non_empty(project_id, "project_id")
|
| 74 |
+
checkpoint_digest = _non_empty(checkpoint_digest, "checkpoint_digest")
|
| 75 |
+
policy_version = _non_empty(policy_version, "policy_version")
|
| 76 |
+
_validate_bool(checkpoint_verified, "checkpoint_verified")
|
| 77 |
+
_validate_bool(human_approved, "human_approved")
|
| 78 |
+
|
| 79 |
+
if current_state is ProjectState.FROZEN:
|
| 80 |
+
if current_checkpoint_digest == checkpoint_digest:
|
| 81 |
+
reason = "already_frozen_same_checkpoint"
|
| 82 |
+
return LifecycleResult(
|
| 83 |
+
state=ProjectState.FROZEN,
|
| 84 |
+
verdict=Verdict.APPROVE,
|
| 85 |
+
reason=reason,
|
| 86 |
+
receipt=_receipt(
|
| 87 |
+
project_id=project_id,
|
| 88 |
+
transition="freeze",
|
| 89 |
+
from_state=ProjectState.FROZEN,
|
| 90 |
+
to_state=ProjectState.FROZEN,
|
| 91 |
+
checkpoint_digest=checkpoint_digest,
|
| 92 |
+
checkpoint_verified=checkpoint_verified,
|
| 93 |
+
human_approved=human_approved,
|
| 94 |
+
policy_version=policy_version,
|
| 95 |
+
reason=reason,
|
| 96 |
+
),
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
reason = "frozen_checkpoint_mismatch"
|
| 100 |
+
return LifecycleResult(
|
| 101 |
+
state=ProjectState.FROZEN,
|
| 102 |
+
verdict=Verdict.BLOCK,
|
| 103 |
+
reason=reason,
|
| 104 |
+
receipt=_receipt(
|
| 105 |
+
project_id=project_id,
|
| 106 |
+
transition="freeze",
|
| 107 |
+
from_state=ProjectState.FROZEN,
|
| 108 |
+
to_state=ProjectState.FROZEN,
|
| 109 |
+
checkpoint_digest=checkpoint_digest,
|
| 110 |
+
checkpoint_verified=checkpoint_verified,
|
| 111 |
+
human_approved=human_approved,
|
| 112 |
+
policy_version=policy_version,
|
| 113 |
+
reason=reason,
|
| 114 |
+
),
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
if not checkpoint_verified:
|
| 118 |
+
reason = "checkpoint_not_verified"
|
| 119 |
+
return LifecycleResult(
|
| 120 |
+
state=ProjectState.ACTIVE,
|
| 121 |
+
verdict=Verdict.BLOCK,
|
| 122 |
+
reason=reason,
|
| 123 |
+
receipt=_receipt(
|
| 124 |
+
project_id=project_id,
|
| 125 |
+
transition="freeze",
|
| 126 |
+
from_state=ProjectState.ACTIVE,
|
| 127 |
+
to_state=ProjectState.ACTIVE,
|
| 128 |
+
checkpoint_digest=checkpoint_digest,
|
| 129 |
+
checkpoint_verified=False,
|
| 130 |
+
human_approved=human_approved,
|
| 131 |
+
policy_version=policy_version,
|
| 132 |
+
reason=reason,
|
| 133 |
+
),
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
if not human_approved:
|
| 137 |
+
reason = "human_approval_required_for_freeze"
|
| 138 |
+
return LifecycleResult(
|
| 139 |
+
state=ProjectState.ACTIVE,
|
| 140 |
+
verdict=Verdict.ESCALATE,
|
| 141 |
+
reason=reason,
|
| 142 |
+
receipt=_receipt(
|
| 143 |
+
project_id=project_id,
|
| 144 |
+
transition="freeze",
|
| 145 |
+
from_state=ProjectState.ACTIVE,
|
| 146 |
+
to_state=ProjectState.ACTIVE,
|
| 147 |
+
checkpoint_digest=checkpoint_digest,
|
| 148 |
+
checkpoint_verified=True,
|
| 149 |
+
human_approved=False,
|
| 150 |
+
policy_version=policy_version,
|
| 151 |
+
reason=reason,
|
| 152 |
+
),
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
reason = "verified_checkpoint_frozen"
|
| 156 |
+
return LifecycleResult(
|
| 157 |
+
state=ProjectState.FROZEN,
|
| 158 |
+
verdict=Verdict.APPROVE,
|
| 159 |
+
reason=reason,
|
| 160 |
+
receipt=_receipt(
|
| 161 |
+
project_id=project_id,
|
| 162 |
+
transition="freeze",
|
| 163 |
+
from_state=ProjectState.ACTIVE,
|
| 164 |
+
to_state=ProjectState.FROZEN,
|
| 165 |
+
checkpoint_digest=checkpoint_digest,
|
| 166 |
+
checkpoint_verified=True,
|
| 167 |
+
human_approved=True,
|
| 168 |
+
policy_version=policy_version,
|
| 169 |
+
reason=reason,
|
| 170 |
+
),
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def authorize_mutation(
|
| 175 |
+
*,
|
| 176 |
+
project_id: str,
|
| 177 |
+
current_state: ProjectState,
|
| 178 |
+
policy_version: str,
|
| 179 |
+
) -> LifecycleResult:
|
| 180 |
+
project_id = _non_empty(project_id, "project_id")
|
| 181 |
+
policy_version = _non_empty(policy_version, "policy_version")
|
| 182 |
+
|
| 183 |
+
if current_state is ProjectState.FROZEN:
|
| 184 |
+
reason = "project_frozen_controlled_reopen_required"
|
| 185 |
+
verdict = Verdict.BLOCK
|
| 186 |
+
else:
|
| 187 |
+
reason = "project_active"
|
| 188 |
+
verdict = Verdict.APPROVE
|
| 189 |
+
|
| 190 |
+
return LifecycleResult(
|
| 191 |
+
state=current_state,
|
| 192 |
+
verdict=verdict,
|
| 193 |
+
reason=reason,
|
| 194 |
+
receipt=_receipt(
|
| 195 |
+
project_id=project_id,
|
| 196 |
+
transition="mutation_gate",
|
| 197 |
+
from_state=current_state,
|
| 198 |
+
to_state=current_state,
|
| 199 |
+
checkpoint_digest=None,
|
| 200 |
+
checkpoint_verified=None,
|
| 201 |
+
human_approved=None,
|
| 202 |
+
policy_version=policy_version,
|
| 203 |
+
reason=reason,
|
| 204 |
+
),
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def reopen_project(
|
| 209 |
+
*,
|
| 210 |
+
project_id: str,
|
| 211 |
+
current_state: ProjectState,
|
| 212 |
+
checkpoint_digest: str,
|
| 213 |
+
reopen_reason: str,
|
| 214 |
+
human_approved: bool,
|
| 215 |
+
policy_version: str,
|
| 216 |
+
) -> LifecycleResult:
|
| 217 |
+
project_id = _non_empty(project_id, "project_id")
|
| 218 |
+
checkpoint_digest = _non_empty(checkpoint_digest, "checkpoint_digest")
|
| 219 |
+
reopen_reason = _non_empty(reopen_reason, "reopen_reason")
|
| 220 |
+
policy_version = _non_empty(policy_version, "policy_version")
|
| 221 |
+
_validate_bool(human_approved, "human_approved")
|
| 222 |
+
|
| 223 |
+
if current_state is not ProjectState.FROZEN:
|
| 224 |
+
reason = "invalid_reopen_transition"
|
| 225 |
+
return LifecycleResult(
|
| 226 |
+
state=current_state,
|
| 227 |
+
verdict=Verdict.BLOCK,
|
| 228 |
+
reason=reason,
|
| 229 |
+
receipt=_receipt(
|
| 230 |
+
project_id=project_id,
|
| 231 |
+
transition="reopen",
|
| 232 |
+
from_state=current_state,
|
| 233 |
+
to_state=current_state,
|
| 234 |
+
checkpoint_digest=checkpoint_digest,
|
| 235 |
+
checkpoint_verified=True,
|
| 236 |
+
human_approved=human_approved,
|
| 237 |
+
policy_version=policy_version,
|
| 238 |
+
reason=reason,
|
| 239 |
+
reopen_reason=reopen_reason,
|
| 240 |
+
),
|
| 241 |
+
)
|
| 242 |
+
|
| 243 |
+
if not human_approved:
|
| 244 |
+
reason = "human_approval_required_for_reopen"
|
| 245 |
+
return LifecycleResult(
|
| 246 |
+
state=ProjectState.FROZEN,
|
| 247 |
+
verdict=Verdict.ESCALATE,
|
| 248 |
+
reason=reason,
|
| 249 |
+
receipt=_receipt(
|
| 250 |
+
project_id=project_id,
|
| 251 |
+
transition="reopen",
|
| 252 |
+
from_state=ProjectState.FROZEN,
|
| 253 |
+
to_state=ProjectState.FROZEN,
|
| 254 |
+
checkpoint_digest=checkpoint_digest,
|
| 255 |
+
checkpoint_verified=True,
|
| 256 |
+
human_approved=False,
|
| 257 |
+
policy_version=policy_version,
|
| 258 |
+
reason=reason,
|
| 259 |
+
reopen_reason=reopen_reason,
|
| 260 |
+
),
|
| 261 |
+
)
|
| 262 |
+
|
| 263 |
+
reason = "controlled_reopen_approved"
|
| 264 |
+
return LifecycleResult(
|
| 265 |
+
state=ProjectState.ACTIVE,
|
| 266 |
+
verdict=Verdict.APPROVE,
|
| 267 |
+
reason=reason,
|
| 268 |
+
receipt=_receipt(
|
| 269 |
+
project_id=project_id,
|
| 270 |
+
transition="reopen",
|
| 271 |
+
from_state=ProjectState.FROZEN,
|
| 272 |
+
to_state=ProjectState.ACTIVE,
|
| 273 |
+
checkpoint_digest=checkpoint_digest,
|
| 274 |
+
checkpoint_verified=True,
|
| 275 |
+
human_approved=True,
|
| 276 |
+
policy_version=policy_version,
|
| 277 |
+
reason=reason,
|
| 278 |
+
reopen_reason=reopen_reason,
|
| 279 |
+
),
|
| 280 |
)
|
bodulskihal_core/raw_pipeline.py
CHANGED
|
@@ -1,464 +1,464 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
import hashlib
|
| 4 |
-
import re
|
| 5 |
-
from dataclasses import dataclass
|
| 6 |
-
from enum import Enum
|
| 7 |
-
|
| 8 |
-
from .engine import ClaimType, Directness
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
class SpeakerRole(str, Enum):
|
| 12 |
-
ASSISTANT = "assistant"
|
| 13 |
-
USER = "user"
|
| 14 |
-
TOOL = "tool"
|
| 15 |
-
UNKNOWN = "unknown"
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
class AtomStatus(str, Enum):
|
| 19 |
-
READY = "ready"
|
| 20 |
-
REVIEW_REQUIRED = "review_required"
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
class ClauseTypeStatus(str, Enum):
|
| 24 |
-
TYPED = "typed"
|
| 25 |
-
REVIEW_REQUIRED = "review_required"
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
@dataclass(frozen=True)
|
| 29 |
-
class RawResponseEnvelope:
|
| 30 |
-
response_id: str
|
| 31 |
-
text: str
|
| 32 |
-
speaker_role: SpeakerRole
|
| 33 |
-
raw_sha256: str
|
| 34 |
-
|
| 35 |
-
@classmethod
|
| 36 |
-
def create(
|
| 37 |
-
cls,
|
| 38 |
-
response_id: str,
|
| 39 |
-
text: str,
|
| 40 |
-
speaker_role: SpeakerRole,
|
| 41 |
-
) -> "RawResponseEnvelope":
|
| 42 |
-
if not isinstance(response_id, str) or not response_id.strip():
|
| 43 |
-
raise ValueError("response_id must be non-empty")
|
| 44 |
-
if not isinstance(text, str) or not text.strip():
|
| 45 |
-
raise ValueError("text must be non-empty")
|
| 46 |
-
if not isinstance(speaker_role, SpeakerRole):
|
| 47 |
-
raise TypeError("speaker_role must be SpeakerRole")
|
| 48 |
-
|
| 49 |
-
return cls(
|
| 50 |
-
response_id=response_id.strip(),
|
| 51 |
-
text=text,
|
| 52 |
-
speaker_role=speaker_role,
|
| 53 |
-
raw_sha256=hashlib.sha256(text.encode("utf-8")).hexdigest(),
|
| 54 |
-
)
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
@dataclass(frozen=True)
|
| 58 |
-
class ClaimAtom:
|
| 59 |
-
atom_id: str
|
| 60 |
-
response_id: str
|
| 61 |
-
start: int
|
| 62 |
-
end: int
|
| 63 |
-
source_text: str
|
| 64 |
-
status: AtomStatus
|
| 65 |
-
review_reasons: tuple[str, ...]
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
@dataclass(frozen=True)
|
| 69 |
-
class EvidenceContract:
|
| 70 |
-
atom_id: str
|
| 71 |
-
claim_type: ClaimType
|
| 72 |
-
required_directness: Directness
|
| 73 |
-
requires_post_action: bool
|
| 74 |
-
requires_human_review: bool
|
| 75 |
-
fact_eligible: bool
|
| 76 |
-
review_reasons: tuple[str, ...]
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
@dataclass(frozen=True)
|
| 80 |
-
class ClauseTypeDecision:
|
| 81 |
-
atom_id: str
|
| 82 |
-
claim_type: ClaimType | None
|
| 83 |
-
status: ClauseTypeStatus
|
| 84 |
-
reasons: tuple[str, ...]
|
| 85 |
-
rule: str
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
@dataclass(frozen=True)
|
| 89 |
-
class SpanProposal:
|
| 90 |
-
atom_id: str
|
| 91 |
-
start: int
|
| 92 |
-
end: int
|
| 93 |
-
quoted_text: str
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
_COMPOUND_CUES = re.compile(
|
| 97 |
-
r"\b(and|or|nor|but|while|although|however|whereas|because)\b",
|
| 98 |
-
re.IGNORECASE,
|
| 99 |
-
)
|
| 100 |
-
|
| 101 |
-
_EPISTEMIC_QUALIFIERS = re.compile(
|
| 102 |
-
r"\b(may|might|could|probably|likely|possibly|perhaps|appears?|seems?)\b",
|
| 103 |
-
re.IGNORECASE,
|
| 104 |
-
)
|
| 105 |
-
|
| 106 |
-
_CONDITIONAL_CUES = re.compile(
|
| 107 |
-
r"\b(if|unless|provided|assuming)\b",
|
| 108 |
-
re.IGNORECASE,
|
| 109 |
-
)
|
| 110 |
-
|
| 111 |
-
_ACTION_STATE_CUES = re.compile(
|
| 112 |
-
r"\b(completed|complete|finished|sent|submitted|deployed|merged|published|"
|
| 113 |
-
r"uploaded|downloaded|installed|deleted|created|updated|saved|executed|"
|
| 114 |
-
r"started|stopped|closed|opened|approved|rejected)\b",
|
| 115 |
-
re.IGNORECASE,
|
| 116 |
-
)
|
| 117 |
-
|
| 118 |
-
_ESTIMATE_CUES = re.compile(
|
| 119 |
-
r"\b(estimate|estimated|approximately|approximate|roughly)\b",
|
| 120 |
-
re.IGNORECASE,
|
| 121 |
-
)
|
| 122 |
-
|
| 123 |
-
_ABBREVIATIONS = {
|
| 124 |
-
"dr.",
|
| 125 |
-
"mr.",
|
| 126 |
-
"mrs.",
|
| 127 |
-
"ms.",
|
| 128 |
-
"prof.",
|
| 129 |
-
"sr.",
|
| 130 |
-
"jr.",
|
| 131 |
-
"st.",
|
| 132 |
-
}
|
| 133 |
-
|
| 134 |
-
_AMBIGUOUS_REFERENT = re.compile(
|
| 135 |
-
r"^(it|this|that|they|these|those|he|she)\b",
|
| 136 |
-
re.IGNORECASE,
|
| 137 |
-
)
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
def _review_reasons(source_text: str) -> tuple[str, ...]:
|
| 141 |
-
reasons: list[str] = []
|
| 142 |
-
stripped = source_text.strip()
|
| 143 |
-
|
| 144 |
-
if _COMPOUND_CUES.search(stripped) or ";" in stripped:
|
| 145 |
-
reasons.append("compound_clause")
|
| 146 |
-
|
| 147 |
-
if _EPISTEMIC_QUALIFIERS.search(stripped):
|
| 148 |
-
reasons.append("epistemic_qualifier")
|
| 149 |
-
|
| 150 |
-
if _CONDITIONAL_CUES.search(stripped):
|
| 151 |
-
reasons.append("conditional_clause")
|
| 152 |
-
|
| 153 |
-
if _AMBIGUOUS_REFERENT.search(stripped):
|
| 154 |
-
reasons.append("ambiguous_referent")
|
| 155 |
-
|
| 156 |
-
return tuple(reasons)
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
def bind_atom(
|
| 160 |
-
envelope: RawResponseEnvelope,
|
| 161 |
-
*,
|
| 162 |
-
atom_id: str,
|
| 163 |
-
start: int,
|
| 164 |
-
end: int,
|
| 165 |
-
proposed_text: str | None = None,
|
| 166 |
-
) -> ClaimAtom:
|
| 167 |
-
if not isinstance(atom_id, str) or not atom_id.strip():
|
| 168 |
-
raise ValueError("atom_id must be non-empty")
|
| 169 |
-
if not isinstance(start, int) or not isinstance(end, int):
|
| 170 |
-
raise TypeError("span offsets must be integers")
|
| 171 |
-
if start < 0 or end > len(envelope.text) or start >= end:
|
| 172 |
-
raise ValueError("invalid source span")
|
| 173 |
-
|
| 174 |
-
source_text = envelope.text[start:end]
|
| 175 |
-
if not source_text.strip():
|
| 176 |
-
raise ValueError("source span must contain non-whitespace text")
|
| 177 |
-
|
| 178 |
-
if proposed_text is not None and proposed_text != source_text:
|
| 179 |
-
raise ValueError("proposed text must exactly match source span")
|
| 180 |
-
|
| 181 |
-
reasons = _review_reasons(source_text)
|
| 182 |
-
status = AtomStatus.REVIEW_REQUIRED if reasons else AtomStatus.READY
|
| 183 |
-
|
| 184 |
-
return ClaimAtom(
|
| 185 |
-
atom_id=atom_id.strip(),
|
| 186 |
-
response_id=envelope.response_id,
|
| 187 |
-
start=start,
|
| 188 |
-
end=end,
|
| 189 |
-
source_text=source_text,
|
| 190 |
-
status=status,
|
| 191 |
-
review_reasons=reasons,
|
| 192 |
-
)
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
def _is_abbreviation_period(text: str, index: int) -> bool:
|
| 196 |
-
if text[index] != "." or index + 1 >= len(text):
|
| 197 |
-
return False
|
| 198 |
-
|
| 199 |
-
word_start = index - 1
|
| 200 |
-
while word_start >= 0 and text[word_start].isalpha():
|
| 201 |
-
word_start -= 1
|
| 202 |
-
token = text[word_start + 1:index + 1].lower()
|
| 203 |
-
|
| 204 |
-
is_initial = len(token) == 2 and token[0].isalpha() and token[1] == "."
|
| 205 |
-
if token not in _ABBREVIATIONS and not is_initial:
|
| 206 |
-
return False
|
| 207 |
-
|
| 208 |
-
cursor = index + 1
|
| 209 |
-
while cursor < len(text) and text[cursor] in " \t":
|
| 210 |
-
cursor += 1
|
| 211 |
-
|
| 212 |
-
if cursor >= len(text) or "\n" in text[index + 1:cursor + 1]:
|
| 213 |
-
return False
|
| 214 |
-
|
| 215 |
-
return text[cursor].isupper()
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
def _sentence_spans(text: str) -> tuple[tuple[int, int], ...]:
|
| 219 |
-
spans: list[tuple[int, int]] = []
|
| 220 |
-
length = len(text)
|
| 221 |
-
cursor = 0
|
| 222 |
-
|
| 223 |
-
while cursor < length:
|
| 224 |
-
while cursor < length and text[cursor].isspace():
|
| 225 |
-
cursor += 1
|
| 226 |
-
if cursor >= length:
|
| 227 |
-
break
|
| 228 |
-
|
| 229 |
-
start = cursor
|
| 230 |
-
end = length
|
| 231 |
-
i = cursor
|
| 232 |
-
|
| 233 |
-
while i < length:
|
| 234 |
-
ch = text[i]
|
| 235 |
-
|
| 236 |
-
if ch == "\n":
|
| 237 |
-
end = i
|
| 238 |
-
break
|
| 239 |
-
|
| 240 |
-
if ch in ".!?":
|
| 241 |
-
if ch == "." and _is_abbreviation_period(text, i):
|
| 242 |
-
i += 1
|
| 243 |
-
continue
|
| 244 |
-
|
| 245 |
-
next_index = i + 1
|
| 246 |
-
if next_index >= length or text[next_index].isspace():
|
| 247 |
-
end = next_index
|
| 248 |
-
break
|
| 249 |
-
|
| 250 |
-
i += 1
|
| 251 |
-
|
| 252 |
-
while end > start and text[end - 1].isspace():
|
| 253 |
-
end -= 1
|
| 254 |
-
|
| 255 |
-
if end > start:
|
| 256 |
-
spans.append((start, end))
|
| 257 |
-
|
| 258 |
-
cursor = max(i + 1, end)
|
| 259 |
-
|
| 260 |
-
return tuple(spans)
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
def atomize_raw_response(
|
| 264 |
-
envelope: RawResponseEnvelope,
|
| 265 |
-
) -> tuple[ClaimAtom, ...]:
|
| 266 |
-
if envelope.speaker_role is not SpeakerRole.ASSISTANT:
|
| 267 |
-
raise ValueError(
|
| 268 |
-
"only assistant responses may be atomized as assistant claims"
|
| 269 |
-
)
|
| 270 |
-
|
| 271 |
-
spans = _sentence_spans(envelope.text)
|
| 272 |
-
return tuple(
|
| 273 |
-
bind_atom(
|
| 274 |
-
envelope,
|
| 275 |
-
atom_id=f"{envelope.response_id}:atom:{index}",
|
| 276 |
-
start=start,
|
| 277 |
-
end=end,
|
| 278 |
-
)
|
| 279 |
-
for index, (start, end) in enumerate(spans, start=1)
|
| 280 |
-
)
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
def validate_atom_proposals(
|
| 284 |
-
envelope: RawResponseEnvelope,
|
| 285 |
-
proposals: list[SpanProposal] | tuple[SpanProposal, ...],
|
| 286 |
-
) -> tuple[ClaimAtom, ...]:
|
| 287 |
-
if envelope.speaker_role is not SpeakerRole.ASSISTANT:
|
| 288 |
-
raise ValueError(
|
| 289 |
-
"only assistant responses may be atomized as assistant claims"
|
| 290 |
-
)
|
| 291 |
-
|
| 292 |
-
proposal_seq = tuple(proposals)
|
| 293 |
-
atom_ids = [proposal.atom_id for proposal in proposal_seq]
|
| 294 |
-
if len(atom_ids) != len(set(atom_ids)):
|
| 295 |
-
raise ValueError("duplicate atom_id")
|
| 296 |
-
|
| 297 |
-
ordered = sorted(
|
| 298 |
-
proposal_seq,
|
| 299 |
-
key=lambda proposal: (proposal.start, proposal.end, proposal.atom_id),
|
| 300 |
-
)
|
| 301 |
-
|
| 302 |
-
previous_end: int | None = None
|
| 303 |
-
atoms: list[ClaimAtom] = []
|
| 304 |
-
|
| 305 |
-
for proposal in ordered:
|
| 306 |
-
if not isinstance(proposal.atom_id, str) or not proposal.atom_id.strip():
|
| 307 |
-
raise ValueError("atom_id must be non-empty")
|
| 308 |
-
if not isinstance(proposal.quoted_text, str):
|
| 309 |
-
raise TypeError("quoted_text must be string")
|
| 310 |
-
|
| 311 |
-
if (
|
| 312 |
-
proposal.start < 0
|
| 313 |
-
or proposal.end > len(envelope.text)
|
| 314 |
-
or proposal.start >= proposal.end
|
| 315 |
-
):
|
| 316 |
-
raise ValueError("invalid source span")
|
| 317 |
-
|
| 318 |
-
exact_slice = envelope.text[proposal.start:proposal.end]
|
| 319 |
-
if proposal.quoted_text != exact_slice:
|
| 320 |
-
raise ValueError("quoted_text must exactly match source span")
|
| 321 |
-
|
| 322 |
-
atom = bind_atom(
|
| 323 |
-
envelope,
|
| 324 |
-
atom_id=proposal.atom_id,
|
| 325 |
-
start=proposal.start,
|
| 326 |
-
end=proposal.end,
|
| 327 |
-
proposed_text=proposal.quoted_text,
|
| 328 |
-
)
|
| 329 |
-
|
| 330 |
-
if previous_end is not None and atom.start < previous_end:
|
| 331 |
-
raise ValueError("span proposals must not overlap")
|
| 332 |
-
|
| 333 |
-
atoms.append(atom)
|
| 334 |
-
previous_end = atom.end
|
| 335 |
-
|
| 336 |
-
return tuple(atoms)
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
def type_clause(atom: ClaimAtom) -> ClauseTypeDecision:
|
| 340 |
-
reasons = list(atom.review_reasons)
|
| 341 |
-
blocking_reasons = {
|
| 342 |
-
"compound_clause",
|
| 343 |
-
"conditional_clause",
|
| 344 |
-
"ambiguous_referent",
|
| 345 |
-
}
|
| 346 |
-
|
| 347 |
-
if blocking_reasons.intersection(reasons):
|
| 348 |
-
return ClauseTypeDecision(
|
| 349 |
-
atom_id=atom.atom_id,
|
| 350 |
-
claim_type=None,
|
| 351 |
-
status=ClauseTypeStatus.REVIEW_REQUIRED,
|
| 352 |
-
reasons=tuple(reasons),
|
| 353 |
-
rule="review_gate",
|
| 354 |
-
)
|
| 355 |
-
|
| 356 |
-
text = atom.source_text.strip()
|
| 357 |
-
action_signal = bool(_ACTION_STATE_CUES.search(text))
|
| 358 |
-
estimate_signal = bool(_ESTIMATE_CUES.search(text))
|
| 359 |
-
inference_signal = "epistemic_qualifier" in reasons
|
| 360 |
-
|
| 361 |
-
signals = [
|
| 362 |
-
claim_type
|
| 363 |
-
for claim_type, present in (
|
| 364 |
-
(ClaimType.ACTION_STATE, action_signal),
|
| 365 |
-
(ClaimType.ESTIMATE, estimate_signal),
|
| 366 |
-
(ClaimType.INFERENCE, inference_signal),
|
| 367 |
-
)
|
| 368 |
-
if present
|
| 369 |
-
]
|
| 370 |
-
|
| 371 |
-
if len(signals) > 1:
|
| 372 |
-
if "multiple_type_signals" not in reasons:
|
| 373 |
-
reasons.append("multiple_type_signals")
|
| 374 |
-
return ClauseTypeDecision(
|
| 375 |
-
atom_id=atom.atom_id,
|
| 376 |
-
claim_type=None,
|
| 377 |
-
status=ClauseTypeStatus.REVIEW_REQUIRED,
|
| 378 |
-
reasons=tuple(reasons),
|
| 379 |
-
rule="conflicting_type_cues",
|
| 380 |
-
)
|
| 381 |
-
|
| 382 |
-
if inference_signal:
|
| 383 |
-
return ClauseTypeDecision(
|
| 384 |
-
atom_id=atom.atom_id,
|
| 385 |
-
claim_type=ClaimType.INFERENCE,
|
| 386 |
-
status=ClauseTypeStatus.REVIEW_REQUIRED,
|
| 387 |
-
reasons=tuple(reasons),
|
| 388 |
-
rule="epistemic_qualifier",
|
| 389 |
-
)
|
| 390 |
-
|
| 391 |
-
if estimate_signal:
|
| 392 |
-
return ClauseTypeDecision(
|
| 393 |
-
atom_id=atom.atom_id,
|
| 394 |
-
claim_type=ClaimType.ESTIMATE,
|
| 395 |
-
status=ClauseTypeStatus.TYPED,
|
| 396 |
-
reasons=tuple(reasons),
|
| 397 |
-
rule="estimate_cue",
|
| 398 |
-
)
|
| 399 |
-
|
| 400 |
-
if action_signal:
|
| 401 |
-
return ClauseTypeDecision(
|
| 402 |
-
atom_id=atom.atom_id,
|
| 403 |
-
claim_type=ClaimType.ACTION_STATE,
|
| 404 |
-
status=ClauseTypeStatus.TYPED,
|
| 405 |
-
reasons=tuple(reasons),
|
| 406 |
-
rule="action_state_cue",
|
| 407 |
-
)
|
| 408 |
-
|
| 409 |
-
return ClauseTypeDecision(
|
| 410 |
-
atom_id=atom.atom_id,
|
| 411 |
-
claim_type=ClaimType.OBSERVATION,
|
| 412 |
-
status=ClauseTypeStatus.TYPED,
|
| 413 |
-
reasons=tuple(reasons),
|
| 414 |
-
rule="default_observation",
|
| 415 |
-
)
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
def build_evidence_contract(
|
| 419 |
-
atom: ClaimAtom,
|
| 420 |
-
claim_type: ClaimType,
|
| 421 |
-
*,
|
| 422 |
-
type_human_approved: bool = False,
|
| 423 |
-
) -> EvidenceContract:
|
| 424 |
-
if not isinstance(claim_type, ClaimType):
|
| 425 |
-
raise TypeError("claim_type must be ClaimType")
|
| 426 |
-
if type(type_human_approved) is not bool:
|
| 427 |
-
raise TypeError("type_human_approved must be bool")
|
| 428 |
-
|
| 429 |
-
review_reasons = list(atom.review_reasons)
|
| 430 |
-
atom_requires_review = atom.status is AtomStatus.REVIEW_REQUIRED
|
| 431 |
-
requires_human_review = atom_requires_review
|
| 432 |
-
requires_post_action = claim_type is ClaimType.ACTION_STATE
|
| 433 |
-
|
| 434 |
-
if claim_type in {ClaimType.INFERENCE, ClaimType.ESTIMATE}:
|
| 435 |
-
fact_eligible = False
|
| 436 |
-
elif claim_type is ClaimType.ACTION_STATE:
|
| 437 |
-
fact_eligible = not atom_requires_review
|
| 438 |
-
elif claim_type is ClaimType.RISK_SENSITIVE:
|
| 439 |
-
fact_eligible = False
|
| 440 |
-
requires_human_review = True
|
| 441 |
-
if "risk_sensitive_requires_human_review" not in review_reasons:
|
| 442 |
-
review_reasons.append("risk_sensitive_requires_human_review")
|
| 443 |
-
else:
|
| 444 |
-
if atom_requires_review:
|
| 445 |
-
fact_eligible = False
|
| 446 |
-
requires_human_review = True
|
| 447 |
-
elif type_human_approved:
|
| 448 |
-
fact_eligible = True
|
| 449 |
-
requires_human_review = False
|
| 450 |
-
else:
|
| 451 |
-
fact_eligible = False
|
| 452 |
-
requires_human_review = True
|
| 453 |
-
if "observation_type_requires_approval" not in review_reasons:
|
| 454 |
-
review_reasons.append("observation_type_requires_approval")
|
| 455 |
-
|
| 456 |
-
return EvidenceContract(
|
| 457 |
-
atom_id=atom.atom_id,
|
| 458 |
-
claim_type=claim_type,
|
| 459 |
-
required_directness=Directness.DIRECT,
|
| 460 |
-
requires_post_action=requires_post_action,
|
| 461 |
-
requires_human_review=requires_human_review,
|
| 462 |
-
fact_eligible=fact_eligible,
|
| 463 |
-
review_reasons=tuple(review_reasons),
|
| 464 |
)
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import hashlib
|
| 4 |
+
import re
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
from enum import Enum
|
| 7 |
+
|
| 8 |
+
from .engine import ClaimType, Directness
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class SpeakerRole(str, Enum):
|
| 12 |
+
ASSISTANT = "assistant"
|
| 13 |
+
USER = "user"
|
| 14 |
+
TOOL = "tool"
|
| 15 |
+
UNKNOWN = "unknown"
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class AtomStatus(str, Enum):
|
| 19 |
+
READY = "ready"
|
| 20 |
+
REVIEW_REQUIRED = "review_required"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class ClauseTypeStatus(str, Enum):
|
| 24 |
+
TYPED = "typed"
|
| 25 |
+
REVIEW_REQUIRED = "review_required"
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@dataclass(frozen=True)
|
| 29 |
+
class RawResponseEnvelope:
|
| 30 |
+
response_id: str
|
| 31 |
+
text: str
|
| 32 |
+
speaker_role: SpeakerRole
|
| 33 |
+
raw_sha256: str
|
| 34 |
+
|
| 35 |
+
@classmethod
|
| 36 |
+
def create(
|
| 37 |
+
cls,
|
| 38 |
+
response_id: str,
|
| 39 |
+
text: str,
|
| 40 |
+
speaker_role: SpeakerRole,
|
| 41 |
+
) -> "RawResponseEnvelope":
|
| 42 |
+
if not isinstance(response_id, str) or not response_id.strip():
|
| 43 |
+
raise ValueError("response_id must be non-empty")
|
| 44 |
+
if not isinstance(text, str) or not text.strip():
|
| 45 |
+
raise ValueError("text must be non-empty")
|
| 46 |
+
if not isinstance(speaker_role, SpeakerRole):
|
| 47 |
+
raise TypeError("speaker_role must be SpeakerRole")
|
| 48 |
+
|
| 49 |
+
return cls(
|
| 50 |
+
response_id=response_id.strip(),
|
| 51 |
+
text=text,
|
| 52 |
+
speaker_role=speaker_role,
|
| 53 |
+
raw_sha256=hashlib.sha256(text.encode("utf-8")).hexdigest(),
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@dataclass(frozen=True)
|
| 58 |
+
class ClaimAtom:
|
| 59 |
+
atom_id: str
|
| 60 |
+
response_id: str
|
| 61 |
+
start: int
|
| 62 |
+
end: int
|
| 63 |
+
source_text: str
|
| 64 |
+
status: AtomStatus
|
| 65 |
+
review_reasons: tuple[str, ...]
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
@dataclass(frozen=True)
|
| 69 |
+
class EvidenceContract:
|
| 70 |
+
atom_id: str
|
| 71 |
+
claim_type: ClaimType
|
| 72 |
+
required_directness: Directness
|
| 73 |
+
requires_post_action: bool
|
| 74 |
+
requires_human_review: bool
|
| 75 |
+
fact_eligible: bool
|
| 76 |
+
review_reasons: tuple[str, ...]
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
@dataclass(frozen=True)
|
| 80 |
+
class ClauseTypeDecision:
|
| 81 |
+
atom_id: str
|
| 82 |
+
claim_type: ClaimType | None
|
| 83 |
+
status: ClauseTypeStatus
|
| 84 |
+
reasons: tuple[str, ...]
|
| 85 |
+
rule: str
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
@dataclass(frozen=True)
|
| 89 |
+
class SpanProposal:
|
| 90 |
+
atom_id: str
|
| 91 |
+
start: int
|
| 92 |
+
end: int
|
| 93 |
+
quoted_text: str
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
_COMPOUND_CUES = re.compile(
|
| 97 |
+
r"\b(and|or|nor|but|while|although|however|whereas|because)\b",
|
| 98 |
+
re.IGNORECASE,
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
_EPISTEMIC_QUALIFIERS = re.compile(
|
| 102 |
+
r"\b(may|might|could|probably|likely|possibly|perhaps|appears?|seems?)\b",
|
| 103 |
+
re.IGNORECASE,
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
_CONDITIONAL_CUES = re.compile(
|
| 107 |
+
r"\b(if|unless|provided|assuming)\b",
|
| 108 |
+
re.IGNORECASE,
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
_ACTION_STATE_CUES = re.compile(
|
| 112 |
+
r"\b(completed|complete|finished|sent|submitted|deployed|merged|published|"
|
| 113 |
+
r"uploaded|downloaded|installed|deleted|created|updated|saved|executed|"
|
| 114 |
+
r"started|stopped|closed|opened|approved|rejected)\b",
|
| 115 |
+
re.IGNORECASE,
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
_ESTIMATE_CUES = re.compile(
|
| 119 |
+
r"\b(estimate|estimated|approximately|approximate|roughly)\b",
|
| 120 |
+
re.IGNORECASE,
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
_ABBREVIATIONS = {
|
| 124 |
+
"dr.",
|
| 125 |
+
"mr.",
|
| 126 |
+
"mrs.",
|
| 127 |
+
"ms.",
|
| 128 |
+
"prof.",
|
| 129 |
+
"sr.",
|
| 130 |
+
"jr.",
|
| 131 |
+
"st.",
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
_AMBIGUOUS_REFERENT = re.compile(
|
| 135 |
+
r"^(it|this|that|they|these|those|he|she)\b",
|
| 136 |
+
re.IGNORECASE,
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def _review_reasons(source_text: str) -> tuple[str, ...]:
|
| 141 |
+
reasons: list[str] = []
|
| 142 |
+
stripped = source_text.strip()
|
| 143 |
+
|
| 144 |
+
if _COMPOUND_CUES.search(stripped) or ";" in stripped:
|
| 145 |
+
reasons.append("compound_clause")
|
| 146 |
+
|
| 147 |
+
if _EPISTEMIC_QUALIFIERS.search(stripped):
|
| 148 |
+
reasons.append("epistemic_qualifier")
|
| 149 |
+
|
| 150 |
+
if _CONDITIONAL_CUES.search(stripped):
|
| 151 |
+
reasons.append("conditional_clause")
|
| 152 |
+
|
| 153 |
+
if _AMBIGUOUS_REFERENT.search(stripped):
|
| 154 |
+
reasons.append("ambiguous_referent")
|
| 155 |
+
|
| 156 |
+
return tuple(reasons)
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def bind_atom(
|
| 160 |
+
envelope: RawResponseEnvelope,
|
| 161 |
+
*,
|
| 162 |
+
atom_id: str,
|
| 163 |
+
start: int,
|
| 164 |
+
end: int,
|
| 165 |
+
proposed_text: str | None = None,
|
| 166 |
+
) -> ClaimAtom:
|
| 167 |
+
if not isinstance(atom_id, str) or not atom_id.strip():
|
| 168 |
+
raise ValueError("atom_id must be non-empty")
|
| 169 |
+
if not isinstance(start, int) or not isinstance(end, int):
|
| 170 |
+
raise TypeError("span offsets must be integers")
|
| 171 |
+
if start < 0 or end > len(envelope.text) or start >= end:
|
| 172 |
+
raise ValueError("invalid source span")
|
| 173 |
+
|
| 174 |
+
source_text = envelope.text[start:end]
|
| 175 |
+
if not source_text.strip():
|
| 176 |
+
raise ValueError("source span must contain non-whitespace text")
|
| 177 |
+
|
| 178 |
+
if proposed_text is not None and proposed_text != source_text:
|
| 179 |
+
raise ValueError("proposed text must exactly match source span")
|
| 180 |
+
|
| 181 |
+
reasons = _review_reasons(source_text)
|
| 182 |
+
status = AtomStatus.REVIEW_REQUIRED if reasons else AtomStatus.READY
|
| 183 |
+
|
| 184 |
+
return ClaimAtom(
|
| 185 |
+
atom_id=atom_id.strip(),
|
| 186 |
+
response_id=envelope.response_id,
|
| 187 |
+
start=start,
|
| 188 |
+
end=end,
|
| 189 |
+
source_text=source_text,
|
| 190 |
+
status=status,
|
| 191 |
+
review_reasons=reasons,
|
| 192 |
+
)
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def _is_abbreviation_period(text: str, index: int) -> bool:
|
| 196 |
+
if text[index] != "." or index + 1 >= len(text):
|
| 197 |
+
return False
|
| 198 |
+
|
| 199 |
+
word_start = index - 1
|
| 200 |
+
while word_start >= 0 and text[word_start].isalpha():
|
| 201 |
+
word_start -= 1
|
| 202 |
+
token = text[word_start + 1:index + 1].lower()
|
| 203 |
+
|
| 204 |
+
is_initial = len(token) == 2 and token[0].isalpha() and token[1] == "."
|
| 205 |
+
if token not in _ABBREVIATIONS and not is_initial:
|
| 206 |
+
return False
|
| 207 |
+
|
| 208 |
+
cursor = index + 1
|
| 209 |
+
while cursor < len(text) and text[cursor] in " \t":
|
| 210 |
+
cursor += 1
|
| 211 |
+
|
| 212 |
+
if cursor >= len(text) or "\n" in text[index + 1:cursor + 1]:
|
| 213 |
+
return False
|
| 214 |
+
|
| 215 |
+
return text[cursor].isupper()
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def _sentence_spans(text: str) -> tuple[tuple[int, int], ...]:
|
| 219 |
+
spans: list[tuple[int, int]] = []
|
| 220 |
+
length = len(text)
|
| 221 |
+
cursor = 0
|
| 222 |
+
|
| 223 |
+
while cursor < length:
|
| 224 |
+
while cursor < length and text[cursor].isspace():
|
| 225 |
+
cursor += 1
|
| 226 |
+
if cursor >= length:
|
| 227 |
+
break
|
| 228 |
+
|
| 229 |
+
start = cursor
|
| 230 |
+
end = length
|
| 231 |
+
i = cursor
|
| 232 |
+
|
| 233 |
+
while i < length:
|
| 234 |
+
ch = text[i]
|
| 235 |
+
|
| 236 |
+
if ch == "\n":
|
| 237 |
+
end = i
|
| 238 |
+
break
|
| 239 |
+
|
| 240 |
+
if ch in ".!?":
|
| 241 |
+
if ch == "." and _is_abbreviation_period(text, i):
|
| 242 |
+
i += 1
|
| 243 |
+
continue
|
| 244 |
+
|
| 245 |
+
next_index = i + 1
|
| 246 |
+
if next_index >= length or text[next_index].isspace():
|
| 247 |
+
end = next_index
|
| 248 |
+
break
|
| 249 |
+
|
| 250 |
+
i += 1
|
| 251 |
+
|
| 252 |
+
while end > start and text[end - 1].isspace():
|
| 253 |
+
end -= 1
|
| 254 |
+
|
| 255 |
+
if end > start:
|
| 256 |
+
spans.append((start, end))
|
| 257 |
+
|
| 258 |
+
cursor = max(i + 1, end)
|
| 259 |
+
|
| 260 |
+
return tuple(spans)
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def atomize_raw_response(
|
| 264 |
+
envelope: RawResponseEnvelope,
|
| 265 |
+
) -> tuple[ClaimAtom, ...]:
|
| 266 |
+
if envelope.speaker_role is not SpeakerRole.ASSISTANT:
|
| 267 |
+
raise ValueError(
|
| 268 |
+
"only assistant responses may be atomized as assistant claims"
|
| 269 |
+
)
|
| 270 |
+
|
| 271 |
+
spans = _sentence_spans(envelope.text)
|
| 272 |
+
return tuple(
|
| 273 |
+
bind_atom(
|
| 274 |
+
envelope,
|
| 275 |
+
atom_id=f"{envelope.response_id}:atom:{index}",
|
| 276 |
+
start=start,
|
| 277 |
+
end=end,
|
| 278 |
+
)
|
| 279 |
+
for index, (start, end) in enumerate(spans, start=1)
|
| 280 |
+
)
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
def validate_atom_proposals(
|
| 284 |
+
envelope: RawResponseEnvelope,
|
| 285 |
+
proposals: list[SpanProposal] | tuple[SpanProposal, ...],
|
| 286 |
+
) -> tuple[ClaimAtom, ...]:
|
| 287 |
+
if envelope.speaker_role is not SpeakerRole.ASSISTANT:
|
| 288 |
+
raise ValueError(
|
| 289 |
+
"only assistant responses may be atomized as assistant claims"
|
| 290 |
+
)
|
| 291 |
+
|
| 292 |
+
proposal_seq = tuple(proposals)
|
| 293 |
+
atom_ids = [proposal.atom_id for proposal in proposal_seq]
|
| 294 |
+
if len(atom_ids) != len(set(atom_ids)):
|
| 295 |
+
raise ValueError("duplicate atom_id")
|
| 296 |
+
|
| 297 |
+
ordered = sorted(
|
| 298 |
+
proposal_seq,
|
| 299 |
+
key=lambda proposal: (proposal.start, proposal.end, proposal.atom_id),
|
| 300 |
+
)
|
| 301 |
+
|
| 302 |
+
previous_end: int | None = None
|
| 303 |
+
atoms: list[ClaimAtom] = []
|
| 304 |
+
|
| 305 |
+
for proposal in ordered:
|
| 306 |
+
if not isinstance(proposal.atom_id, str) or not proposal.atom_id.strip():
|
| 307 |
+
raise ValueError("atom_id must be non-empty")
|
| 308 |
+
if not isinstance(proposal.quoted_text, str):
|
| 309 |
+
raise TypeError("quoted_text must be string")
|
| 310 |
+
|
| 311 |
+
if (
|
| 312 |
+
proposal.start < 0
|
| 313 |
+
or proposal.end > len(envelope.text)
|
| 314 |
+
or proposal.start >= proposal.end
|
| 315 |
+
):
|
| 316 |
+
raise ValueError("invalid source span")
|
| 317 |
+
|
| 318 |
+
exact_slice = envelope.text[proposal.start:proposal.end]
|
| 319 |
+
if proposal.quoted_text != exact_slice:
|
| 320 |
+
raise ValueError("quoted_text must exactly match source span")
|
| 321 |
+
|
| 322 |
+
atom = bind_atom(
|
| 323 |
+
envelope,
|
| 324 |
+
atom_id=proposal.atom_id,
|
| 325 |
+
start=proposal.start,
|
| 326 |
+
end=proposal.end,
|
| 327 |
+
proposed_text=proposal.quoted_text,
|
| 328 |
+
)
|
| 329 |
+
|
| 330 |
+
if previous_end is not None and atom.start < previous_end:
|
| 331 |
+
raise ValueError("span proposals must not overlap")
|
| 332 |
+
|
| 333 |
+
atoms.append(atom)
|
| 334 |
+
previous_end = atom.end
|
| 335 |
+
|
| 336 |
+
return tuple(atoms)
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
def type_clause(atom: ClaimAtom) -> ClauseTypeDecision:
|
| 340 |
+
reasons = list(atom.review_reasons)
|
| 341 |
+
blocking_reasons = {
|
| 342 |
+
"compound_clause",
|
| 343 |
+
"conditional_clause",
|
| 344 |
+
"ambiguous_referent",
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
if blocking_reasons.intersection(reasons):
|
| 348 |
+
return ClauseTypeDecision(
|
| 349 |
+
atom_id=atom.atom_id,
|
| 350 |
+
claim_type=None,
|
| 351 |
+
status=ClauseTypeStatus.REVIEW_REQUIRED,
|
| 352 |
+
reasons=tuple(reasons),
|
| 353 |
+
rule="review_gate",
|
| 354 |
+
)
|
| 355 |
+
|
| 356 |
+
text = atom.source_text.strip()
|
| 357 |
+
action_signal = bool(_ACTION_STATE_CUES.search(text))
|
| 358 |
+
estimate_signal = bool(_ESTIMATE_CUES.search(text))
|
| 359 |
+
inference_signal = "epistemic_qualifier" in reasons
|
| 360 |
+
|
| 361 |
+
signals = [
|
| 362 |
+
claim_type
|
| 363 |
+
for claim_type, present in (
|
| 364 |
+
(ClaimType.ACTION_STATE, action_signal),
|
| 365 |
+
(ClaimType.ESTIMATE, estimate_signal),
|
| 366 |
+
(ClaimType.INFERENCE, inference_signal),
|
| 367 |
+
)
|
| 368 |
+
if present
|
| 369 |
+
]
|
| 370 |
+
|
| 371 |
+
if len(signals) > 1:
|
| 372 |
+
if "multiple_type_signals" not in reasons:
|
| 373 |
+
reasons.append("multiple_type_signals")
|
| 374 |
+
return ClauseTypeDecision(
|
| 375 |
+
atom_id=atom.atom_id,
|
| 376 |
+
claim_type=None,
|
| 377 |
+
status=ClauseTypeStatus.REVIEW_REQUIRED,
|
| 378 |
+
reasons=tuple(reasons),
|
| 379 |
+
rule="conflicting_type_cues",
|
| 380 |
+
)
|
| 381 |
+
|
| 382 |
+
if inference_signal:
|
| 383 |
+
return ClauseTypeDecision(
|
| 384 |
+
atom_id=atom.atom_id,
|
| 385 |
+
claim_type=ClaimType.INFERENCE,
|
| 386 |
+
status=ClauseTypeStatus.REVIEW_REQUIRED,
|
| 387 |
+
reasons=tuple(reasons),
|
| 388 |
+
rule="epistemic_qualifier",
|
| 389 |
+
)
|
| 390 |
+
|
| 391 |
+
if estimate_signal:
|
| 392 |
+
return ClauseTypeDecision(
|
| 393 |
+
atom_id=atom.atom_id,
|
| 394 |
+
claim_type=ClaimType.ESTIMATE,
|
| 395 |
+
status=ClauseTypeStatus.TYPED,
|
| 396 |
+
reasons=tuple(reasons),
|
| 397 |
+
rule="estimate_cue",
|
| 398 |
+
)
|
| 399 |
+
|
| 400 |
+
if action_signal:
|
| 401 |
+
return ClauseTypeDecision(
|
| 402 |
+
atom_id=atom.atom_id,
|
| 403 |
+
claim_type=ClaimType.ACTION_STATE,
|
| 404 |
+
status=ClauseTypeStatus.TYPED,
|
| 405 |
+
reasons=tuple(reasons),
|
| 406 |
+
rule="action_state_cue",
|
| 407 |
+
)
|
| 408 |
+
|
| 409 |
+
return ClauseTypeDecision(
|
| 410 |
+
atom_id=atom.atom_id,
|
| 411 |
+
claim_type=ClaimType.OBSERVATION,
|
| 412 |
+
status=ClauseTypeStatus.TYPED,
|
| 413 |
+
reasons=tuple(reasons),
|
| 414 |
+
rule="default_observation",
|
| 415 |
+
)
|
| 416 |
+
|
| 417 |
+
|
| 418 |
+
def build_evidence_contract(
|
| 419 |
+
atom: ClaimAtom,
|
| 420 |
+
claim_type: ClaimType,
|
| 421 |
+
*,
|
| 422 |
+
type_human_approved: bool = False,
|
| 423 |
+
) -> EvidenceContract:
|
| 424 |
+
if not isinstance(claim_type, ClaimType):
|
| 425 |
+
raise TypeError("claim_type must be ClaimType")
|
| 426 |
+
if type(type_human_approved) is not bool:
|
| 427 |
+
raise TypeError("type_human_approved must be bool")
|
| 428 |
+
|
| 429 |
+
review_reasons = list(atom.review_reasons)
|
| 430 |
+
atom_requires_review = atom.status is AtomStatus.REVIEW_REQUIRED
|
| 431 |
+
requires_human_review = atom_requires_review
|
| 432 |
+
requires_post_action = claim_type is ClaimType.ACTION_STATE
|
| 433 |
+
|
| 434 |
+
if claim_type in {ClaimType.INFERENCE, ClaimType.ESTIMATE}:
|
| 435 |
+
fact_eligible = False
|
| 436 |
+
elif claim_type is ClaimType.ACTION_STATE:
|
| 437 |
+
fact_eligible = not atom_requires_review
|
| 438 |
+
elif claim_type is ClaimType.RISK_SENSITIVE:
|
| 439 |
+
fact_eligible = False
|
| 440 |
+
requires_human_review = True
|
| 441 |
+
if "risk_sensitive_requires_human_review" not in review_reasons:
|
| 442 |
+
review_reasons.append("risk_sensitive_requires_human_review")
|
| 443 |
+
else:
|
| 444 |
+
if atom_requires_review:
|
| 445 |
+
fact_eligible = False
|
| 446 |
+
requires_human_review = True
|
| 447 |
+
elif type_human_approved:
|
| 448 |
+
fact_eligible = True
|
| 449 |
+
requires_human_review = False
|
| 450 |
+
else:
|
| 451 |
+
fact_eligible = False
|
| 452 |
+
requires_human_review = True
|
| 453 |
+
if "observation_type_requires_approval" not in review_reasons:
|
| 454 |
+
review_reasons.append("observation_type_requires_approval")
|
| 455 |
+
|
| 456 |
+
return EvidenceContract(
|
| 457 |
+
atom_id=atom.atom_id,
|
| 458 |
+
claim_type=claim_type,
|
| 459 |
+
required_directness=Directness.DIRECT,
|
| 460 |
+
requires_post_action=requires_post_action,
|
| 461 |
+
requires_human_review=requires_human_review,
|
| 462 |
+
fact_eligible=fact_eligible,
|
| 463 |
+
review_reasons=tuple(review_reasons),
|
| 464 |
)
|
bodulskihal_core/research_demo.py
CHANGED
|
@@ -1,147 +1,147 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
from typing import Any, Mapping
|
| 4 |
-
|
| 5 |
-
from .adapter import SCHEMA_VERSION, verify_compound_payload
|
| 6 |
-
from .engine import ALGORITHM_VERSION
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
def _non_empty_string(value: Any, path: str) -> str:
|
| 10 |
-
if not isinstance(value, str) or not value.strip():
|
| 11 |
-
raise TypeError(f"{path} must be non-empty string")
|
| 12 |
-
return value.strip()
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
def _metrics(results: list[dict[str, Any]]) -> dict[str, Any]:
|
| 16 |
-
total = len(results)
|
| 17 |
-
eligible_claims = sum(
|
| 18 |
-
1
|
| 19 |
-
for item in results
|
| 20 |
-
if item["receipt"]["gates"]["eligible_source_count"] > 0
|
| 21 |
-
)
|
| 22 |
-
|
| 23 |
-
return {
|
| 24 |
-
"total_claims": total,
|
| 25 |
-
"verified_fact_count": sum(
|
| 26 |
-
1
|
| 27 |
-
for item in results
|
| 28 |
-
if item["classification"] == "FACT" and item["verdict"] == "APPROVE"
|
| 29 |
-
),
|
| 30 |
-
"unknown_blocked_count": sum(
|
| 31 |
-
1
|
| 32 |
-
for item in results
|
| 33 |
-
if item["classification"] == "UNKNOWN" and item["verdict"] == "BLOCK"
|
| 34 |
-
),
|
| 35 |
-
"conflict_blocked_count": sum(
|
| 36 |
-
1
|
| 37 |
-
for item in results
|
| 38 |
-
if item["classification"] == "CONFLICT" and item["verdict"] == "BLOCK"
|
| 39 |
-
),
|
| 40 |
-
"action_gate_blocked_count": sum(
|
| 41 |
-
1
|
| 42 |
-
for item in results
|
| 43 |
-
if item["reason"] == "missing_post_action_final_state_evidence"
|
| 44 |
-
),
|
| 45 |
-
"escalated_count": sum(
|
| 46 |
-
1 for item in results if item["verdict"] == "ESCALATE"
|
| 47 |
-
),
|
| 48 |
-
"blocked_unsupported_claim_count": sum(
|
| 49 |
-
1
|
| 50 |
-
for item in results
|
| 51 |
-
if item["classification"] == "UNKNOWN" and item["verdict"] == "BLOCK"
|
| 52 |
-
),
|
| 53 |
-
"evidence_coverage": (eligible_claims / total) if total else 0.0,
|
| 54 |
-
}
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
def _bullet(item: dict[str, Any]) -> str:
|
| 58 |
-
claim = item["receipt"]["claim"]
|
| 59 |
-
return f"- [{item['classification']}/{item['verdict']}] {claim['text']}"
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
def _human_report(research_request: str, results: list[dict[str, Any]]) -> str:
|
| 63 |
-
sections = [
|
| 64 |
-
(
|
| 65 |
-
"VERIFIED FACTS",
|
| 66 |
-
[
|
| 67 |
-
item
|
| 68 |
-
for item in results
|
| 69 |
-
if item["classification"] == "FACT"
|
| 70 |
-
and item["verdict"] == "APPROVE"
|
| 71 |
-
],
|
| 72 |
-
),
|
| 73 |
-
(
|
| 74 |
-
"UNKNOWN / BLOCKED",
|
| 75 |
-
[
|
| 76 |
-
item
|
| 77 |
-
for item in results
|
| 78 |
-
if item["classification"] == "UNKNOWN"
|
| 79 |
-
and item["verdict"] == "BLOCK"
|
| 80 |
-
],
|
| 81 |
-
),
|
| 82 |
-
(
|
| 83 |
-
"CONFLICTS",
|
| 84 |
-
[item for item in results if item["classification"] == "CONFLICT"],
|
| 85 |
-
),
|
| 86 |
-
(
|
| 87 |
-
"ACTION GATE BLOCKS",
|
| 88 |
-
[
|
| 89 |
-
item
|
| 90 |
-
for item in results
|
| 91 |
-
if item["reason"] == "missing_post_action_final_state_evidence"
|
| 92 |
-
],
|
| 93 |
-
),
|
| 94 |
-
(
|
| 95 |
-
"ESCALATIONS",
|
| 96 |
-
[item for item in results if item["verdict"] == "ESCALATE"],
|
| 97 |
-
),
|
| 98 |
-
]
|
| 99 |
-
|
| 100 |
-
lines = [f"RESEARCH REQUEST: {research_request}", ""]
|
| 101 |
-
for title, items in sections:
|
| 102 |
-
lines.append(title)
|
| 103 |
-
if items:
|
| 104 |
-
lines.extend(_bullet(item) for item in items)
|
| 105 |
-
else:
|
| 106 |
-
lines.append("- none")
|
| 107 |
-
lines.append("")
|
| 108 |
-
|
| 109 |
-
return "\n".join(lines).rstrip()
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
def run_research_demo(payload: Mapping[str, Any]) -> dict[str, Any]:
|
| 113 |
-
if not isinstance(payload, Mapping):
|
| 114 |
-
raise TypeError("payload must be object")
|
| 115 |
-
|
| 116 |
-
research_request = _non_empty_string(
|
| 117 |
-
payload.get("research_request"),
|
| 118 |
-
"payload.research_request",
|
| 119 |
-
)
|
| 120 |
-
|
| 121 |
-
verification_payload = {
|
| 122 |
-
"claims": payload.get("claims"),
|
| 123 |
-
"evidence_by_claim": payload.get("evidence_by_claim"),
|
| 124 |
-
"human_approvals": payload.get("human_approvals", {}),
|
| 125 |
-
"policy": payload.get("policy"),
|
| 126 |
-
"evaluation_time": payload.get("evaluation_time"),
|
| 127 |
-
}
|
| 128 |
-
|
| 129 |
-
verified = verify_compound_payload(verification_payload)
|
| 130 |
-
results = verified["results"]
|
| 131 |
-
policy = payload.get("policy")
|
| 132 |
-
policy_version = (
|
| 133 |
-
policy.get("version")
|
| 134 |
-
if isinstance(policy, Mapping)
|
| 135 |
-
else None
|
| 136 |
-
)
|
| 137 |
-
|
| 138 |
-
return {
|
| 139 |
-
"research_request": research_request,
|
| 140 |
-
"schema_version": SCHEMA_VERSION,
|
| 141 |
-
"algorithm_version": ALGORITHM_VERSION,
|
| 142 |
-
"policy_version": policy_version,
|
| 143 |
-
"overall_verdict": verified["verdict"],
|
| 144 |
-
"results": results,
|
| 145 |
-
"metrics": _metrics(results),
|
| 146 |
-
"human_report": _human_report(research_request, results),
|
| 147 |
}
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any, Mapping
|
| 4 |
+
|
| 5 |
+
from .adapter import SCHEMA_VERSION, verify_compound_payload
|
| 6 |
+
from .engine import ALGORITHM_VERSION
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def _non_empty_string(value: Any, path: str) -> str:
|
| 10 |
+
if not isinstance(value, str) or not value.strip():
|
| 11 |
+
raise TypeError(f"{path} must be non-empty string")
|
| 12 |
+
return value.strip()
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _metrics(results: list[dict[str, Any]]) -> dict[str, Any]:
|
| 16 |
+
total = len(results)
|
| 17 |
+
eligible_claims = sum(
|
| 18 |
+
1
|
| 19 |
+
for item in results
|
| 20 |
+
if item["receipt"]["gates"]["eligible_source_count"] > 0
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
return {
|
| 24 |
+
"total_claims": total,
|
| 25 |
+
"verified_fact_count": sum(
|
| 26 |
+
1
|
| 27 |
+
for item in results
|
| 28 |
+
if item["classification"] == "FACT" and item["verdict"] == "APPROVE"
|
| 29 |
+
),
|
| 30 |
+
"unknown_blocked_count": sum(
|
| 31 |
+
1
|
| 32 |
+
for item in results
|
| 33 |
+
if item["classification"] == "UNKNOWN" and item["verdict"] == "BLOCK"
|
| 34 |
+
),
|
| 35 |
+
"conflict_blocked_count": sum(
|
| 36 |
+
1
|
| 37 |
+
for item in results
|
| 38 |
+
if item["classification"] == "CONFLICT" and item["verdict"] == "BLOCK"
|
| 39 |
+
),
|
| 40 |
+
"action_gate_blocked_count": sum(
|
| 41 |
+
1
|
| 42 |
+
for item in results
|
| 43 |
+
if item["reason"] == "missing_post_action_final_state_evidence"
|
| 44 |
+
),
|
| 45 |
+
"escalated_count": sum(
|
| 46 |
+
1 for item in results if item["verdict"] == "ESCALATE"
|
| 47 |
+
),
|
| 48 |
+
"blocked_unsupported_claim_count": sum(
|
| 49 |
+
1
|
| 50 |
+
for item in results
|
| 51 |
+
if item["classification"] == "UNKNOWN" and item["verdict"] == "BLOCK"
|
| 52 |
+
),
|
| 53 |
+
"evidence_coverage": (eligible_claims / total) if total else 0.0,
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _bullet(item: dict[str, Any]) -> str:
|
| 58 |
+
claim = item["receipt"]["claim"]
|
| 59 |
+
return f"- [{item['classification']}/{item['verdict']}] {claim['text']}"
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _human_report(research_request: str, results: list[dict[str, Any]]) -> str:
|
| 63 |
+
sections = [
|
| 64 |
+
(
|
| 65 |
+
"VERIFIED FACTS",
|
| 66 |
+
[
|
| 67 |
+
item
|
| 68 |
+
for item in results
|
| 69 |
+
if item["classification"] == "FACT"
|
| 70 |
+
and item["verdict"] == "APPROVE"
|
| 71 |
+
],
|
| 72 |
+
),
|
| 73 |
+
(
|
| 74 |
+
"UNKNOWN / BLOCKED",
|
| 75 |
+
[
|
| 76 |
+
item
|
| 77 |
+
for item in results
|
| 78 |
+
if item["classification"] == "UNKNOWN"
|
| 79 |
+
and item["verdict"] == "BLOCK"
|
| 80 |
+
],
|
| 81 |
+
),
|
| 82 |
+
(
|
| 83 |
+
"CONFLICTS",
|
| 84 |
+
[item for item in results if item["classification"] == "CONFLICT"],
|
| 85 |
+
),
|
| 86 |
+
(
|
| 87 |
+
"ACTION GATE BLOCKS",
|
| 88 |
+
[
|
| 89 |
+
item
|
| 90 |
+
for item in results
|
| 91 |
+
if item["reason"] == "missing_post_action_final_state_evidence"
|
| 92 |
+
],
|
| 93 |
+
),
|
| 94 |
+
(
|
| 95 |
+
"ESCALATIONS",
|
| 96 |
+
[item for item in results if item["verdict"] == "ESCALATE"],
|
| 97 |
+
),
|
| 98 |
+
]
|
| 99 |
+
|
| 100 |
+
lines = [f"RESEARCH REQUEST: {research_request}", ""]
|
| 101 |
+
for title, items in sections:
|
| 102 |
+
lines.append(title)
|
| 103 |
+
if items:
|
| 104 |
+
lines.extend(_bullet(item) for item in items)
|
| 105 |
+
else:
|
| 106 |
+
lines.append("- none")
|
| 107 |
+
lines.append("")
|
| 108 |
+
|
| 109 |
+
return "\n".join(lines).rstrip()
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def run_research_demo(payload: Mapping[str, Any]) -> dict[str, Any]:
|
| 113 |
+
if not isinstance(payload, Mapping):
|
| 114 |
+
raise TypeError("payload must be object")
|
| 115 |
+
|
| 116 |
+
research_request = _non_empty_string(
|
| 117 |
+
payload.get("research_request"),
|
| 118 |
+
"payload.research_request",
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
verification_payload = {
|
| 122 |
+
"claims": payload.get("claims"),
|
| 123 |
+
"evidence_by_claim": payload.get("evidence_by_claim"),
|
| 124 |
+
"human_approvals": payload.get("human_approvals", {}),
|
| 125 |
+
"policy": payload.get("policy"),
|
| 126 |
+
"evaluation_time": payload.get("evaluation_time"),
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
verified = verify_compound_payload(verification_payload)
|
| 130 |
+
results = verified["results"]
|
| 131 |
+
policy = payload.get("policy")
|
| 132 |
+
policy_version = (
|
| 133 |
+
policy.get("version")
|
| 134 |
+
if isinstance(policy, Mapping)
|
| 135 |
+
else None
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
return {
|
| 139 |
+
"research_request": research_request,
|
| 140 |
+
"schema_version": SCHEMA_VERSION,
|
| 141 |
+
"algorithm_version": ALGORITHM_VERSION,
|
| 142 |
+
"policy_version": policy_version,
|
| 143 |
+
"overall_verdict": verified["verdict"],
|
| 144 |
+
"results": results,
|
| 145 |
+
"metrics": _metrics(results),
|
| 146 |
+
"human_report": _human_report(research_request, results),
|
| 147 |
}
|
bodulskihal_core/runtime_provenance.py
CHANGED
|
@@ -1,48 +1,48 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
import os
|
| 4 |
-
import re
|
| 5 |
-
from dataclasses import asdict, dataclass
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
UNVERIFIED = "UNVERIFIED"
|
| 9 |
-
_GIT_DIGEST = re.compile(r"^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$")
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
@dataclass(frozen=True)
|
| 13 |
-
class RuntimeProvenance:
|
| 14 |
-
source_commit: str
|
| 15 |
-
source_ref: str
|
| 16 |
-
build_id: str
|
| 17 |
-
verified: bool
|
| 18 |
-
|
| 19 |
-
def as_dict(self) -> dict[str, str | bool]:
|
| 20 |
-
return asdict(self)
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
def _env_value(name: str) -> str:
|
| 24 |
-
raw = os.environ.get(name)
|
| 25 |
-
if raw is None:
|
| 26 |
-
return UNVERIFIED
|
| 27 |
-
value = raw.strip()
|
| 28 |
-
return value if value else UNVERIFIED
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
def current_runtime_provenance() -> RuntimeProvenance:
|
| 32 |
-
source_commit = _env_value("BODULSKIHAL_SOURCE_COMMIT")
|
| 33 |
-
source_ref = _env_value("BODULSKIHAL_SOURCE_REF")
|
| 34 |
-
build_id = _env_value("BODULSKIHAL_BUILD_ID")
|
| 35 |
-
|
| 36 |
-
commit_verified = bool(_GIT_DIGEST.fullmatch(source_commit))
|
| 37 |
-
verified = (
|
| 38 |
-
commit_verified
|
| 39 |
-
and source_ref != UNVERIFIED
|
| 40 |
-
and build_id != UNVERIFIED
|
| 41 |
-
)
|
| 42 |
-
|
| 43 |
-
return RuntimeProvenance(
|
| 44 |
-
source_commit=source_commit.lower() if commit_verified else source_commit,
|
| 45 |
-
source_ref=source_ref,
|
| 46 |
-
build_id=build_id,
|
| 47 |
-
verified=verified,
|
| 48 |
)
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import re
|
| 5 |
+
from dataclasses import asdict, dataclass
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
UNVERIFIED = "UNVERIFIED"
|
| 9 |
+
_GIT_DIGEST = re.compile(r"^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$")
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@dataclass(frozen=True)
|
| 13 |
+
class RuntimeProvenance:
|
| 14 |
+
source_commit: str
|
| 15 |
+
source_ref: str
|
| 16 |
+
build_id: str
|
| 17 |
+
verified: bool
|
| 18 |
+
|
| 19 |
+
def as_dict(self) -> dict[str, str | bool]:
|
| 20 |
+
return asdict(self)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _env_value(name: str) -> str:
|
| 24 |
+
raw = os.environ.get(name)
|
| 25 |
+
if raw is None:
|
| 26 |
+
return UNVERIFIED
|
| 27 |
+
value = raw.strip()
|
| 28 |
+
return value if value else UNVERIFIED
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def current_runtime_provenance() -> RuntimeProvenance:
|
| 32 |
+
source_commit = _env_value("BODULSKIHAL_SOURCE_COMMIT")
|
| 33 |
+
source_ref = _env_value("BODULSKIHAL_SOURCE_REF")
|
| 34 |
+
build_id = _env_value("BODULSKIHAL_BUILD_ID")
|
| 35 |
+
|
| 36 |
+
commit_verified = bool(_GIT_DIGEST.fullmatch(source_commit))
|
| 37 |
+
verified = (
|
| 38 |
+
commit_verified
|
| 39 |
+
and source_ref != UNVERIFIED
|
| 40 |
+
and build_id != UNVERIFIED
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
return RuntimeProvenance(
|
| 44 |
+
source_commit=source_commit.lower() if commit_verified else source_commit,
|
| 45 |
+
source_ref=source_ref,
|
| 46 |
+
build_id=build_id,
|
| 47 |
+
verified=verified,
|
| 48 |
)
|
build_archive.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Rebuild the exact source ZIP from a verified extracted release, offline."""
|
| 3 |
+
import hashlib
|
| 4 |
+
import json
|
| 5 |
+
import sys
|
| 6 |
+
import zipfile
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from safe_json import strict_loads
|
| 9 |
+
from verify_release import verify_release
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def build_archive(root, target):
|
| 13 |
+
root, target = Path(root).resolve(), Path(target).resolve()
|
| 14 |
+
if target.is_relative_to(root):
|
| 15 |
+
raise ValueError("Write the ZIP outside the extracted release directory")
|
| 16 |
+
checked = verify_release(root)
|
| 17 |
+
if not checked["verified"]:
|
| 18 |
+
raise ValueError("Source integrity check failed")
|
| 19 |
+
manifest = strict_loads((root / "RELEASE_MANIFEST.json").read_text(encoding="utf-8"))
|
| 20 |
+
names = sorted([*manifest["files"], "RELEASE_MANIFEST.json"])
|
| 21 |
+
with target.open("xb") as handle:
|
| 22 |
+
with zipfile.ZipFile(handle, "w", compression=zipfile.ZIP_STORED) as archive:
|
| 23 |
+
for name in names:
|
| 24 |
+
entry = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0))
|
| 25 |
+
entry.create_system = 3
|
| 26 |
+
entry.external_attr = 0o100644 << 16
|
| 27 |
+
entry.compress_type = zipfile.ZIP_STORED
|
| 28 |
+
archive.writestr(entry, (root / name).read_bytes())
|
| 29 |
+
data = target.read_bytes()
|
| 30 |
+
return {"built": True, "bytes": len(data), "files": len(names), "sha256": hashlib.sha256(data).hexdigest(), "sha512": hashlib.sha512(data).hexdigest()}
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
if __name__ == "__main__":
|
| 34 |
+
try:
|
| 35 |
+
if len(sys.argv) != 2:
|
| 36 |
+
raise ValueError("Usage: python -B build_archive.py ../rebuilt.zip")
|
| 37 |
+
result = build_archive(Path(__file__).resolve().parent, sys.argv[1])
|
| 38 |
+
print(json.dumps(result, sort_keys=True))
|
| 39 |
+
except (ValueError, OSError, zipfile.BadZipFile) as exc:
|
| 40 |
+
print(json.dumps({"built": False, "error": str(exc)}))
|
| 41 |
+
sys.exit(2)
|
demo.py
CHANGED
|
@@ -1,34 +1,74 @@
|
|
| 1 |
-
"""Public
|
| 2 |
from datetime import datetime, timezone
|
| 3 |
-
from typing import Any
|
| 4 |
from bodulskihal_core import verify_payload, verify_compound_payload
|
|
|
|
|
|
|
| 5 |
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
try:
|
| 8 |
-
if not isinstance(payload, dict):
|
| 9 |
-
raise TypeError(
|
| 10 |
-
|
|
|
|
|
|
|
| 11 |
if batch:
|
| 12 |
-
claims = clean.get(
|
| 13 |
if not isinstance(claims, list) or not 1 <= len(claims) <= 100:
|
| 14 |
-
raise ValueError(
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
return verify_compound_payload(clean)
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
return verify_payload(clean)
|
| 19 |
-
except (ValueError, TypeError, KeyError) as exc:
|
| 20 |
-
return {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
-
def example(scenario
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
payload = {
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
}
|
| 30 |
-
if scenario ==
|
| 31 |
-
payload[
|
| 32 |
-
elif scenario ==
|
| 33 |
-
payload[
|
|
|
|
|
|
|
| 34 |
return payload
|
|
|
|
| 1 |
+
"""Public supplied-evidence boundary v1.1.0; approvals are never authenticated."""
|
| 2 |
from datetime import datetime, timezone
|
|
|
|
| 3 |
from bodulskihal_core import verify_payload, verify_compound_payload
|
| 4 |
+
from safe_json import strict_loads, strict_dumps
|
| 5 |
+
from matrix_core import parse_instant
|
| 6 |
|
| 7 |
+
BOUNDARY_VERSION = "1.1.0"
|
| 8 |
+
MAX_EVIDENCE = 1000
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def _times(payload, evidence):
|
| 12 |
+
if "evaluation_time" in payload:
|
| 13 |
+
payload["evaluation_time"] = parse_instant(payload["evaluation_time"]).isoformat()
|
| 14 |
+
for item in evidence:
|
| 15 |
+
if isinstance(item, dict) and "observed_at" in item:
|
| 16 |
+
item["observed_at"] = parse_instant(item["observed_at"]).isoformat()
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def evaluate(payload, batch=False):
|
| 20 |
+
"""Bounded local evaluation. Input evidence is supplied, not fetched/authenticated."""
|
| 21 |
try:
|
| 22 |
+
if not isinstance(payload, dict) or type(batch) is not bool:
|
| 23 |
+
raise TypeError("payload must be an object; batch must be bool")
|
| 24 |
+
# Apply the same finite-number, scalar-Unicode and resource limits to
|
| 25 |
+
# Python callers as the public JSON entry point.
|
| 26 |
+
clean = strict_loads(strict_dumps(payload))
|
| 27 |
if batch:
|
| 28 |
+
claims = clean.get("claims")
|
| 29 |
if not isinstance(claims, list) or not 1 <= len(claims) <= 100:
|
| 30 |
+
raise ValueError("Provide between 1 and 100 claims")
|
| 31 |
+
evidence_map = clean.get("evidence_by_claim", {})
|
| 32 |
+
if not isinstance(evidence_map, dict):
|
| 33 |
+
raise TypeError("evidence_by_claim must be an object")
|
| 34 |
+
if sum(len(v) if isinstance(v, list) else MAX_EVIDENCE + 1 for v in evidence_map.values()) > MAX_EVIDENCE:
|
| 35 |
+
raise ValueError("Too many evidence records")
|
| 36 |
+
_times(clean, [item for records in evidence_map.values() for item in records])
|
| 37 |
+
clean["human_approvals"] = {}
|
| 38 |
return verify_compound_payload(clean)
|
| 39 |
+
evidence = clean.get("evidence", [])
|
| 40 |
+
if not isinstance(evidence, list) or len(evidence) > MAX_EVIDENCE:
|
| 41 |
+
raise ValueError("Evidence must be a list of at most 1000 records")
|
| 42 |
+
_times(clean, evidence)
|
| 43 |
+
clean["human_approved"] = False
|
| 44 |
return verify_payload(clean)
|
| 45 |
+
except (ValueError, TypeError, KeyError, OverflowError, RecursionError, UnicodeError) as exc:
|
| 46 |
+
return {"classification": "UNKNOWN", "verdict": "BLOCK", "reason": "invalid_input", "error": str(exc)}
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def evaluate_json(text, batch=False):
|
| 50 |
+
try:
|
| 51 |
+
return evaluate(strict_loads(text), batch=batch)
|
| 52 |
+
except (ValueError, TypeError, OverflowError, RecursionError, UnicodeError) as exc:
|
| 53 |
+
return {"classification": "UNKNOWN", "verdict": "BLOCK", "reason": "invalid_json", "error": str(exc)}
|
| 54 |
+
|
| 55 |
|
| 56 |
+
def example(scenario="supported", *, at=None):
|
| 57 |
+
"""Synthetic fixture; an explicit aware datetime supports historical replay."""
|
| 58 |
+
instant = datetime.now(timezone.utc) if at is None else at
|
| 59 |
+
if not isinstance(instant, datetime) or instant.tzinfo is None or instant.utcoffset() is None:
|
| 60 |
+
raise ValueError("at must be timezone-aware")
|
| 61 |
+
now = instant.astimezone(timezone.utc).isoformat()
|
| 62 |
payload = {
|
| 63 |
+
"claim": {"id": "synthetic-count", "text": "This synthetic record contains 30 questions", "type": "observation", "risk": "low"},
|
| 64 |
+
"evidence": [{"source_id": "synthetic-demo", "supports": True, "observed_at": now, "directness": "direct", "origin": "computed_result"}],
|
| 65 |
+
"policy": {"version": "3.0", "max_age_seconds": 300},
|
| 66 |
+
"evaluation_time": now,
|
| 67 |
}
|
| 68 |
+
if scenario == "unsupported":
|
| 69 |
+
payload["evidence"] = []
|
| 70 |
+
elif scenario == "contradiction":
|
| 71 |
+
payload["evidence"][0]["supports"] = False
|
| 72 |
+
elif scenario != "supported":
|
| 73 |
+
raise ValueError("Unknown synthetic scenario")
|
| 74 |
return payload
|
examples/contradiction.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"claim": {
|
| 3 |
+
"id": "synthetic-count",
|
| 4 |
+
"text": "This synthetic record contains 30 questions",
|
| 5 |
+
"type": "observation",
|
| 6 |
+
"risk": "low"
|
| 7 |
+
},
|
| 8 |
+
"evidence": [
|
| 9 |
+
{
|
| 10 |
+
"source_id": "synthetic-demo",
|
| 11 |
+
"supports": false,
|
| 12 |
+
"observed_at": "2026-11-03T12:00:00+00:00",
|
| 13 |
+
"directness": "direct",
|
| 14 |
+
"origin": "computed_result"
|
| 15 |
+
}
|
| 16 |
+
],
|
| 17 |
+
"policy": {
|
| 18 |
+
"version": "3.0",
|
| 19 |
+
"max_age_seconds": 300
|
| 20 |
+
},
|
| 21 |
+
"evaluation_time": "2026-11-03T12:00:00+00:00"
|
| 22 |
+
}
|
examples/matrix-result.json
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"valid": true,
|
| 3 |
+
"input_payload": "Evidence before confidence.",
|
| 4 |
+
"seal_date": "2026-11-03",
|
| 5 |
+
"matrix_verification_seal": "36e7be60a50a6d846b8b162c199a5e534c612ba171163d9c3406e2f2b12c0651",
|
| 6 |
+
"timestamp": "2026-11-03T12:00:00+00:00",
|
| 7 |
+
"engine_version": "3.6.0-OMEGA",
|
| 8 |
+
"checksum_format": "bodul-matrix-v1",
|
| 9 |
+
"normalization_profile": "bodul-whitespace-v1",
|
| 10 |
+
"hash_algorithm": "sha256"
|
| 11 |
+
}
|
examples/matrix.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
Evidence before confidence.
|
examples/supported.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"claim": {
|
| 3 |
+
"id": "synthetic-count",
|
| 4 |
+
"text": "This synthetic record contains 30 questions",
|
| 5 |
+
"type": "observation",
|
| 6 |
+
"risk": "low"
|
| 7 |
+
},
|
| 8 |
+
"evidence": [
|
| 9 |
+
{
|
| 10 |
+
"source_id": "synthetic-demo",
|
| 11 |
+
"supports": true,
|
| 12 |
+
"observed_at": "2026-11-03T12:00:00+00:00",
|
| 13 |
+
"directness": "direct",
|
| 14 |
+
"origin": "computed_result"
|
| 15 |
+
}
|
| 16 |
+
],
|
| 17 |
+
"policy": {
|
| 18 |
+
"version": "3.0",
|
| 19 |
+
"max_age_seconds": 300
|
| 20 |
+
},
|
| 21 |
+
"evaluation_time": "2026-11-03T12:00:00+00:00"
|
| 22 |
+
}
|
examples/unsupported.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"claim": {
|
| 3 |
+
"id": "synthetic-count",
|
| 4 |
+
"text": "This synthetic record contains 30 questions",
|
| 5 |
+
"type": "observation",
|
| 6 |
+
"risk": "low"
|
| 7 |
+
},
|
| 8 |
+
"evidence": [],
|
| 9 |
+
"policy": {
|
| 10 |
+
"version": "3.0",
|
| 11 |
+
"max_age_seconds": 300
|
| 12 |
+
},
|
| 13 |
+
"evaluation_time": "2026-11-03T12:00:00+00:00"
|
| 14 |
+
}
|
matrix_core.py
CHANGED
|
@@ -1,114 +1,199 @@
|
|
| 1 |
-
#!/usr/bin/env python3
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
import
|
| 15 |
-
import
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""BODULAI Matrix Core: portable v1 text checksums; Apache-2.0.
|
| 3 |
+
|
| 4 |
+
Author: Damir Balenović (mirdax25-ux).
|
| 5 |
+
The format and normalization table are frozen; see MATRIX_FORMAT.md.
|
| 6 |
+
A checksum is not a signature, authorization, or evidence of truth.
|
| 7 |
+
"""
|
| 8 |
+
import hashlib
|
| 9 |
+
import hmac
|
| 10 |
+
import json
|
| 11 |
+
import logging
|
| 12 |
+
import re
|
| 13 |
+
import sys
|
| 14 |
+
from datetime import datetime, timezone
|
| 15 |
+
from typing import Any, Dict, Optional
|
| 16 |
+
|
| 17 |
+
ENGINE_VERSION = "3.6.0-OMEGA"
|
| 18 |
+
CHECKSUM_FORMAT = "bodul-matrix-v1"
|
| 19 |
+
NORMALIZATION_PROFILE = "bodul-whitespace-v1"
|
| 20 |
+
HASH_ALGORITHM = "sha256"
|
| 21 |
+
MAX_PAYLOAD_BYTES = 1_048_576
|
| 22 |
+
# Frozen compatibility table: Python 3.12 split() whitespace, 29 code points.
|
| 23 |
+
# Do not replace with str.split(), isspace(), or regex \s: Unicode evolves.
|
| 24 |
+
WHITESPACE_CODEPOINTS = (
|
| 25 |
+
0x0009, 0x000A, 0x000B, 0x000C, 0x000D,
|
| 26 |
+
0x001C, 0x001D, 0x001E, 0x001F, 0x0020, 0x0085, 0x00A0, 0x1680,
|
| 27 |
+
0x2000, 0x2001, 0x2002, 0x2003, 0x2004, 0x2005, 0x2006, 0x2007,
|
| 28 |
+
0x2008, 0x2009, 0x200A, 0x2028, 0x2029, 0x202F, 0x205F, 0x3000,
|
| 29 |
+
)
|
| 30 |
+
_SEPARATORS = re.compile("[" + "".join(chr(n) for n in WHITESPACE_CODEPOINTS) + "]+")
|
| 31 |
+
_DATE = re.compile(r"[0-9]{4}-[0-9]{2}-[0-9]{2}\Z")
|
| 32 |
+
_HEX = re.compile(r"[0-9a-f]{64}\Z")
|
| 33 |
+
_INSTANT = re.compile(r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]{1,6})?(?:Z|[+-][0-9]{2}:[0-9]{2})\Z")
|
| 34 |
+
logger = logging.getLogger("BodulMatrixCore")
|
| 35 |
+
logger.addHandler(logging.NullHandler())
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def parse_instant(value: str) -> datetime:
|
| 39 |
+
"""Stable ASCII timestamp grammar with whole-minute offsets; return UTC."""
|
| 40 |
+
if not isinstance(value, str) or not _INSTANT.fullmatch(value):
|
| 41 |
+
raise ValueError("Use YYYY-MM-DDTHH:MM:SS[.ffffff]Z or an explicit +/-HH:MM offset")
|
| 42 |
+
# RFC 3339 -00:00 means unknown local offset, not confirmed UTC.
|
| 43 |
+
if value.endswith("-00:00"):
|
| 44 |
+
raise ValueError("Unknown UTC offset -00:00 is not accepted; use Z or +00:00")
|
| 45 |
+
if not value.endswith("Z") and (int(value[-5:-3]) > 23 or int(value[-2:]) > 59):
|
| 46 |
+
raise ValueError("UTC offset hours must be 00..23 and minutes 00..59")
|
| 47 |
+
try:
|
| 48 |
+
return datetime.fromisoformat(value[:-1] + "+00:00" if value.endswith("Z") else value).astimezone(timezone.utc)
|
| 49 |
+
except (ValueError, OverflowError) as exc:
|
| 50 |
+
raise ValueError("Timestamp is outside the supported Gregorian range 0001..9999") from exc
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _date(value: Any) -> str:
|
| 54 |
+
if not isinstance(value, str) or not _DATE.fullmatch(value):
|
| 55 |
+
raise ValueError("Date must be YYYY-MM-DD")
|
| 56 |
+
datetime(int(value[:4]), int(value[5:7]), int(value[8:10]))
|
| 57 |
+
return value
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _input_error(payload: Any) -> Optional[str]:
|
| 61 |
+
if not isinstance(payload, str):
|
| 62 |
+
return "EMPTY_OR_INVALID_PAYLOAD"
|
| 63 |
+
if len(payload) > MAX_PAYLOAD_BYTES:
|
| 64 |
+
return "PAYLOAD_TOO_LARGE"
|
| 65 |
+
try:
|
| 66 |
+
if len(payload.encode("utf-8", errors="strict")) > MAX_PAYLOAD_BYTES:
|
| 67 |
+
return "PAYLOAD_TOO_LARGE"
|
| 68 |
+
except UnicodeEncodeError:
|
| 69 |
+
return "INVALID_UNICODE"
|
| 70 |
+
return None
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def normalize_payload(payload: str) -> str:
|
| 74 |
+
"""Collapse the frozen whitespace table; no Unicode normalization/casefold."""
|
| 75 |
+
error = _input_error(payload)
|
| 76 |
+
if error:
|
| 77 |
+
raise ValueError(error)
|
| 78 |
+
# trim only the explicit table; ordinary str.strip() is Unicode-dependent.
|
| 79 |
+
return _SEPARATORS.sub(" ", payload).strip(" ")
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def checksum_for_date(payload: str, seal_date: str) -> str:
|
| 83 |
+
"""No clock or network: checksum of non-empty normalized text and UTC date."""
|
| 84 |
+
clean = normalize_payload(payload)
|
| 85 |
+
if not clean:
|
| 86 |
+
raise ValueError("EMPTY_OR_INVALID_PAYLOAD")
|
| 87 |
+
raw = "BODUL_MATRIX::" + clean + "::" + _date(seal_date) + "::OMEGA_FLOW"
|
| 88 |
+
return hashlib.sha256(raw.encode("utf-8", errors="strict")).hexdigest()
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class DeterministicMatrixCore:
|
| 92 |
+
def __init__(self, engine_version: str = ENGINE_VERSION):
|
| 93 |
+
if not isinstance(engine_version, str) or not engine_version.strip() or len(engine_version) > 128:
|
| 94 |
+
raise ValueError("engine_version must be a non-empty string of at most 128 characters")
|
| 95 |
+
self.engine_version = engine_version
|
| 96 |
+
|
| 97 |
+
@staticmethod
|
| 98 |
+
def _sanitize_payload(payload: Any) -> Optional[str]:
|
| 99 |
+
if _input_error(payload):
|
| 100 |
+
return None
|
| 101 |
+
return normalize_payload(payload) or None
|
| 102 |
+
|
| 103 |
+
@staticmethod
|
| 104 |
+
def _utc_now(now: Optional[datetime]) -> datetime:
|
| 105 |
+
if now is None:
|
| 106 |
+
return datetime.now(timezone.utc)
|
| 107 |
+
if not isinstance(now, datetime):
|
| 108 |
+
raise TypeError("now must be a timezone-aware datetime or None")
|
| 109 |
+
if now.tzinfo is None or now.utcoffset() is None:
|
| 110 |
+
raise ValueError("now must include a UTC offset or timezone")
|
| 111 |
+
try:
|
| 112 |
+
return now.astimezone(timezone.utc)
|
| 113 |
+
except (ValueError, OverflowError) as exc:
|
| 114 |
+
raise ValueError("UTC instant is outside supported years 0001..9999") from exc
|
| 115 |
+
|
| 116 |
+
def enforce_zero_tolerance_matrix(self, payload: Any, now: Optional[datetime] = None) -> Dict[str, Any]:
|
| 117 |
+
instant = self._utc_now(now)
|
| 118 |
+
metadata = {
|
| 119 |
+
"timestamp": instant.isoformat(), "engine_version": self.engine_version,
|
| 120 |
+
"checksum_format": CHECKSUM_FORMAT, "normalization_profile": NORMALIZATION_PROFILE,
|
| 121 |
+
"hash_algorithm": HASH_ALGORITHM,
|
| 122 |
+
}
|
| 123 |
+
error = _input_error(payload)
|
| 124 |
+
clean = None if error else normalize_payload(payload)
|
| 125 |
+
if not clean:
|
| 126 |
+
return {"valid": False, "error": error or "EMPTY_OR_INVALID_PAYLOAD", **metadata}
|
| 127 |
+
seal_date = instant.date().isoformat()
|
| 128 |
+
return {
|
| 129 |
+
"valid": True, "input_payload": clean, "seal_date": seal_date,
|
| 130 |
+
"matrix_verification_seal": checksum_for_date(clean, seal_date), **metadata,
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def verify_result(result: Any) -> Dict[str, Any]:
|
| 135 |
+
"""Check checksum consistency of an exported result, never source identity.
|
| 136 |
+
|
| 137 |
+
Reads original 3.5.1 records and explicit v1 records. Unknown formats and
|
| 138 |
+
partially labeled records fail closed. Metadata is not authenticated.
|
| 139 |
+
"""
|
| 140 |
+
def reject(reason):
|
| 141 |
+
return {"verified": False, "scope": "checksum_consistency", "error": reason}
|
| 142 |
+
if not isinstance(result, dict) or result.get("valid") is not True:
|
| 143 |
+
return reject("INVALID_RESULT")
|
| 144 |
+
base = {"valid", "input_payload", "seal_date", "matrix_verification_seal", "timestamp", "engine_version"}
|
| 145 |
+
identifiers = {"checksum_format", "normalization_profile", "hash_algorithm"}
|
| 146 |
+
if set(result) == base:
|
| 147 |
+
if result.get("engine_version") != "3.5.1-OMEGA":
|
| 148 |
+
return reject("UNLABELED_FORMAT")
|
| 149 |
+
elif set(result) == base | identifiers:
|
| 150 |
+
if result.get("checksum_format") != CHECKSUM_FORMAT or result.get("normalization_profile") != NORMALIZATION_PROFILE or result.get("hash_algorithm") != HASH_ALGORITHM:
|
| 151 |
+
return reject("UNSUPPORTED_FORMAT")
|
| 152 |
+
else:
|
| 153 |
+
return reject("UNEXPECTED_FIELDS")
|
| 154 |
+
try:
|
| 155 |
+
version = result["engine_version"]
|
| 156 |
+
if not isinstance(version, str) or not version.strip() or len(version) > 128:
|
| 157 |
+
return reject("INVALID_METADATA")
|
| 158 |
+
payload = result["input_payload"]
|
| 159 |
+
if not isinstance(payload, str) or not payload or normalize_payload(payload) != payload:
|
| 160 |
+
return reject("NON_CANONICAL_PAYLOAD")
|
| 161 |
+
instant = parse_instant(result["timestamp"])
|
| 162 |
+
if instant.date().isoformat() != _date(result["seal_date"]):
|
| 163 |
+
return reject("DATE_MISMATCH")
|
| 164 |
+
expected = checksum_for_date(payload, result["seal_date"])
|
| 165 |
+
provided = result["matrix_verification_seal"]
|
| 166 |
+
if not isinstance(provided, str) or not _HEX.fullmatch(provided) or not hmac.compare_digest(expected, provided):
|
| 167 |
+
return reject("CHECKSUM_MISMATCH")
|
| 168 |
+
except (ValueError, TypeError, OverflowError):
|
| 169 |
+
return reject("INVALID_RESULT")
|
| 170 |
+
return {"verified": True, "scope": "checksum_consistency", "checksum_format": CHECKSUM_FORMAT, "metadata_authenticated": False}
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def main(argv=None) -> int:
|
| 174 |
+
"""JSON-only CLI. Exit 0 valid; 2 invalid input; 1 unexpected failure."""
|
| 175 |
+
import argparse
|
| 176 |
+
for stream in (sys.stdout, sys.stderr):
|
| 177 |
+
try:
|
| 178 |
+
stream.reconfigure(encoding="utf-8")
|
| 179 |
+
except (AttributeError, ValueError):
|
| 180 |
+
pass
|
| 181 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 182 |
+
parser.add_argument("payload", nargs="*")
|
| 183 |
+
parser.add_argument("--at", help="Explicit timezone required; see MATRIX_FORMAT.md")
|
| 184 |
+
parser.add_argument("--verbose", action="store_true")
|
| 185 |
+
args = parser.parse_args(argv)
|
| 186 |
+
if args.verbose:
|
| 187 |
+
logging.basicConfig(level=logging.INFO, stream=sys.stderr)
|
| 188 |
+
try:
|
| 189 |
+
instant = parse_instant(args.at) if args.at is not None else None
|
| 190 |
+
result = DeterministicMatrixCore().enforce_zero_tolerance_matrix(" ".join(args.payload), instant)
|
| 191 |
+
except (TypeError, ValueError) as exc:
|
| 192 |
+
print(json.dumps({"valid": False, "error": "INVALID_TIMESTAMP", "detail": str(exc)}, ensure_ascii=True))
|
| 193 |
+
return 2
|
| 194 |
+
print(json.dumps(result, ensure_ascii=True, sort_keys=True, indent=2, allow_nan=False))
|
| 195 |
+
return 0 if result["valid"] else 2
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
if __name__ == "__main__":
|
| 199 |
+
sys.exit(main())
|
offline.html
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
| 3 |
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'none'; img-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'">
|
| 4 |
+
<title>Bodulski HAL | Offline Matrix</title>
|
| 5 |
+
<style>
|
| 6 |
+
:root{color-scheme:dark;font-family:system-ui,sans-serif;background:#0b1118;color:#e6edf5}*{box-sizing:border-box}body{margin:0}main{max-width:1080px;margin:auto;padding:42px 24px}h1{font-size:clamp(2rem,5vw,3rem);margin:8px 0 12px}.eyebrow{color:#8de6c3;font-size:.8rem;letter-spacing:.14em;font-weight:700}.lead{color:#b6c6d7;max-width:760px;line-height:1.65}.grid{display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-top:28px}.panel{background:#121d29;border:1px solid #2c3d50;border-radius:16px;padding:22px}label{display:block;margin-bottom:8px;font-weight:600}textarea,input{font:inherit;color:inherit;background:#09131e;border:1px solid #456075;border-radius:8px;padding:12px;width:100%;margin-bottom:20px}textarea{resize:vertical;min-height:160px}input{font-family:ui-monospace,monospace}button,a.button{font:inherit;font-weight:650;border-radius:8px;padding:11px 15px;border:1px solid #4c6d78;background:#172b36;color:#e6edf5;cursor:pointer;text-decoration:none;display:inline-block}button.primary{background:#8de6c3;border-color:#8de6c3;color:#09251b}button:focus-visible,a:focus-visible,input:focus-visible,textarea:focus-visible{outline:3px solid #b5c7ff;outline-offset:3px}.actions{display:flex;gap:9px;flex-wrap:wrap}button:disabled{opacity:.45;cursor:default}pre{white-space:pre-wrap;overflow-wrap:anywhere;font:13px/1.65 ui-monospace,monospace;margin:0}h2{font-size:1rem;margin-top:0}.note{font-size:.85rem;line-height:1.6;color:#b6c6d7}.status{color:#8de6c3;margin-top:16px;min-height:24px}footer{margin-top:26px;color:#b6c6d7;font-size:.85rem;line-height:1.7}a{color:#8de6c3}code{color:#c1d8ff}@media(max-width:760px){.grid{grid-template-columns:1fr}main{padding:28px 16px}}
|
| 7 |
+
</style></head><body><main>
|
| 8 |
+
<div class="eyebrow">BODULSKI HAL / PORTABLE EXECUTION</div>
|
| 9 |
+
<h1>A checksum you can reproduce.</h1>
|
| 10 |
+
<p class="lead">Normalize text and compute its UTC-date SHA-256 checksum. This page is a single file: no installation, external scripts, API keys or network requests. Save it and open it locally whenever you need it.</p>
|
| 11 |
+
<div class="grid"><section class="panel" aria-label="Checksum inputs">
|
| 12 |
+
<label for="payload">Input text</label><textarea id="payload" spellcheck="false">Evidence before confidence.</textarea>
|
| 13 |
+
<label for="at">Explicit timestamp with timezone</label><input id="at" value="2026-11-03T12:00:00+00:00" autocomplete="off" spellcheck="false">
|
| 14 |
+
<div class="actions"><button id="compute" class="primary" type="button">Compute checksum</button><button id="sample" type="button">Known vector</button><button id="boundary" type="button">UTC boundary</button><button id="empty" type="button">Empty input</button><button id="selftest" type="button">Run self-tests</button></div>
|
| 15 |
+
<p class="note">The explicit time is intentional: the result can be reproduced without relying on a future system clock. Input limit: 1 MiB UTF-8. Format <code>bodul-matrix-v1</code>; normalization <code>bodul-whitespace-v1</code>.</p>
|
| 16 |
+
</section><section class="panel" aria-label="Structured checksum result"><h2>Structured result</h2><pre id="output" aria-live="polite">Choose Compute checksum to begin.</pre><div class="actions" style="margin-top:20px"><button id="download" type="button" disabled>Save result.json</button></div><p id="status" class="status" role="status"></p></section></div>
|
| 17 |
+
<footer><strong>Scope:</strong> <code>valid: true</code> means non-empty, valid text within the input limit. The checksum does not authenticate a source, establish truth or authorize an action. Anyone can recompute it. Full timestamps and engine versions are metadata outside the checksum.<br>
|
| 18 |
+
Reference: Matrix Core 3.6.0-OMEGA · Apache-2.0 · Damir Balenović. The downloadable Python archive also contains the supplied-evidence verifier and tests. <a href="bodul-offline-1.1.0.zip" download>Download the complete offline source archive</a> · <a href="index.html">Open the evidence playground</a></footer>
|
| 19 |
+
</main><script>
|
| 20 |
+
"use strict";
|
| 21 |
+
(function () {
|
| 22 |
+
const VERSION="3.6.0-OMEGA", FORMAT="bodul-matrix-v1", PROFILE="bodul-whitespace-v1", LIMIT=1048576;
|
| 23 |
+
const WHITE=[9,10,11,12,13,28,29,30,31,32,133,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288];
|
| 24 |
+
const separators=new RegExp("["+WHITE.map(n=>String.fromCharCode(n)).join("")+"]+","g");
|
| 25 |
+
const encoder=new TextEncoder();
|
| 26 |
+
function normalize(text){return text.replace(separators," ").replace(/^ +| +$/g,"");}
|
| 27 |
+
function scalarText(text){for(let i=0;i<text.length;i++){const c=text.charCodeAt(i);if(c>=0xd800&&c<=0xdbff){const d=text.charCodeAt(++i);if(!(d>=0xdc00&&d<=0xdfff))return false;}else if(c>=0xdc00&&c<=0xdfff)return false;}return true;}
|
| 28 |
+
function pad(n,len){return String(n).padStart(len||2,"0");}
|
| 29 |
+
function instant(raw){
|
| 30 |
+
const m=/^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})(?:\.([0-9]{1,6}))?(Z|[+-][0-9]{2}:[0-9]{2})$/.exec(raw);
|
| 31 |
+
if(!m||m[8]==="-00:00")throw Error("Use a complete timestamp with a known UTC offset.");
|
| 32 |
+
const [y,mo,d,h,mi,s]=m.slice(1,7).map(Number), z=m[8];
|
| 33 |
+
if(y<1||mo<1||mo>12||d<1||d>31||h>23||mi>59||s>59)throw Error("Invalid Gregorian timestamp.");
|
| 34 |
+
const value=new Date(0);value.setUTCFullYear(y,mo-1,d);value.setUTCHours(h,mi,s,0);
|
| 35 |
+
if(value.getUTCFullYear()!==y||value.getUTCMonth()!==mo-1||value.getUTCDate()!==d)throw Error("Invalid Gregorian date.");
|
| 36 |
+
let offset=0;
|
| 37 |
+
if(z!=="Z"){const oh=Number(z.slice(1,3)),om=Number(z.slice(4));if(oh>23||om>59)throw Error("Invalid UTC offset.");offset=(oh*60+om)*(z[0]==="-"?-1:1);}
|
| 38 |
+
value.setTime(value.getTime()-offset*60000);
|
| 39 |
+
const year=value.getUTCFullYear();if(year<1||year>9999)throw Error("Supported UTC years are 0001 through 9999.");
|
| 40 |
+
const date=pad(year,4)+"-"+pad(value.getUTCMonth()+1)+"-"+pad(value.getUTCDate());
|
| 41 |
+
const micro=(m[7]||"").padEnd(6,"0");
|
| 42 |
+
return {date:date,timestamp:date+"T"+pad(value.getUTCHours())+":"+pad(value.getUTCMinutes())+":"+pad(value.getUTCSeconds())+(Number(micro)?"."+micro:"")+"+00:00"};
|
| 43 |
+
}
|
| 44 |
+
// SHA-256 compression from the published FIPS 180-4 algorithm. This is a
|
| 45 |
+
// checksum implementation, not a cryptographic certification or signature.
|
| 46 |
+
const K=[0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2];
|
| 47 |
+
function rr(n,s){return(n>>>s)|(n<<(32-s));}
|
| 48 |
+
function sha256(bytes){
|
| 49 |
+
const length=bytes.length,total=Math.ceil((length+9)/64)*64,data=new Uint8Array(total);data.set(bytes);data[length]=128;
|
| 50 |
+
const view=new DataView(data.buffer);view.setUint32(total-8,Math.floor(length/536870912),false);view.setUint32(total-4,(length*8)>>>0,false);
|
| 51 |
+
const state=[0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19],w=new Int32Array(64);
|
| 52 |
+
for(let p=0;p<total;p+=64){
|
| 53 |
+
for(let i=0;i<16;i++)w[i]=view.getInt32(p+i*4,false);
|
| 54 |
+
for(let i=16;i<64;i++){const a=w[i-15],b=w[i-2];w[i]=(w[i-16]+(rr(a,7)^rr(a,18)^(a>>>3))+w[i-7]+(rr(b,17)^rr(b,19)^(b>>>10)))|0;}
|
| 55 |
+
let [a,b,c,d,e,f,g,h]=state;
|
| 56 |
+
for(let i=0;i<64;i++){const t1=(h+(rr(e,6)^rr(e,11)^rr(e,25))+((e&f)^(~e&g))+K[i]+w[i])|0,t2=((rr(a,2)^rr(a,13)^rr(a,22))+((a&b)^(a&c)^(b&c)))|0;h=g;g=f;f=e;e=(d+t1)|0;d=c;c=b;b=a;a=(t1+t2)|0;}
|
| 57 |
+
[a,b,c,d,e,f,g,h].forEach((v,i)=>{state[i]=(state[i]+v)|0;});
|
| 58 |
+
}
|
| 59 |
+
return state.map(v=>(v>>>0).toString(16).padStart(8,"0")).join("");
|
| 60 |
+
}
|
| 61 |
+
function compute(text,at){
|
| 62 |
+
let time;try{time=instant(at);}catch(error){return{valid:false,error:"INVALID_TIMESTAMP",detail:error.message};}
|
| 63 |
+
const meta={timestamp:time.timestamp,engine_version:VERSION,checksum_format:FORMAT,normalization_profile:PROFILE,hash_algorithm:"sha256"};
|
| 64 |
+
if(typeof text!=="string")return Object.assign({valid:false,error:"EMPTY_OR_INVALID_PAYLOAD"},meta);
|
| 65 |
+
if(!scalarText(text))return Object.assign({valid:false,error:"INVALID_UNICODE"},meta);
|
| 66 |
+
if(text.length>LIMIT||encoder.encode(text).length>LIMIT)return Object.assign({valid:false,error:"PAYLOAD_TOO_LARGE"},meta);
|
| 67 |
+
const clean=normalize(text);if(!clean)return Object.assign({valid:false,error:"EMPTY_OR_INVALID_PAYLOAD"},meta);
|
| 68 |
+
const raw="BODUL_MATRIX::"+clean+"::"+time.date+"::OMEGA_FLOW";
|
| 69 |
+
return Object.assign({valid:true,input_payload:clean,seal_date:time.date,matrix_verification_seal:sha256(encoder.encode(raw))},meta);
|
| 70 |
+
}
|
| 71 |
+
const KNOWN="36e7be60a50a6d846b8b162c199a5e534c612ba171163d9c3406e2f2b12c0651";
|
| 72 |
+
function selftest(){
|
| 73 |
+
const tests=[sha256(encoder.encode(""))==="e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",sha256(encoder.encode("abc"))==="ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",compute("Evidence before confidence.","2026-11-03T12:00:00Z").matrix_verification_seal===KNOWN,compute(" Evidence\u00a0before\nconfidence. ","2026-11-04T00:30:00+02:00").matrix_verification_seal===KNOWN,compute(" ","2026-11-03T12:00:00Z").valid===false,compute("x","2100-02-29T00:00:00Z").valid===false,compute("x","2126-11-03T12:00:00Z").valid===true,compute("x","2400-02-29T00:00:00Z").valid===true,compute("\ud800","2026-11-03T12:00:00Z").error==="INVALID_UNICODE"];
|
| 74 |
+
return{passed:tests.every(Boolean),checks:tests.length,scope:"local checksum vectors, Unicode and Gregorian boundary fixtures"};
|
| 75 |
+
}
|
| 76 |
+
const el=id=>document.getElementById(id);let last=null;
|
| 77 |
+
function show(){last=compute(el("payload").value,el("at").value);el("output").textContent=JSON.stringify(last,null,2);el("download").disabled=!last.valid;el("status").textContent=last.valid?"Checksum computed locally. No network request was made.":"Input rejected: "+last.error;}
|
| 78 |
+
function fixture(kind){el("payload").value=kind==="empty"?" \t\n":"Evidence before confidence.";el("at").value=kind==="boundary"?"2026-11-04T00:30:00+02:00":"2026-11-03T12:00:00+00:00";show();}
|
| 79 |
+
el("compute").addEventListener("click",show);el("sample").addEventListener("click",()=>fixture("sample"));el("boundary").addEventListener("click",()=>fixture("boundary"));el("empty").addEventListener("click",()=>fixture("empty"));
|
| 80 |
+
el("selftest").addEventListener("click",()=>{el("output").textContent=JSON.stringify(selftest(),null,2);el("status").textContent="Self-tests completed locally.";last=null;el("download").disabled=true;});
|
| 81 |
+
el("download").addEventListener("click",()=>{if(!last||!last.valid)return;const url=URL.createObjectURL(new Blob([JSON.stringify(last,null,2)+"\n"],{type:"application/json;charset=utf-8"})),a=document.createElement("a");a.href=url;a.download="matrix-result.json";a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);});
|
| 82 |
+
window.BodulMatrix=Object.freeze({compute:compute,sha256:sha256,selftest:selftest,format:FORMAT,version:VERSION,whitespace:WHITE.slice()});
|
| 83 |
+
}());
|
| 84 |
+
</script></body></html>
|
offline_cli.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Offline JSON interface. No network, API keys, model download, or pip needed."""
|
| 3 |
+
import argparse
|
| 4 |
+
import sys
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from demo import evaluate_json
|
| 7 |
+
from matrix_core import DeterministicMatrixCore, parse_instant, verify_result
|
| 8 |
+
from safe_json import MAX_JSON_BYTES, strict_dumps, strict_loads
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def _read(path, limit=MAX_JSON_BYTES):
|
| 12 |
+
stream = sys.stdin.buffer if path == "-" else Path(path).open("rb")
|
| 13 |
+
try:
|
| 14 |
+
data = stream.read(limit + 1)
|
| 15 |
+
finally:
|
| 16 |
+
if path != "-":
|
| 17 |
+
stream.close()
|
| 18 |
+
if len(data) > limit:
|
| 19 |
+
raise ValueError("Input exceeds the documented size limit")
|
| 20 |
+
return data.decode("utf-8", errors="strict")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def main(argv=None):
|
| 24 |
+
for stream in (sys.stdout, sys.stderr):
|
| 25 |
+
try:
|
| 26 |
+
stream.reconfigure(encoding="utf-8")
|
| 27 |
+
except (AttributeError, ValueError):
|
| 28 |
+
pass
|
| 29 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 30 |
+
commands = parser.add_subparsers(dest="command", required=True)
|
| 31 |
+
matrix = commands.add_parser("matrix", help="Checksum a UTF-8 text file, or - for stdin")
|
| 32 |
+
matrix.add_argument("input")
|
| 33 |
+
matrix.add_argument("--at", required=True, help="Explicit timestamp for reproducible execution")
|
| 34 |
+
verify = commands.add_parser("verify-matrix", help="Verify a saved Matrix JSON result")
|
| 35 |
+
verify.add_argument("input")
|
| 36 |
+
claims = commands.add_parser("claims", help="Evaluate supplied evidence JSON at its evaluation_time")
|
| 37 |
+
claims.add_argument("input")
|
| 38 |
+
claims.add_argument("--batch", action="store_true")
|
| 39 |
+
args = parser.parse_args(argv)
|
| 40 |
+
try:
|
| 41 |
+
text = _read(args.input)
|
| 42 |
+
if args.command == "matrix":
|
| 43 |
+
result = DeterministicMatrixCore().enforce_zero_tolerance_matrix(text, parse_instant(args.at))
|
| 44 |
+
status = 0 if result["valid"] else 2
|
| 45 |
+
elif args.command == "verify-matrix":
|
| 46 |
+
result = verify_result(strict_loads(text))
|
| 47 |
+
status = 0 if result["verified"] else 2
|
| 48 |
+
else:
|
| 49 |
+
result = evaluate_json(text, batch=args.batch)
|
| 50 |
+
status = 0 if result.get("verdict") == "APPROVE" else 3
|
| 51 |
+
except (ValueError, TypeError, UnicodeError, OSError, OverflowError) as exc:
|
| 52 |
+
result = {"valid": False, "error": "INVALID_INPUT", "detail": str(exc)}
|
| 53 |
+
status = 2
|
| 54 |
+
print(strict_dumps(result))
|
| 55 |
+
return status
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
if __name__ == "__main__":
|
| 59 |
+
sys.exit(main())
|
requirements.txt
CHANGED
|
@@ -1 +1,2 @@
|
|
| 1 |
-
#
|
|
|
|
|
|
| 1 |
+
# Python 3.10+ standard library only for the core and offline CLI.
|
| 2 |
+
# The hosted Gradio-Lite presentation manages its separately pinned browser dependencies.
|
run_example.py
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
-
import json
|
| 2 |
-
from demo import evaluate, example
|
| 3 |
-
print(json.dumps(evaluate(example()), indent=2))
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from demo import evaluate, example
|
| 3 |
+
print(json.dumps(evaluate(example()), indent=2))
|
safe_json.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Bounded UTF-8 JSON boundary, rejecting duplicate keys and non-finite values."""
|
| 2 |
+
import json
|
| 3 |
+
|
| 4 |
+
MAX_JSON_BYTES = 1_048_576
|
| 5 |
+
MAX_JSON_DEPTH = 32
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def _pairs(pairs):
|
| 9 |
+
result = {}
|
| 10 |
+
for key, value in pairs:
|
| 11 |
+
if key in result:
|
| 12 |
+
raise ValueError("Duplicate JSON key")
|
| 13 |
+
result[key] = value
|
| 14 |
+
return result
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _constant(value):
|
| 18 |
+
raise ValueError("Non-finite JSON number")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _float(value):
|
| 22 |
+
import math
|
| 23 |
+
parsed = float(value)
|
| 24 |
+
if not math.isfinite(parsed):
|
| 25 |
+
raise ValueError("Non-finite JSON number")
|
| 26 |
+
return parsed
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _integer(value):
|
| 30 |
+
# Cross-runtime bound, independent of Python's changing int conversion cap.
|
| 31 |
+
if len(value.lstrip("-")) > 128:
|
| 32 |
+
raise ValueError("JSON integer exceeds 128 digits")
|
| 33 |
+
return int(value)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _check_text(text):
|
| 37 |
+
if not isinstance(text, str) or len(text) > MAX_JSON_BYTES:
|
| 38 |
+
raise ValueError("JSON input must be bounded UTF-8 text")
|
| 39 |
+
try:
|
| 40 |
+
if len(text.encode("utf-8", errors="strict")) > MAX_JSON_BYTES:
|
| 41 |
+
raise ValueError("JSON input too large")
|
| 42 |
+
except UnicodeEncodeError as exc:
|
| 43 |
+
raise ValueError("Invalid Unicode scalar value") from exc
|
| 44 |
+
# Limit nesting before asking a recursive JSON decoder to allocate objects.
|
| 45 |
+
depth = 0
|
| 46 |
+
quoted = escaped = False
|
| 47 |
+
for ch in text:
|
| 48 |
+
if quoted:
|
| 49 |
+
if escaped:
|
| 50 |
+
escaped = False
|
| 51 |
+
elif ch == "\\":
|
| 52 |
+
escaped = True
|
| 53 |
+
elif ch == '"':
|
| 54 |
+
quoted = False
|
| 55 |
+
elif ch == '"':
|
| 56 |
+
quoted = True
|
| 57 |
+
elif ch in "[{":
|
| 58 |
+
depth += 1
|
| 59 |
+
if depth > MAX_JSON_DEPTH:
|
| 60 |
+
raise ValueError("JSON nesting too deep")
|
| 61 |
+
elif ch in "]}":
|
| 62 |
+
depth -= 1
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def strict_loads(text):
|
| 66 |
+
_check_text(text)
|
| 67 |
+
try:
|
| 68 |
+
value = json.loads(text, object_pairs_hook=_pairs, parse_constant=_constant, parse_float=_float, parse_int=_integer)
|
| 69 |
+
# Escaped surrogate pairs are decoded normally; lone surrogates fail.
|
| 70 |
+
stack = [value]
|
| 71 |
+
while stack:
|
| 72 |
+
item = stack.pop()
|
| 73 |
+
if isinstance(item, str):
|
| 74 |
+
item.encode("utf-8", errors="strict")
|
| 75 |
+
elif isinstance(item, dict):
|
| 76 |
+
stack.extend(item.keys())
|
| 77 |
+
stack.extend(item.values())
|
| 78 |
+
elif isinstance(item, list):
|
| 79 |
+
stack.extend(item)
|
| 80 |
+
return value
|
| 81 |
+
except (UnicodeError, RecursionError) as exc:
|
| 82 |
+
raise ValueError("Invalid JSON Unicode or nesting") from exc
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def strict_dumps(value):
|
| 86 |
+
return json.dumps(value, ensure_ascii=True, sort_keys=True, indent=2, allow_nan=False)
|
schemas/matrix-result-v1.json
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
| 3 |
+
"$id": "urn:bodul:matrix-result:v1",
|
| 4 |
+
"title": "Bodul Matrix v1 result",
|
| 5 |
+
"type": "object",
|
| 6 |
+
"additionalProperties": false,
|
| 7 |
+
"properties": {
|
| 8 |
+
"valid": {
|
| 9 |
+
"type": "boolean"
|
| 10 |
+
},
|
| 11 |
+
"input_payload": {
|
| 12 |
+
"type": "string",
|
| 13 |
+
"minLength": 1
|
| 14 |
+
},
|
| 15 |
+
"seal_date": {
|
| 16 |
+
"type": "string",
|
| 17 |
+
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
|
| 18 |
+
},
|
| 19 |
+
"matrix_verification_seal": {
|
| 20 |
+
"type": "string",
|
| 21 |
+
"pattern": "^[0-9a-f]{64}$"
|
| 22 |
+
},
|
| 23 |
+
"timestamp": {
|
| 24 |
+
"type": "string",
|
| 25 |
+
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{6})?\\+00:00$"
|
| 26 |
+
},
|
| 27 |
+
"engine_version": {
|
| 28 |
+
"type": "string",
|
| 29 |
+
"minLength": 1,
|
| 30 |
+
"maxLength": 128
|
| 31 |
+
},
|
| 32 |
+
"checksum_format": {
|
| 33 |
+
"const": "bodul-matrix-v1"
|
| 34 |
+
},
|
| 35 |
+
"normalization_profile": {
|
| 36 |
+
"const": "bodul-whitespace-v1"
|
| 37 |
+
},
|
| 38 |
+
"hash_algorithm": {
|
| 39 |
+
"const": "sha256"
|
| 40 |
+
},
|
| 41 |
+
"error": {
|
| 42 |
+
"enum": [
|
| 43 |
+
"EMPTY_OR_INVALID_PAYLOAD",
|
| 44 |
+
"INVALID_UNICODE",
|
| 45 |
+
"PAYLOAD_TOO_LARGE"
|
| 46 |
+
]
|
| 47 |
+
}
|
| 48 |
+
},
|
| 49 |
+
"required": [
|
| 50 |
+
"valid",
|
| 51 |
+
"timestamp",
|
| 52 |
+
"engine_version",
|
| 53 |
+
"checksum_format",
|
| 54 |
+
"normalization_profile",
|
| 55 |
+
"hash_algorithm"
|
| 56 |
+
],
|
| 57 |
+
"oneOf": [
|
| 58 |
+
{
|
| 59 |
+
"properties": {
|
| 60 |
+
"valid": {
|
| 61 |
+
"const": true
|
| 62 |
+
}
|
| 63 |
+
},
|
| 64 |
+
"required": [
|
| 65 |
+
"input_payload",
|
| 66 |
+
"seal_date",
|
| 67 |
+
"matrix_verification_seal"
|
| 68 |
+
],
|
| 69 |
+
"not": {
|
| 70 |
+
"required": [
|
| 71 |
+
"error"
|
| 72 |
+
]
|
| 73 |
+
}
|
| 74 |
+
},
|
| 75 |
+
{
|
| 76 |
+
"properties": {
|
| 77 |
+
"valid": {
|
| 78 |
+
"const": false
|
| 79 |
+
}
|
| 80 |
+
},
|
| 81 |
+
"required": [
|
| 82 |
+
"error"
|
| 83 |
+
],
|
| 84 |
+
"not": {
|
| 85 |
+
"anyOf": [
|
| 86 |
+
{
|
| 87 |
+
"required": [
|
| 88 |
+
"input_payload"
|
| 89 |
+
]
|
| 90 |
+
},
|
| 91 |
+
{
|
| 92 |
+
"required": [
|
| 93 |
+
"seal_date"
|
| 94 |
+
]
|
| 95 |
+
},
|
| 96 |
+
{
|
| 97 |
+
"required": [
|
| 98 |
+
"matrix_verification_seal"
|
| 99 |
+
]
|
| 100 |
+
}
|
| 101 |
+
]
|
| 102 |
+
}
|
| 103 |
+
}
|
| 104 |
+
]
|
| 105 |
+
}
|
test_vectors.json
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"checksum_format": "bodul-matrix-v1",
|
| 3 |
+
"normalization_profile": "bodul-whitespace-v1",
|
| 4 |
+
"vectors": [
|
| 5 |
+
{
|
| 6 |
+
"id": "legacy-known",
|
| 7 |
+
"payload": "Evidence before confidence.",
|
| 8 |
+
"normalized": "Evidence before confidence.",
|
| 9 |
+
"at": "2026-11-03T12:00:00Z",
|
| 10 |
+
"utc_date": "2026-11-03",
|
| 11 |
+
"sha256": "36e7be60a50a6d846b8b162c199a5e534c612ba171163d9c3406e2f2b12c0651"
|
| 12 |
+
},
|
| 13 |
+
{
|
| 14 |
+
"id": "whitespace",
|
| 15 |
+
"payload": " \tEvidence\u00a0before\nconfidence. ",
|
| 16 |
+
"normalized": "Evidence before confidence.",
|
| 17 |
+
"at": "2026-11-03T12:00:00Z",
|
| 18 |
+
"utc_date": "2026-11-03",
|
| 19 |
+
"sha256": "36e7be60a50a6d846b8b162c199a5e534c612ba171163d9c3406e2f2b12c0651"
|
| 20 |
+
},
|
| 21 |
+
{
|
| 22 |
+
"id": "utc-boundary",
|
| 23 |
+
"payload": "Evidence before confidence.",
|
| 24 |
+
"normalized": "Evidence before confidence.",
|
| 25 |
+
"at": "2026-11-04T00:30:00+02:00",
|
| 26 |
+
"utc_date": "2026-11-03",
|
| 27 |
+
"sha256": "36e7be60a50a6d846b8b162c199a5e534c612ba171163d9c3406e2f2b12c0651"
|
| 28 |
+
},
|
| 29 |
+
{
|
| 30 |
+
"id": "croatian-emoji",
|
| 31 |
+
"payload": "\u010c\u0107\u0161\u0111\u017e \ud83d\ude00",
|
| 32 |
+
"normalized": "\u010c\u0107\u0161\u0111\u017e \ud83d\ude00",
|
| 33 |
+
"at": "2126-11-03T12:00:00Z",
|
| 34 |
+
"utc_date": "2126-11-03",
|
| 35 |
+
"sha256": "2b2080b121d8b9b26c9db80ee4fb38da7d1a4cefdccb1cf853abecf51df12468"
|
| 36 |
+
},
|
| 37 |
+
{
|
| 38 |
+
"id": "hundred-years",
|
| 39 |
+
"payload": "Evidence before confidence.",
|
| 40 |
+
"normalized": "Evidence before confidence.",
|
| 41 |
+
"at": "2126-11-03T12:00:00Z",
|
| 42 |
+
"utc_date": "2126-11-03",
|
| 43 |
+
"sha256": "fae53da0e322d987a3692f9764f76b8836741ce4832fa5c75a5bb2e7c16c1ec6"
|
| 44 |
+
},
|
| 45 |
+
{
|
| 46 |
+
"id": "gregorian-2400",
|
| 47 |
+
"payload": "leap",
|
| 48 |
+
"normalized": "leap",
|
| 49 |
+
"at": "2400-02-29T00:00:00Z",
|
| 50 |
+
"utc_date": "2400-02-29",
|
| 51 |
+
"sha256": "b94ffe1428617d114837b744c5f67b6346fa80bd79b3ba10c03486e31402be43"
|
| 52 |
+
},
|
| 53 |
+
{
|
| 54 |
+
"id": "minimum-year",
|
| 55 |
+
"payload": "origin",
|
| 56 |
+
"normalized": "origin",
|
| 57 |
+
"at": "0001-01-01T00:00:00Z",
|
| 58 |
+
"utc_date": "0001-01-01",
|
| 59 |
+
"sha256": "a5edb94148551dbe2410fe1830987ca50c9f1949c9169917324cfed8ec803275"
|
| 60 |
+
},
|
| 61 |
+
{
|
| 62 |
+
"id": "maximum-year",
|
| 63 |
+
"payload": "last",
|
| 64 |
+
"normalized": "last",
|
| 65 |
+
"at": "9999-12-31T23:59:59Z",
|
| 66 |
+
"utc_date": "9999-12-31",
|
| 67 |
+
"sha256": "e1692573d3bc5aae5ac5e7794f15ba550f8137dba78baca5928f21e6d9a244ca"
|
| 68 |
+
},
|
| 69 |
+
{
|
| 70 |
+
"id": "zero-width-preserved",
|
| 71 |
+
"payload": "a\u200bb",
|
| 72 |
+
"normalized": "a\u200bb",
|
| 73 |
+
"at": "2026-11-03T12:00:00.123456Z",
|
| 74 |
+
"utc_date": "2026-11-03",
|
| 75 |
+
"sha256": "997ae295d638782fa2844f9cdd36bbd20b359766bce16d01ad4f13bef0dc18ce"
|
| 76 |
+
},
|
| 77 |
+
{
|
| 78 |
+
"id": "decomposed",
|
| 79 |
+
"payload": "e\u0301",
|
| 80 |
+
"normalized": "e\u0301",
|
| 81 |
+
"at": "2126-11-03T12:00:00Z",
|
| 82 |
+
"utc_date": "2126-11-03",
|
| 83 |
+
"sha256": "73e5ba5a4bcef9c1a7ee0df380dd5c0f692d676c30477fccc4bda2fc30836d62"
|
| 84 |
+
},
|
| 85 |
+
{
|
| 86 |
+
"id": "composed",
|
| 87 |
+
"payload": "\u00e9",
|
| 88 |
+
"normalized": "\u00e9",
|
| 89 |
+
"at": "2126-11-03T12:00:00Z",
|
| 90 |
+
"utc_date": "2126-11-03",
|
| 91 |
+
"sha256": "fa36e13722a7c7a0377e18cc43566ecb5ef1a17b078259e2c4bca8a1266843ef"
|
| 92 |
+
},
|
| 93 |
+
{
|
| 94 |
+
"id": "literal-delimiters",
|
| 95 |
+
"payload": "a::b::OMEGA_FLOW",
|
| 96 |
+
"normalized": "a::b::OMEGA_FLOW",
|
| 97 |
+
"at": "2026-11-03T12:00:00Z",
|
| 98 |
+
"utc_date": "2026-11-03",
|
| 99 |
+
"sha256": "7a722c2f2ebc889f588b56131ffd64e222c2d7adc47b4e1fc3c5afae3464cb93"
|
| 100 |
+
},
|
| 101 |
+
{
|
| 102 |
+
"id": "utf8-bom-is-data",
|
| 103 |
+
"payload": "\ufefftest",
|
| 104 |
+
"normalized": "\ufefftest",
|
| 105 |
+
"at": "2026-11-03T12:00:00Z",
|
| 106 |
+
"utc_date": "2026-11-03",
|
| 107 |
+
"sha256": "435a547df74b925f80b707598ac0bbc9b76b2a64f731a0bca56e2780ed9295ab"
|
| 108 |
+
}
|
| 109 |
+
]
|
| 110 |
+
}
|
tests/test_longevity.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Regression checks for the portable source release; standard library only."""
|
| 2 |
+
import copy
|
| 3 |
+
import hashlib
|
| 4 |
+
import json
|
| 5 |
+
import os
|
| 6 |
+
import random
|
| 7 |
+
import socket
|
| 8 |
+
import subprocess
|
| 9 |
+
import sys
|
| 10 |
+
import tempfile
|
| 11 |
+
import unittest
|
| 12 |
+
from datetime import datetime, timedelta, timezone
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
from unittest.mock import patch
|
| 15 |
+
|
| 16 |
+
from demo import evaluate, evaluate_json, example
|
| 17 |
+
from matrix_core import (
|
| 18 |
+
CHECKSUM_FORMAT, ENGINE_VERSION, MAX_PAYLOAD_BYTES, WHITESPACE_CODEPOINTS,
|
| 19 |
+
DeterministicMatrixCore, checksum_for_date, normalize_payload, parse_instant, verify_result,
|
| 20 |
+
)
|
| 21 |
+
from safe_json import strict_loads
|
| 22 |
+
from verify_release import verify_release
|
| 23 |
+
|
| 24 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 25 |
+
FIXED = datetime(2026, 11, 3, 12, tzinfo=timezone.utc)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class LongevityTests(unittest.TestCase):
|
| 29 |
+
def setUp(self):
|
| 30 |
+
self.core = DeterministicMatrixCore()
|
| 31 |
+
|
| 32 |
+
def test_published_vectors_and_utc_dates(self):
|
| 33 |
+
vectors = json.loads((ROOT / "test_vectors.json").read_text(encoding="utf-8"))
|
| 34 |
+
for vector in vectors["vectors"]:
|
| 35 |
+
with self.subTest(id=vector["id"]):
|
| 36 |
+
result = self.core.enforce_zero_tolerance_matrix(vector["payload"], parse_instant(vector["at"]))
|
| 37 |
+
self.assertEqual(result["input_payload"], vector["normalized"])
|
| 38 |
+
self.assertEqual(result["seal_date"], vector["utc_date"])
|
| 39 |
+
self.assertEqual(result["matrix_verification_seal"], vector["sha256"])
|
| 40 |
+
|
| 41 |
+
def test_frozen_whitespace_table(self):
|
| 42 |
+
self.assertEqual(len(WHITESPACE_CODEPOINTS), 29)
|
| 43 |
+
for codepoint in WHITESPACE_CODEPOINTS:
|
| 44 |
+
self.assertEqual(normalize_payload(chr(codepoint) + "a" + chr(codepoint) * 3 + "b" + chr(codepoint)), "a b")
|
| 45 |
+
|
| 46 |
+
def test_non_whitespace_characters_are_preserved(self):
|
| 47 |
+
for codepoint in (0, 0x180E, 0x200B, 0x2060, 0xFEFF, 0x1F600):
|
| 48 |
+
payload = chr(codepoint) + "text" + chr(codepoint)
|
| 49 |
+
self.assertEqual(normalize_payload(payload), payload)
|
| 50 |
+
|
| 51 |
+
def test_no_implicit_unicode_normalization(self):
|
| 52 |
+
self.assertNotEqual(checksum_for_date("é", "2126-11-03"), checksum_for_date("e\u0301", "2126-11-03"))
|
| 53 |
+
|
| 54 |
+
def test_normalization_idempotence_for_seeded_unicode_inputs(self):
|
| 55 |
+
rng = random.Random(36500)
|
| 56 |
+
alphabet = ["a", "Č", "😀", "\u200b", "é"] + [chr(c) for c in WHITESPACE_CODEPOINTS]
|
| 57 |
+
for _ in range(100):
|
| 58 |
+
payload = "".join(rng.choice(alphabet) for _ in range(100))
|
| 59 |
+
clean = normalize_payload(payload)
|
| 60 |
+
self.assertEqual(normalize_payload(clean), clean)
|
| 61 |
+
if clean:
|
| 62 |
+
self.assertEqual(checksum_for_date(payload, "2126-11-03"), checksum_for_date(clean, "2126-11-03"))
|
| 63 |
+
|
| 64 |
+
def test_lone_surrogates_have_no_checksum(self):
|
| 65 |
+
for payload in ("\ud800", "x\udfff", "\ud800\udc00"):
|
| 66 |
+
result = self.core.enforce_zero_tolerance_matrix(payload, FIXED)
|
| 67 |
+
self.assertFalse(result["valid"])
|
| 68 |
+
self.assertEqual(result["error"], "INVALID_UNICODE")
|
| 69 |
+
self.assertNotIn("matrix_verification_seal", result)
|
| 70 |
+
|
| 71 |
+
def test_raw_utf8_size_limit_before_normalization(self):
|
| 72 |
+
for payload in (" " * (MAX_PAYLOAD_BYTES + 1), "😀" * (MAX_PAYLOAD_BYTES // 4 + 1)):
|
| 73 |
+
self.assertEqual(self.core.enforce_zero_tolerance_matrix(payload, FIXED)["error"], "PAYLOAD_TOO_LARGE")
|
| 74 |
+
self.assertTrue(self.core.enforce_zero_tolerance_matrix("a" * MAX_PAYLOAD_BYTES, FIXED)["valid"])
|
| 75 |
+
|
| 76 |
+
def test_future_gregorian_calendar_and_leap_rules(self):
|
| 77 |
+
for at in ("2126-11-03T12:00:00Z", "2400-02-29T12:00:00Z", "0001-01-01T00:00:00Z", "9999-12-31T23:59:59Z"):
|
| 78 |
+
self.assertTrue(self.core.enforce_zero_tolerance_matrix("x", parse_instant(at))["valid"])
|
| 79 |
+
for at in ("2100-02-29T12:00:00Z", "2200-02-29T12:00:00Z", "0000-01-01T00:00:00Z", "2026-02-30T12:00:00Z"):
|
| 80 |
+
with self.assertRaises(ValueError):
|
| 81 |
+
parse_instant(at)
|
| 82 |
+
|
| 83 |
+
def test_timestamp_grammar_is_explicit_and_ascii(self):
|
| 84 |
+
for at in ("2026-11-03", "2026-11-03 12:00:00Z", "20261103T120000Z", "2026-11-03T12:00:00-00:00", "2026-11-03T12:00:00+00:60", "2026-11-03T12:00:00+24:00", "2026-11-03T12:00:60Z", "2026-11-03T12:00:00.1234567Z"):
|
| 85 |
+
with self.subTest(at=at), self.assertRaises(ValueError):
|
| 86 |
+
parse_instant(at)
|
| 87 |
+
|
| 88 |
+
def test_utc_overflow_is_controlled(self):
|
| 89 |
+
for at in ("0001-01-01T00:00:00+01:00", "9999-12-31T23:59:59-01:00"):
|
| 90 |
+
with self.assertRaises(ValueError):
|
| 91 |
+
parse_instant(at)
|
| 92 |
+
|
| 93 |
+
def test_valid_export_roundtrips(self):
|
| 94 |
+
result = self.core.enforce_zero_tolerance_matrix("Č 😀", FIXED)
|
| 95 |
+
self.assertTrue(verify_result(strict_loads(json.dumps(result)))["verified"])
|
| 96 |
+
self.assertFalse(verify_result(result)["metadata_authenticated"])
|
| 97 |
+
|
| 98 |
+
def test_legacy_351_export_remains_readable(self):
|
| 99 |
+
legacy = self.core.enforce_zero_tolerance_matrix("Evidence before confidence.", FIXED)
|
| 100 |
+
for key in ("checksum_format", "normalization_profile", "hash_algorithm"):
|
| 101 |
+
legacy.pop(key)
|
| 102 |
+
legacy["engine_version"] = "3.5.1-OMEGA"
|
| 103 |
+
self.assertTrue(verify_result(legacy)["verified"])
|
| 104 |
+
legacy["engine_version"] = "unknown"
|
| 105 |
+
self.assertFalse(verify_result(legacy)["verified"])
|
| 106 |
+
|
| 107 |
+
def test_export_mutations_fail_closed(self):
|
| 108 |
+
result = self.core.enforce_zero_tolerance_matrix("Evidence before confidence.", FIXED)
|
| 109 |
+
mutations = {"valid": 1, "input_payload": "other", "seal_date": "2026-11-04", "timestamp": "2026-11-04T12:00:00Z", "matrix_verification_seal": "0" * 64, "checksum_format": "bodul-matrix-v999", "normalization_profile": "unicode-latest", "hash_algorithm": "md5", "engine_version": ""}
|
| 110 |
+
for key, value in mutations.items():
|
| 111 |
+
item = dict(result)
|
| 112 |
+
item[key] = value
|
| 113 |
+
with self.subTest(key=key):
|
| 114 |
+
self.assertFalse(verify_result(item)["verified"])
|
| 115 |
+
item = dict(result, extra="ignored?")
|
| 116 |
+
self.assertFalse(verify_result(item)["verified"])
|
| 117 |
+
item = dict(result)
|
| 118 |
+
del item["hash_algorithm"]
|
| 119 |
+
self.assertFalse(verify_result(item)["verified"])
|
| 120 |
+
|
| 121 |
+
def test_export_requires_canonical_payload(self):
|
| 122 |
+
result = self.core.enforce_zero_tolerance_matrix("x", FIXED)
|
| 123 |
+
result["input_payload"] = " x "
|
| 124 |
+
self.assertEqual(verify_result(result)["error"], "NON_CANONICAL_PAYLOAD")
|
| 125 |
+
|
| 126 |
+
def test_metadata_is_not_authenticated(self):
|
| 127 |
+
result = self.core.enforce_zero_tolerance_matrix("x", FIXED)
|
| 128 |
+
result["engine_version"] = "different-but-unsealed-metadata"
|
| 129 |
+
result["timestamp"] = "2026-11-03T23:59:59Z"
|
| 130 |
+
self.assertTrue(verify_result(result)["verified"])
|
| 131 |
+
|
| 132 |
+
def test_duplicate_keys_and_nonfinite_numbers_are_rejected(self):
|
| 133 |
+
for text in ('{"a":1,"a":2}', '{"a":{"x":1,"x":2}}', '{"x":NaN}', '{"x":Infinity}', '{"x":1e9999}'):
|
| 134 |
+
with self.subTest(text=text), self.assertRaises(ValueError):
|
| 135 |
+
strict_loads(text)
|
| 136 |
+
|
| 137 |
+
def test_json_limits_and_surrogates(self):
|
| 138 |
+
for text in ('[' * 33 + '0' + ']' * 33, '"\\ud800"', '1' * 129):
|
| 139 |
+
with self.assertRaises(ValueError):
|
| 140 |
+
strict_loads(text)
|
| 141 |
+
self.assertEqual(strict_loads('"\\ud83d\\ude00"'), "😀")
|
| 142 |
+
self.assertEqual(strict_loads('{"text":"[[[{{{"}'), {"text": "[[[{{{"})
|
| 143 |
+
|
| 144 |
+
def test_malformed_public_inputs_never_approve(self):
|
| 145 |
+
for payload in (None, True, [], {}, {"claim": {}}, {"evidence": "wrong"}):
|
| 146 |
+
self.assertEqual(evaluate(payload)["verdict"], "BLOCK")
|
| 147 |
+
self.assertEqual(evaluate_json('{"claim":1,"claim":2}')["reason"], "invalid_json")
|
| 148 |
+
|
| 149 |
+
def test_public_boolean_types_are_not_integers(self):
|
| 150 |
+
payload = example(at=FIXED)
|
| 151 |
+
payload["evidence"][0]["supports"] = 1
|
| 152 |
+
self.assertEqual(evaluate(payload)["verdict"], "BLOCK")
|
| 153 |
+
|
| 154 |
+
def test_public_timestamps_are_frozen_and_utc_normalized(self):
|
| 155 |
+
payload = example(at=FIXED)
|
| 156 |
+
other = copy.deepcopy(payload)
|
| 157 |
+
other["evaluation_time"] = "2026-11-03T14:00:00+02:00"
|
| 158 |
+
other["evidence"][0]["observed_at"] = "2026-11-03T14:00:00+02:00"
|
| 159 |
+
self.assertEqual(evaluate(payload), evaluate(other))
|
| 160 |
+
payload["evaluation_time"] = "2026-11-03 12:00:00Z"
|
| 161 |
+
self.assertEqual(evaluate(payload)["verdict"], "BLOCK")
|
| 162 |
+
|
| 163 |
+
def test_public_approval_flags_cannot_authorize_high_risk_claims(self):
|
| 164 |
+
payload = example(at=FIXED)
|
| 165 |
+
payload["claim"]["risk"] = "high"
|
| 166 |
+
payload["human_approved"] = True
|
| 167 |
+
self.assertEqual(evaluate(payload)["verdict"], "ESCALATE")
|
| 168 |
+
self.assertEqual(evaluate(payload)["reason"], "human_approval_required")
|
| 169 |
+
|
| 170 |
+
def test_missing_stale_future_and_conflicting_evidence_block(self):
|
| 171 |
+
payload = example(at=FIXED)
|
| 172 |
+
self.assertEqual(evaluate(payload)["verdict"], "APPROVE")
|
| 173 |
+
missing = example("unsupported", at=FIXED)
|
| 174 |
+
self.assertEqual(evaluate(missing)["classification"], "UNKNOWN")
|
| 175 |
+
for seconds in (-301, 1):
|
| 176 |
+
item = copy.deepcopy(payload)
|
| 177 |
+
item["evidence"][0]["observed_at"] = (FIXED + timedelta(seconds=seconds)).isoformat()
|
| 178 |
+
self.assertEqual(evaluate(item)["verdict"], "BLOCK")
|
| 179 |
+
contradictory = dict(payload["evidence"][0], source_id="second", supports=False)
|
| 180 |
+
payload["evidence"].append(contradictory)
|
| 181 |
+
self.assertEqual(evaluate(payload)["classification"], "CONFLICT")
|
| 182 |
+
self.assertEqual(evaluate(payload)["verdict"], "BLOCK")
|
| 183 |
+
|
| 184 |
+
def test_source_duplicates_do_not_satisfy_independence_quorum(self):
|
| 185 |
+
payload = example(at=FIXED)
|
| 186 |
+
payload["claim"]["risk"] = "medium"
|
| 187 |
+
payload["evidence"].append(dict(payload["evidence"][0], evidence_id="copy"))
|
| 188 |
+
self.assertEqual(evaluate(payload)["verdict"], "ESCALATE")
|
| 189 |
+
|
| 190 |
+
def test_offline_execution_does_not_use_network(self):
|
| 191 |
+
with patch.object(socket, "socket", side_effect=AssertionError("Network use forbidden")):
|
| 192 |
+
self.assertTrue(self.core.enforce_zero_tolerance_matrix("x", FIXED)["valid"])
|
| 193 |
+
self.assertEqual(evaluate(example(at=FIXED))["verdict"], "APPROVE")
|
| 194 |
+
|
| 195 |
+
def test_explicit_future_evaluation_repeats(self):
|
| 196 |
+
payload = example(at=datetime(2126, 11, 3, tzinfo=timezone.utc))
|
| 197 |
+
expected = evaluate(payload)
|
| 198 |
+
self.assertEqual(expected["verdict"], "APPROVE")
|
| 199 |
+
for _ in range(25):
|
| 200 |
+
self.assertEqual(evaluate(payload), expected)
|
| 201 |
+
|
| 202 |
+
def test_offline_cli_matrix_and_saved_result(self):
|
| 203 |
+
with tempfile.TemporaryDirectory() as directory:
|
| 204 |
+
folder = Path(directory)
|
| 205 |
+
text = folder / "input.txt"
|
| 206 |
+
result_file = folder / "result.json"
|
| 207 |
+
text.write_text("Evidence before confidence.", encoding="utf-8")
|
| 208 |
+
run = subprocess.run([sys.executable, "-B", str(ROOT / "offline_cli.py"), "matrix", str(text), "--at", FIXED.isoformat()], capture_output=True, text=True, encoding="utf-8")
|
| 209 |
+
self.assertEqual(run.returncode, 0, run.stderr)
|
| 210 |
+
result_file.write_text(run.stdout, encoding="utf-8")
|
| 211 |
+
replay = subprocess.run([sys.executable, "-B", str(ROOT / "offline_cli.py"), "verify-matrix", str(result_file)], capture_output=True, text=True, encoding="utf-8")
|
| 212 |
+
self.assertEqual(replay.returncode, 0, replay.stderr)
|
| 213 |
+
self.assertTrue(json.loads(replay.stdout)["verified"])
|
| 214 |
+
|
| 215 |
+
def test_offline_cli_block_has_nonzero_exit(self):
|
| 216 |
+
with tempfile.TemporaryDirectory() as directory:
|
| 217 |
+
path = Path(directory) / "claim.json"
|
| 218 |
+
path.write_text(json.dumps(example("unsupported", at=FIXED)), encoding="utf-8")
|
| 219 |
+
run = subprocess.run([sys.executable, "-B", str(ROOT / "offline_cli.py"), "claims", str(path)], capture_output=True, text=True, encoding="utf-8")
|
| 220 |
+
self.assertEqual(run.returncode, 3)
|
| 221 |
+
self.assertEqual(json.loads(run.stdout)["verdict"], "BLOCK")
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
class ArchiveIntegrityTests(unittest.TestCase):
|
| 225 |
+
def make_archive(self, folder):
|
| 226 |
+
data = b"portable source\n"
|
| 227 |
+
(folder / "source.txt").write_bytes(data)
|
| 228 |
+
manifest = {"manifest_format": "bodul-source-archive-v1", "files": {"source.txt": {"bytes": len(data), "sha256": hashlib.sha256(data).hexdigest(), "sha512": hashlib.sha512(data).hexdigest()}}}
|
| 229 |
+
(folder / "RELEASE_MANIFEST.json").write_text(json.dumps(manifest), encoding="utf-8")
|
| 230 |
+
return manifest
|
| 231 |
+
|
| 232 |
+
def test_clean_archive_and_tampering(self):
|
| 233 |
+
with tempfile.TemporaryDirectory() as directory:
|
| 234 |
+
root = Path(directory)
|
| 235 |
+
self.make_archive(root)
|
| 236 |
+
self.assertTrue(verify_release(root)["verified"])
|
| 237 |
+
(root / "source.txt").write_bytes(b"modified source\n")
|
| 238 |
+
self.assertFalse(verify_release(root)["verified"])
|
| 239 |
+
|
| 240 |
+
def test_missing_and_unlisted_files(self):
|
| 241 |
+
with tempfile.TemporaryDirectory() as directory:
|
| 242 |
+
root = Path(directory)
|
| 243 |
+
self.make_archive(root)
|
| 244 |
+
(root / "extra.txt").write_text("x")
|
| 245 |
+
self.assertFalse(verify_release(root)["verified"])
|
| 246 |
+
(root / "extra.txt").unlink()
|
| 247 |
+
(root / "source.txt").unlink()
|
| 248 |
+
self.assertFalse(verify_release(root)["verified"])
|
| 249 |
+
|
| 250 |
+
def test_unknown_format_and_unsafe_paths(self):
|
| 251 |
+
with tempfile.TemporaryDirectory() as directory:
|
| 252 |
+
root = Path(directory)
|
| 253 |
+
manifest = self.make_archive(root)
|
| 254 |
+
for path in ("../escape.txt", "/absolute.txt", "drive:path", "foo\\bar", "."):
|
| 255 |
+
bad = copy.deepcopy(manifest)
|
| 256 |
+
bad["files"][path] = bad["files"].pop("source.txt")
|
| 257 |
+
(root / "RELEASE_MANIFEST.json").write_text(json.dumps(bad), encoding="utf-8")
|
| 258 |
+
self.assertFalse(verify_release(root)["verified"])
|
| 259 |
+
manifest["manifest_format"] = "future-unknown-format"
|
| 260 |
+
(root / "RELEASE_MANIFEST.json").write_text(json.dumps(manifest), encoding="utf-8")
|
| 261 |
+
self.assertFalse(verify_release(root)["verified"])
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
if __name__ == "__main__":
|
| 265 |
+
unittest.main()
|
verify_release.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Verify the bounded source archive against its manifest; this is not a signature."""
|
| 3 |
+
import hashlib
|
| 4 |
+
import json
|
| 5 |
+
import re
|
| 6 |
+
import sys
|
| 7 |
+
from pathlib import Path, PurePosixPath
|
| 8 |
+
from safe_json import strict_loads
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def verify_release(root):
|
| 12 |
+
root = Path(root).resolve()
|
| 13 |
+
failures = []
|
| 14 |
+
try:
|
| 15 |
+
manifest_file = root / "RELEASE_MANIFEST.json"
|
| 16 |
+
if manifest_file.is_symlink() or manifest_file.stat().st_size > 1_048_576:
|
| 17 |
+
raise ValueError("Unsafe manifest")
|
| 18 |
+
manifest = strict_loads(manifest_file.read_text(encoding="utf-8"))
|
| 19 |
+
if not isinstance(manifest, dict) or manifest.get("manifest_format") != "bodul-source-archive-v1":
|
| 20 |
+
raise ValueError("Unknown manifest format")
|
| 21 |
+
files = manifest.get("files")
|
| 22 |
+
if not isinstance(files, dict) or not 1 <= len(files) <= 500:
|
| 23 |
+
raise ValueError("Invalid file list")
|
| 24 |
+
folded = set()
|
| 25 |
+
for name, entry in files.items():
|
| 26 |
+
relative = PurePosixPath(name)
|
| 27 |
+
if not relative.parts or not re.fullmatch(r"[A-Za-z0-9_.\-/]+", name) or relative.is_absolute() or name != relative.as_posix() or any(part in (".", "..") for part in relative.parts) or name == "RELEASE_MANIFEST.json":
|
| 28 |
+
raise ValueError("Unsafe archive path")
|
| 29 |
+
if name.casefold() in folded:
|
| 30 |
+
raise ValueError("Case-insensitive archive path collision")
|
| 31 |
+
folded.add(name.casefold())
|
| 32 |
+
if not isinstance(entry, dict) or set(entry) != {"bytes", "sha256", "sha512"}:
|
| 33 |
+
raise ValueError("Invalid file entry")
|
| 34 |
+
if type(entry["bytes"]) is not int or not 0 <= entry["bytes"] <= 2_000_000:
|
| 35 |
+
raise ValueError("Invalid file size")
|
| 36 |
+
for algorithm, length in (("sha256", 64), ("sha512", 128)):
|
| 37 |
+
if not isinstance(entry[algorithm], str) or not re.fullmatch("[0-9a-f]{" + str(length) + "}", entry[algorithm]):
|
| 38 |
+
raise ValueError("Invalid digest")
|
| 39 |
+
path = root.joinpath(*relative.parts)
|
| 40 |
+
if not path.is_relative_to(root) or any(parent.is_symlink() for parent in (path, *path.parents) if parent != root and parent.is_relative_to(root)):
|
| 41 |
+
failures.append(name + ": unsafe symlink")
|
| 42 |
+
continue
|
| 43 |
+
if not path.is_file() or path.stat().st_size != entry["bytes"]:
|
| 44 |
+
failures.append(name + ": missing file or changed length")
|
| 45 |
+
continue
|
| 46 |
+
data = path.read_bytes()
|
| 47 |
+
if any(hashlib.new(a, data).hexdigest() != entry[a] for a in ("sha256", "sha512")):
|
| 48 |
+
failures.append(name + ": digest mismatch")
|
| 49 |
+
# Ignore development machinery; reject extra source/data files.
|
| 50 |
+
for path in root.rglob("*"):
|
| 51 |
+
rel = path.relative_to(root)
|
| 52 |
+
if any(part in (".git", ".venv", "__pycache__") for part in rel.parts):
|
| 53 |
+
continue
|
| 54 |
+
if path.is_symlink():
|
| 55 |
+
failures.append(rel.as_posix() + ": unexpected symlink")
|
| 56 |
+
elif path.is_file() and rel.as_posix() not in files and rel.as_posix() != "RELEASE_MANIFEST.json":
|
| 57 |
+
failures.append(rel.as_posix() + ": unlisted file")
|
| 58 |
+
return {"verified": not failures, "files_checked": len(files), "scope": "archive_byte_integrity", "authenticated": False, "failures": failures}
|
| 59 |
+
except (ValueError, TypeError, OSError, KeyError, UnicodeError) as exc:
|
| 60 |
+
return {"verified": False, "scope": "archive_byte_integrity", "authenticated": False, "failures": [str(exc)]}
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
if __name__ == "__main__":
|
| 64 |
+
result = verify_release(Path(__file__).resolve().parent)
|
| 65 |
+
print(json.dumps(result, sort_keys=True))
|
| 66 |
+
sys.exit(0 if result["verified"] else 2)
|