mirdax25-ux commited on
Commit
ecad52f
·
verified ·
1 Parent(s): c08b4a5

Release portable deterministic core 1.1.0: frozen formats, offline recovery and regression checks

Browse files
.gitattributes CHANGED
@@ -1,6 +1,8 @@
1
- * text=auto eol=lf
2
- *.safetensors filter=lfs diff=lfs merge=lfs -text
3
- *.gguf filter=lfs diff=lfs merge=lfs -text
4
- *.bin filter=lfs diff=lfs merge=lfs -text
5
- *.pt filter=lfs diff=lfs merge=lfs -text
6
- *.onnx filter=lfs diff=lfs merge=lfs -text
 
 
 
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
- *.log
6
- *.tmp
7
- *.bak
8
- .venv/
9
- venv/
10
- .cache/
11
- tokens/
12
- credentials*
13
- secrets*
14
- *.pem
15
- *.key
 
 
 
 
 
 
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
- tags: [deterministic-ai, hal, system-architecture, agentic, mcp]
4
- ---
5
- # Deterministic Engine
6
-
7
- ## Executive Summary
8
-
9
- Deterministic Engine provides an explicit deterministic control boundary for agent claims. Structured evidence, policy and evaluation time are evaluated without probabilistic text generation. Unsupported claims are blocked; insufficient authority can require escalation. The calling application must enforce the decision.
10
-
11
- This is an executable Python system, **not a pretrained language model**. No model weights are required or included. Deterministic control eliminates stochastic generation inside the verifier; it does not establish the truth of untrusted input or eliminate every hallucination in an upstream LLM.
12
-
13
- ## Architecture Overview
14
-
15
- ```text
16
- Structured claim + supplied evidence + policy + evaluation time
17
- |
18
- v
19
- Schema and type validation
20
- |
21
- v
22
- Freshness / authority / contradiction gates
23
- |
24
- v
25
- Deterministic classification and policy decision
26
- |
27
- v
28
- Result + reason + SHA-256 receipt
29
- |
30
- v
31
- Calling application enforces verdict
32
- ```
33
-
34
- ## Deterministic Execution Proof
35
-
36
- For fixed inputs, policy, evaluation time and runtime provenance, the local benchmark produced **1,000 identical serialized results in 1,000 executions**. The reproducible check covers that synthetic fixture, not an unrestricted correctness guarantee. Algorithm version: **2.1.2**; JSON contract: **v3**.
37
-
38
- The zero-hallucination objective is implemented as a fail-closed claim boundary. **0% real-world hallucinations is not a measured or guaranteed metric.** Fabricated evidence, incorrect adapters and callers that ignore a verdict remain outside that guarantee.
39
-
40
- ## Quickstart Code Snippets
41
-
42
- After cloning or downloading the repository, use Python 3.12 or later:
43
-
44
- ```bash
45
- python run_example.py
46
- ```
47
-
48
- ```python
49
- import json
50
- from demo import evaluate, example
51
-
52
- payload = example("supported") # Explicitly synthetic demo evidence
53
- result = evaluate(payload)
54
- print(json.dumps(result, indent=2))
55
-
56
- payload["evidence"] = []
57
- blocked = evaluate(payload)
58
- assert blocked["classification"] == "UNKNOWN"
59
- assert blocked["verdict"] == "BLOCK"
60
- ```
61
-
62
- For production integration, replace the synthetic sample with actual structured evidence and its original timestamp. See [JSON_CONTRACT.md](JSON_CONTRACT.md). This public wrapper ignores caller-supplied human-approval flags; high-risk claims still require review.
63
-
64
- ## Benchmark & Reliability Specifications
65
-
66
- | Property | Local observation / contract |
67
- | --- | --- |
68
- | Execution | CPU, standard-library verification kernel; no inference service |
69
- | Fixture | One synthetic claim with fixed input and evaluation time |
70
- | Repetitions | 1000 |
71
- | Identical serialized results | 1000 |
72
- | Kernel latency p50 | 0.1524 ms |
73
- | Kernel latency p95 | 0.2647 ms |
74
- | Runtime | Python 3.12.14, AMD64 |
75
- | Exclusions | HTTP, UI, cold starts, workload scaling and external truth accuracy |
76
- | Decisions | APPROVE, BLOCK, ESCALATE |
77
- | Integrity | SHA-256 receipt; does not authenticate external sources |
78
-
79
- These measurements characterize one local execution environment. They are not a hosted SLA or evidence of enterprise certification. Zero-trust handling means missing, stale, unauthorized and contradictory evidence must not be silently upgraded to fact. The verifier does not fetch URLs or authenticate evidence provenance by itself.
80
-
81
- ## System Integration
82
-
83
- - [Free interactive Gradio-Lite Space](https://huggingface.co/spaces/mirdax25-ux/bodulski-hal-playground) — Python runs in the browser; no server-side MCP endpoint.
84
- - [Bodulski HAL repository](https://huggingface.co/mirdax25-ux/Bodul)
85
- - [Deterministic engine repository](https://huggingface.co/mirdax25-ux/deterministic-engine)
86
- - [Authenticated MCP Actor on Apify](https://apify.com/bodul/bodulskihal-claim-verifier)
87
-
88
- The free Static Space runs Gradio-Lite and Python in the browser. It does not expose a remote MCP server. Use the separately authenticated Apify Actor for server-side MCP integration. Do not submit secrets or personal data to the public demo. The application does not explicitly persist claim payloads; hosting retention and logs require separate review.
89
-
90
- ## Author and License
91
-
92
- [GitHub](https://github.com/mirdax25-ux) · [X](https://x.com/ddddddd97424402) · [LinkedIn](https://www.linkedin.com/in/damir-balenovic-594015429/)
93
-
94
- Apache License 2.0. This release does not provide warranties, source-authentication services or a guarantee of real-world truth.
95
-
96
- <!-- MATRIX_CORE:START -->
97
- ## Matrix Core: deterministic text checksums
98
-
99
- `matrix_core.py` is a separate, standard-library-only module by Damir Balenović (mirdax25-ux), version **3.5.1-OMEGA**. It normalizes whitespace, rejects empty/non-string input, and computes SHA-256 over the normalized text and **UTC date**. It does not replace the evidence verifier.
100
-
101
- ```python
102
- from datetime import datetime, timezone
103
- from matrix_core import DeterministicMatrixCore
104
-
105
- core = DeterministicMatrixCore()
106
- result = core.enforce_zero_tolerance_matrix(
107
- " Evidence\tbefore\nconfidence. ",
108
- now=datetime(2026, 11, 3, 12, tzinfo=timezone.utc),
109
- )
110
- assert result["valid"] is True
111
- assert result["matrix_verification_seal"] == "36e7be60a50a6d846b8b162c199a5e534c612ba171163d9c3406e2f2b12c0651"
112
- ```
113
-
114
- ```bash
115
- python matrix_core.py "Evidence before confidence." --at "2026-11-03T12:00:00+00:00"
116
- python -m unittest discover -s tests -p test_matrix_core.py -q
117
- ```
118
-
119
- The checksum is repeatable for identical normalized text and UTC date. The **entire response** is repeatable only with the same instant and engine version, because the response includes a full timestamp. Supplied aware timestamps are converted to UTC before selecting the date; naive datetimes are rejected. With no timestamp, one UTC instant is captured for the whole response.
120
-
121
- `valid: true` means non-empty text, **not verified truth or authorization**. The checksum is not a keyed signature: anyone who knows the text and date can recompute it. The engine version and full timestamp are metadata outside the checksum input. Text that resembles a shell command is treated as data and is never executed.
122
-
123
- Empty input returns `valid: false`, `EMPTY_OR_INVALID_PAYLOAD` and **no checksum**. The CLI returns exit code 2 for invalid input or a timestamp without a timezone; missing CLI input no longer substitutes a deployment-like example string. Importing the module does not configure the root logger.
124
-
125
- [Try the free Matrix Core browser panel](https://huggingface.co/spaces/mirdax25-ux/bodulski-hal-playground).
126
- <!-- MATRIX_CORE:END -->
 
 
 
 
 
 
 
 
 
 
 
 
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 demo boundary. Caller-supplied approvals are never authenticated."""
2
  from datetime import datetime, timezone
3
- from typing import Any
4
  from bodulskihal_core import verify_payload, verify_compound_payload
 
 
5
 
6
- def evaluate(payload: dict[str, Any], batch: bool = False) -> dict[str, Any]:
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  try:
8
- if not isinstance(payload, dict):
9
- raise TypeError('payload must be an object')
10
- clean = dict(payload)
 
 
11
  if batch:
12
- claims = clean.get('claims')
13
  if not isinstance(claims, list) or not 1 <= len(claims) <= 100:
14
- raise ValueError('Provide between 1 and 100 claims')
15
- clean['human_approvals'] = {}
 
 
 
 
 
 
16
  return verify_compound_payload(clean)
17
- clean['human_approved'] = False
 
 
 
 
18
  return verify_payload(clean)
19
- except (ValueError, TypeError, KeyError) as exc:
20
- return {'classification': 'UNKNOWN', 'verdict': 'BLOCK', 'reason': 'invalid_input', 'error': str(exc)}
 
 
 
 
 
 
 
 
21
 
22
- def example(scenario: str = 'supported') -> dict[str, Any]:
23
- now = datetime.now(timezone.utc).isoformat()
 
 
 
 
24
  payload = {
25
- 'claim': {'id': 'synthetic-count', 'text': 'This synthetic record contains 30 questions', 'type': 'observation', 'risk': 'low'},
26
- 'evidence': [{'source_id': 'synthetic-demo', 'supports': True, 'observed_at': now, 'directness': 'direct', 'origin': 'computed_result'}],
27
- 'policy': {'version': '3.0', 'max_age_seconds': 300},
28
- 'evaluation_time': now,
29
  }
30
- if scenario == 'unsupported':
31
- payload['evidence'] = []
32
- elif scenario == 'contradiction':
33
- payload['evidence'][0]['supports'] = False
 
 
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
- # -*- coding: utf-8 -*-
3
- """BODULAI MATRIX CORE — deterministic text checksums (v3.5.1).
4
-
5
- Author: Damir Balenović (mirdax25-ux).
6
-
7
- Normalizes whitespace, rejects empty/non-string input, and calculates SHA-256
8
- over the normalized payload and UTC date. This checksum is not a signature,
9
- proof of truth, authorization decision, or deployment action. Anyone who knows
10
- the payload and date can calculate it. The engine version and full timestamp
11
- are response metadata, not part of the checksum format.
12
- """
13
-
14
- import hashlib
15
- import json
16
- import logging
17
- import sys
18
- from datetime import datetime, timezone
19
- from typing import Any, Dict, Optional
20
-
21
- ENGINE_VERSION = "3.5.1-OMEGA"
22
- logger = logging.getLogger("BodulMatrixCore")
23
- logger.addHandler(logging.NullHandler())
24
-
25
-
26
- class DeterministicMatrixCore:
27
- def __init__(self, engine_version: str = ENGINE_VERSION):
28
- self.engine_version = engine_version
29
- logger.info("Initializing Matrix Core %s.", self.engine_version)
30
-
31
- @staticmethod
32
- def _sanitize_payload(payload: Any) -> Optional[str]:
33
- if not isinstance(payload, str):
34
- return None
35
- clean = " ".join(payload.split())
36
- return clean or None
37
-
38
- @staticmethod
39
- def _utc_now(now: Optional[datetime]) -> datetime:
40
- if now is None:
41
- return datetime.now(timezone.utc)
42
- if not isinstance(now, datetime):
43
- raise TypeError("now must be a timezone-aware datetime or None")
44
- if now.tzinfo is None or now.utcoffset() is None:
45
- raise ValueError("now must include a UTC offset or timezone")
46
- return now.astimezone(timezone.utc)
47
-
48
- def enforce_zero_tolerance_matrix(
49
- self, payload: Any, now: Optional[datetime] = None
50
- ) -> Dict[str, Any]:
51
- """Return a normalized payload and UTC-date checksum, or reject input.
52
-
53
- The checksum repeats for identical normalized text and UTC date.
54
- The complete result repeats only if the time and engine version also
55
- match. A supplied naive datetime is rejected rather than interpreted
56
- using the machine's local timezone.
57
- """
58
- instant = self._utc_now(now)
59
- clean = self._sanitize_payload(payload)
60
- if clean is None:
61
- logger.warning("Empty or invalid input; no checksum issued.")
62
- return {
63
- "valid": False,
64
- "error": "EMPTY_OR_INVALID_PAYLOAD",
65
- "timestamp": instant.isoformat(),
66
- "engine_version": self.engine_version,
67
- }
68
-
69
- seal_date = instant.date().isoformat()
70
- seal_raw = f"BODUL_MATRIX::{clean}::{seal_date}::OMEGA_FLOW"
71
- seal = hashlib.sha256(seal_raw.encode("utf-8")).hexdigest()
72
- logger.info("Checksum calculated: %s...", seal[:16])
73
- return {
74
- "valid": True,
75
- "input_payload": clean,
76
- "seal_date": seal_date,
77
- "matrix_verification_seal": seal,
78
- "timestamp": instant.isoformat(),
79
- "engine_version": self.engine_version,
80
- }
81
-
82
-
83
- def main(argv=None) -> int:
84
- """JSON-only CLI; missing/invalid input returns exit code 2."""
85
- import argparse
86
-
87
- for stream in (sys.stdout, sys.stderr):
88
- try:
89
- stream.reconfigure(encoding="utf-8")
90
- except (AttributeError, ValueError):
91
- pass
92
-
93
- parser = argparse.ArgumentParser(description=__doc__)
94
- parser.add_argument("payload", nargs="*", help="Text to normalize and checksum")
95
- parser.add_argument("--at", help="ISO 8601 timestamp with an explicit UTC offset")
96
- parser.add_argument("--verbose", action="store_true")
97
- args = parser.parse_args(argv)
98
- if args.verbose:
99
- logging.basicConfig(level=logging.INFO, stream=sys.stderr)
100
-
101
- try:
102
- instant = datetime.fromisoformat(args.at) if args.at else None
103
- result = DeterministicMatrixCore().enforce_zero_tolerance_matrix(
104
- " ".join(args.payload), now=instant
105
- )
106
- except (TypeError, ValueError) as exc:
107
- print(json.dumps({"valid": False, "error": "INVALID_TIMESTAMP", "detail": str(exc)}, ensure_ascii=False))
108
- return 2
109
- print(json.dumps(result, indent=2, ensure_ascii=False))
110
- return 0 if result["valid"] else 2
111
-
112
-
113
- if __name__ == "__main__":
114
- sys.exit(main())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- # Deterministic engine: Python 3.12+, standard library only.
 
 
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)