tekmerion 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. tekmerion-0.1.0/LICENSE +5 -0
  2. tekmerion-0.1.0/PKG-INFO +12 -0
  3. tekmerion-0.1.0/README.md +73 -0
  4. tekmerion-0.1.0/pyproject.toml +30 -0
  5. tekmerion-0.1.0/setup.cfg +4 -0
  6. tekmerion-0.1.0/src/tekmerion/__init__.py +10 -0
  7. tekmerion-0.1.0/src/tekmerion/adapters/__init__.py +12 -0
  8. tekmerion-0.1.0/src/tekmerion/adapters/checkcif.py +338 -0
  9. tekmerion-0.1.0/src/tekmerion/adapters/codecheck.py +418 -0
  10. tekmerion-0.1.0/src/tekmerion/adapters/core.py +151 -0
  11. tekmerion-0.1.0/src/tekmerion/adapters/statconsistency.py +570 -0
  12. tekmerion-0.1.0/src/tekmerion/claims.py +172 -0
  13. tekmerion-0.1.0/src/tekmerion/connectors.py +510 -0
  14. tekmerion-0.1.0/src/tekmerion/deposit.py +320 -0
  15. tekmerion-0.1.0/src/tekmerion/export.py +1145 -0
  16. tekmerion-0.1.0/src/tekmerion/freeze.py +134 -0
  17. tekmerion-0.1.0/src/tekmerion/marking.py +61 -0
  18. tekmerion-0.1.0/src/tekmerion/mathclaims.py +745 -0
  19. tekmerion-0.1.0/src/tekmerion/mcp/__init__.py +12 -0
  20. tekmerion-0.1.0/src/tekmerion/mcp/__main__.py +6 -0
  21. tekmerion-0.1.0/src/tekmerion/mcp/server.py +891 -0
  22. tekmerion-0.1.0/src/tekmerion/osf_deposit.py +773 -0
  23. tekmerion-0.1.0/src/tekmerion/platform/__init__.py +9 -0
  24. tekmerion-0.1.0/src/tekmerion/platform/authz.py +146 -0
  25. tekmerion-0.1.0/src/tekmerion/platform/baseline.py +148 -0
  26. tekmerion-0.1.0/src/tekmerion/platform/breakglass.py +94 -0
  27. tekmerion-0.1.0/src/tekmerion/platform/compliance.py +421 -0
  28. tekmerion-0.1.0/src/tekmerion/platform/directory.py +358 -0
  29. tekmerion-0.1.0/src/tekmerion/platform/ed25519.py +153 -0
  30. tekmerion-0.1.0/src/tekmerion/platform/errors.py +46 -0
  31. tekmerion-0.1.0/src/tekmerion/platform/exports.py +356 -0
  32. tekmerion-0.1.0/src/tekmerion/platform/gate.py +115 -0
  33. tekmerion-0.1.0/src/tekmerion/platform/keys.py +266 -0
  34. tekmerion-0.1.0/src/tekmerion/platform/ledger.py +206 -0
  35. tekmerion-0.1.0/src/tekmerion/platform/matrix_data.py +121 -0
  36. tekmerion-0.1.0/src/tekmerion/platform/oidc.py +210 -0
  37. tekmerion-0.1.0/src/tekmerion/platform/packaging.py +93 -0
  38. tekmerion-0.1.0/src/tekmerion/platform/poam.py +207 -0
  39. tekmerion-0.1.0/src/tekmerion/platform/saml.py +512 -0
  40. tekmerion-0.1.0/src/tekmerion/platform/scim.py +490 -0
  41. tekmerion-0.1.0/src/tekmerion/platform/service.py +3164 -0
  42. tekmerion-0.1.0/src/tekmerion/platform/webhooks.py +302 -0
  43. tekmerion-0.1.0/src/tekmerion/platform/x509.py +140 -0
  44. tekmerion-0.1.0/src/tekmerion/platform/xmlc14n.py +153 -0
  45. tekmerion-0.1.0/src/tekmerion/record.py +191 -0
  46. tekmerion-0.1.0/src/tekmerion/store.py +113 -0
  47. tekmerion-0.1.0/src/tekmerion/ui/__init__.py +18 -0
  48. tekmerion-0.1.0/src/tekmerion/ui/__main__.py +29 -0
  49. tekmerion-0.1.0/src/tekmerion/ui/server.py +830 -0
  50. tekmerion-0.1.0/src/tekmerion/ui/state.py +626 -0
  51. tekmerion-0.1.0/src/tekmerion/ui/style.css +202 -0
  52. tekmerion-0.1.0/src/tekmerion/ui/views.py +1337 -0
  53. tekmerion-0.1.0/src/tekmerion/verdicts.py +355 -0
  54. tekmerion-0.1.0/src/tekmerion/verification.py +634 -0
  55. tekmerion-0.1.0/src/tekmerion.egg-info/PKG-INFO +12 -0
  56. tekmerion-0.1.0/src/tekmerion.egg-info/SOURCES.txt +85 -0
  57. tekmerion-0.1.0/src/tekmerion.egg-info/dependency_links.txt +1 -0
  58. tekmerion-0.1.0/src/tekmerion.egg-info/entry_points.txt +2 -0
  59. tekmerion-0.1.0/src/tekmerion.egg-info/requires.txt +6 -0
  60. tekmerion-0.1.0/src/tekmerion.egg-info/top_level.txt +1 -0
  61. tekmerion-0.1.0/tests/test_adapters_checkcif.py +263 -0
  62. tekmerion-0.1.0/tests/test_adapters_codecheck.py +236 -0
  63. tekmerion-0.1.0/tests/test_adapters_statcheck_grim.py +317 -0
  64. tekmerion-0.1.0/tests/test_connectors.py +121 -0
  65. tekmerion-0.1.0/tests/test_core_connector.py +433 -0
  66. tekmerion-0.1.0/tests/test_e1_ed25519.py +41 -0
  67. tekmerion-0.1.0/tests/test_e1_identity.py +447 -0
  68. tekmerion-0.1.0/tests/test_e1_keys_gate.py +313 -0
  69. tekmerion-0.1.0/tests/test_e2_saml.py +486 -0
  70. tekmerion-0.1.0/tests/test_e3_scim.py +966 -0
  71. tekmerion-0.1.0/tests/test_e4_breakglass.py +309 -0
  72. tekmerion-0.1.0/tests/test_e4_tenancy.py +543 -0
  73. tekmerion-0.1.0/tests/test_e4_webhooks.py +377 -0
  74. tekmerion-0.1.0/tests/test_e6_compliance.py +564 -0
  75. tekmerion-0.1.0/tests/test_e6_packaging.py +140 -0
  76. tekmerion-0.1.0/tests/test_mathclaims.py +386 -0
  77. tekmerion-0.1.0/tests/test_mcp_server.py +498 -0
  78. tekmerion-0.1.0/tests/test_osf_deposit.py +573 -0
  79. tekmerion-0.1.0/tests/test_phase3.py +165 -0
  80. tekmerion-0.1.0/tests/test_phase4.py +337 -0
  81. tekmerion-0.1.0/tests/test_phase5.py +418 -0
  82. tekmerion-0.1.0/tests/test_phase6_ui.py +483 -0
  83. tekmerion-0.1.0/tests/test_phase7_prep.py +348 -0
  84. tekmerion-0.1.0/tests/test_record.py +105 -0
  85. tekmerion-0.1.0/tests/test_spec011.py +656 -0
  86. tekmerion-0.1.0/tests/test_store_axiomere.py +33 -0
  87. tekmerion-0.1.0/tests/test_workspace_run_wall.py +125 -0
@@ -0,0 +1,5 @@
1
+ Copyright © 2026 Axiovex Systems, LLC. All rights reserved.
2
+
3
+ No part of this repository may be reproduced, distributed, or transmitted in
4
+ any form or by any means without the prior written permission of
5
+ Axiovex Systems, LLC, except as permitted by applicable law.
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.4
2
+ Name: tekmerion
3
+ Version: 0.1.0
4
+ Summary: Tekmerion — defensible AI research: append-only, hash-chained research records with per-claim verdicts.
5
+ License: All rights reserved. (c) 2026 Axiovex Systems, LLC
6
+ Requires-Python: >=3.12
7
+ License-File: LICENSE
8
+ Provides-Extra: dev
9
+ Requires-Dist: pytest>=8; extra == "dev"
10
+ Provides-Extra: mcp
11
+ Requires-Dist: mcp<3,>=2; extra == "mcp"
12
+ Dynamic: license-file
@@ -0,0 +1,73 @@
1
+ <h1 align="center">Tekmerion</h1>
2
+
3
+ <p align="center">
4
+ <strong>Defensible AI research — built for universities that have to stand behind the result.</strong>
5
+ </p>
6
+
7
+ <p align="center">
8
+ 🌐 <a href="https://axiovexsystems.com"><strong>axiovexsystems.com</strong></a>
9
+ &nbsp;·&nbsp; Theory sibling: <a href="https://github.com/AXIOVEX/epistemic-ledger">AXIOVEX/epistemic-ledger</a>
10
+ &nbsp;·&nbsp; Record layer: <a href="https://github.com/AXIOVEX/axiomere">AXIOVEX/axiomere</a>
11
+ </p>
12
+
13
+ **Tekmerion** (τεκμήριον — *conclusive evidence by which a thing is proved*; renamed from the Axiovex Research Engine, 2026-10-11, DN-006) is Axiovex's defensible-research framework. Its records live in [Axiomere](https://github.com/AXIOVEX/axiomere), a truth ledger where records are revised by new snapshots and never deleted.
14
+
15
+ Most AI research tools sell speed and summaries. This one sells something different: **an audit trail a university can rely on.** Every claim the engine makes carries the evidence it rests on and the test that would falsify it. Verdicts are registered before outcomes are seen. Records are append-only — a correction is a new entry, never a quiet rewrite of the old one. When the engine cannot support a claim, it says so, in a verdict designed for exactly that: *inconclusive* is an answer, not a failure.
16
+
17
+ ## The problem
18
+
19
+ AI-assisted research is fast, fluent — and hard to defend. Citations are generated as easily as they are retrieved, and some are fabricated outright. Methods shift mid-run without a record. A summary arrives with no way to ask *what does this rest on, what would change it, and who checked?* For a student paper that is an inconvenience. For a research integrity office, a systematic-review team, or a lab whose work will be replicated, challenged, or published, it is disqualifying.
20
+
21
+ The missing piece is not a better summary. It is a research process whose every step leaves evidence.
22
+
23
+ ## What it will do
24
+
25
+ - **Register the question before answering it.** Research questions, inclusion criteria, and verdict rules are frozen and timestamped before evidence is gathered — the same discipline as a pre-registered study.
26
+ - **Retrieve from scholarly infrastructure, with provenance.** Sources are real scholarly systems (OpenAlex, Semantic Scholar, Crossref, CORE, and peers — see the [source registry](docs/architecture/01-source-layer.md)), every query is logged, and every retrieved item carries its origin. Nothing enters the record without a source that can be re-checked.
27
+ - **Build a claim register, not a prose answer.** Findings are decomposed into claims with stable IDs, each carrying its evidence, its dependencies, and an explicit **falsifier** — the observation that would overturn it.
28
+ - **Count evidence honestly.** Derivative sources that repeat one origin are counted once, by origin group. Ten echoes of a single study are one piece of evidence, not ten.
29
+ - **Render verdicts that can fail.** Each claim resolves to a verdict from a fixed taxonomy — **Supported, Contradicted, Inconclusive,** or **Invalid** (the run itself was defective). Controls are designed so they *can* fail: a check that cannot differentially fail marks the run invalid rather than passing it.
30
+ - **Keep the record append-only and hash-verified.** The full history of what was believed, when, and why stays queryable. Exports are dossiers and audit bundles a university can keep, deposit (OSF, Zenodo), and hand to a reviewer or an integrity board.
31
+
32
+ ## How it works
33
+
34
+ 1. **Frame.** State the question, the claims it implies, and what would falsify each one.
35
+ 2. **Freeze.** Register the design — sources, criteria, verdict rules — before outcomes are seen.
36
+ 3. **Gather.** Retrieve from scholarly sources; log every query; deduplicate by DOI and identity; attach provenance to every item.
37
+ 4. **Weigh.** Group evidence by origin, test it against the falsifiers, run the controls.
38
+ 5. **Decide.** Issue verdicts claim by claim, with intervals and margins — never a single blended score that hides a weak link.
39
+ 6. **Deposit.** Export the registration, the claim register, and the audit bundle. The record stands on its own, without the engine.
40
+
41
+ The method is not new decoration. It is the epistemic engineering framework Axiovex has developed and measured in the open — claim registers with falsifiers, origin-group evidence counting, discriminating controls, freeze-and-replay integrity, and an append-only ledger whose scoring machinery is published, with its negative results, in the theory sibling repository, [AXIOVEX/epistemic-ledger](https://github.com/AXIOVEX/epistemic-ledger). This repository is the **tool** built on that theory.
42
+
43
+ ## Who it is for
44
+
45
+ - **Research integrity offices** that must inspect how an AI-assisted conclusion was reached.
46
+ - **Systematic-review and meta-analysis teams** that need reproducible searches and auditable screening.
47
+ - **Graduate research programs** teaching — and enforcing — defensible AI use.
48
+ - **Libraries and labs** that need research records they can keep, deposit, and re-run.
49
+
50
+ ## Status
51
+
52
+ **Implemented through Spec 002 Phase 5 (October 2026); lifecycle stages per DN-010.** The MVP engine (Specs 001–002) is built and CI-verified: study store on the Axiomere record layer, live Crossref/OpenAlex connectors, claims/freeze/verdicts, the citation pipeline, and the audit bundle with a standalone verifier and Zenodo anchoring — demonstrated live in Phase 5 (gate G2 ratified by the owner, DN-008; the demonstration run itself was INVALID and is recorded as such). Spec 003 (math-claims layer, Tao realignment per DN-010) is implemented; its worked study is in progress. Phase 6 (UI) is claims-first only: its wireframe package awaits owner approval (gate T219-WF). Under the DN-010 stage model, the engineering claims stand at **checked** (one gate demonstration **accepted**); none is presented as explained or digested. The founding decisions are recorded in [Design Note 001](docs/design-notes/DN-001-founding-decisions.md), the architecture in [`docs/architecture/`](docs/architecture/), and the governing specifications in [`specs/`](specs/) under the project [constitution](.specify/memory/constitution.md). A status in this repository is a claim, and claims here carry evidence. (This section previously read "Design phase… No implementation yet" — stale since Phase 0/1; corrected 2026-10-12 by the Tao realignment audit, docs/audits/2026-10-12-tao-realignment-audit.md.)
53
+
54
+ ## Repository layout
55
+
56
+ ```
57
+ .
58
+ ├── README.md # This page
59
+ ├── LICENSE # All rights reserved, © 2026 Axiovex Systems, LLC
60
+ ├── .specify/memory/ # Project constitution
61
+ ├── specs/001-research-engine/ # Spec, plan, tasks, AEE claims
62
+ └── docs/
63
+ ├── architecture/ # The six architecture workstreams
64
+ └── design-notes/ # Numbered, append-only design decisions
65
+ ```
66
+
67
+ ## License
68
+
69
+ All rights reserved. © 2026 Axiovex Systems, LLC. See [LICENSE](LICENSE).
70
+
71
+ <p align="center">
72
+ <strong>Claims with falsifiers. Verdicts with receipts.</strong>
73
+ </p>
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "tekmerion"
7
+ version = "0.1.0"
8
+ description = "Tekmerion — defensible AI research: append-only, hash-chained research records with per-claim verdicts."
9
+ requires-python = ">=3.12"
10
+ license = { text = "All rights reserved. (c) 2026 Axiovex Systems, LLC" }
11
+ dependencies = []
12
+
13
+ [project.optional-dependencies]
14
+ dev = ["pytest>=8"]
15
+ mcp = ["mcp>=2,<3"]
16
+
17
+ [project.scripts]
18
+ tekmerion-mcp = "tekmerion.mcp.server:main"
19
+
20
+ [tool.setuptools.packages.find]
21
+ where = ["src"]
22
+
23
+ # tekmerion.ui.server reads style.css at import time; without this
24
+ # the wheel ships a package that cannot be imported (found by the
25
+ # Spec 009 clean-room acceptance, 2026-10-12).
26
+ [tool.setuptools.package-data]
27
+ "tekmerion.ui" = ["style.css"]
28
+
29
+ [tool.pytest.ini_options]
30
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,10 @@
1
+ """Tekmerion — record service core (Spec 002, Phase 1).
2
+
3
+ The record is an append-only, hash-chained JSONL event log per study.
4
+ Everything else in the system is a projection or a service around it.
5
+ """
6
+
7
+ from .record import EventStore, VerificationResult, verify_log
8
+
9
+ __all__ = ["EventStore", "VerificationResult", "verify_log"]
10
+ __version__ = "0.1.0"
@@ -0,0 +1,12 @@
1
+ """Domain evidence adapters (Specs 006–008; survey shortlist, owner
2
+ gate passed 2026-10-12).
3
+
4
+ An adapter = (artifact type, independent checker, registered scope
5
+ claim stating what the check does NOT establish) — DN-010, Spec 004 §2.
6
+ Adapters supply lane-1 evidence only: they never issue verdicts, and
7
+ an adapter result can never settle a claim alone. Every adapter output
8
+ carries the artifact hash, checker identity + version, the invocation
9
+ honesty state, the raw-output hash, and the id of the adapter's
10
+ registered blind-spot scope claim (CLM-006-02 / CLM-007-02 /
11
+ CLM-008-02). The shared seam lives in `tekmerion.adapters.core`.
12
+ """
@@ -0,0 +1,338 @@
1
+ """checkCIF adapter (Spec 006; pattern P2 — automated validation suite).
2
+
3
+ Artifact = a CIF (+ optional structure-factor file). Independent
4
+ checker = PLATON's check.def suite, as run by a local PLATON binary or
5
+ by the IUCr checkCIF service. Registered scope claim = CLM-006-02: a
6
+ clean checkCIF result establishes only internal consistency under the
7
+ check.def suite — never chemical correctness, never genuineness (IUCr
8
+ states the absence-of-alerts caveat in every report it issues).
9
+
10
+ Invocation reality, recorded plainly: PLATON is author-distributed
11
+ (© A. L. Spek) and **not OSI-licensed**, and the IUCr checkCIF web
12
+ service is a *submission* service — automating submissions is an
13
+ operator action, not something this adapter does. So the adapter has
14
+ three honest modes:
15
+
16
+ - a local PLATON binary is present → run `platon -u` ourselves
17
+ (`run_by_us`);
18
+ - a recorded checkCIF/PLATON output is supplied (an operator ran the
19
+ service or PLATON and deposited the output) → parse the recorded
20
+ bytes (`recorded_output`), hash-pinned like any other artifact;
21
+ - neither → `not_runnable_here`, ledgered, no evidence.
22
+
23
+ The alert parser is validated against recorded real outputs (IUCr
24
+ service HTML reports and PLATON `.chk` files) held as fixtures with
25
+ provenance in tests/fixtures/adapters/checkcif/.
26
+
27
+ Alert→mass mapping (CLM-006-03 — registered here, monotone, and not
28
+ tunable per study):
29
+
30
+ | alerts present | stance | contradiction mass | falsifier observation |
31
+ |----------------|-------------|--------------------|-----------------------|
32
+ | ≥ 1 level A | contradicts | 1.00 | yes |
33
+ | ≥ 1 level B | contradicts | 0.50 | no |
34
+ | only C / G | contextual | 0 | no |
35
+ | none | contextual | 0 | no |
36
+
37
+ An A alert is checkCIF's own "serious problem" finding on the
38
+ artifact, so it is recorded as the falsifying observation for a
39
+ structure sub-claim whose falsifier names serious validation alerts.
40
+ C/G alerts are context: they neither support nor contradict, and a
41
+ clean run is likewise context only — per CLM-006-02 a pass is not
42
+ supporting evidence for a structure's correctness.
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import hashlib
48
+ import html as html_mod
49
+ import os
50
+ import re
51
+ import shutil
52
+ import subprocess
53
+ import tempfile
54
+ from dataclasses import dataclass, field
55
+ from pathlib import Path
56
+ from typing import Any, Optional
57
+
58
+ from ..claims import ClaimRegister, EvidenceItem
59
+ from .core import (
60
+ NOT_RUNNABLE,
61
+ RECORDED_OUTPUT,
62
+ RUN_BY_US,
63
+ AdapterRun,
64
+ emit_adapter_evidence,
65
+ ledger_run,
66
+ sha256_hex,
67
+ )
68
+
69
+ ADAPTER_ID = "checkcif"
70
+ PATTERN = "P2"
71
+ SCOPE_CLAIM_ID = "CLM-006-02"
72
+ CHECKER_NAME_LIVE = "PLATON (local binary)"
73
+ CHECKER_NAME_RECORDED = "checkCIF/PLATON (recorded output)"
74
+
75
+ NO_CHECKER_REASON = (
76
+ "no local PLATON binary found (checked $TEKMERION_PLATON_BIN and PATH); "
77
+ "PLATON is author-distributed and not OSI-licensed, and the IUCr checkCIF "
78
+ "web service is a submission service this adapter does not automate — "
79
+ "supply a recorded checkCIF/PLATON output or install PLATON under its "
80
+ "license terms (open item, Spec 006 build record)"
81
+ )
82
+
83
+
84
+ # ------------------------------------------------------------------- parser
85
+
86
+ # Alert codes: <prefix><number>_ALERT_<type>_<level>, e.g.
87
+ # PLAT417_ALERT_2_A (service HTML), STRVA01_ALERT_4_C (2-digit number),
88
+ # or the bare numeric form 048_ALERT_1_G (PLATON .chk files).
89
+ _ALERT_LINE = re.compile(
90
+ r"((?:[A-Z]{2,6}\d{2,3}|\d{3})_ALERT_(\d)_([ABCG]))\s*(.*?)\s*$")
91
+
92
+ _CHK_VERSION = re.compile(
93
+ r"PLATON/CHECK-\(\s*(\d+)\)\s*versus check\.def version(?: of)?\s*(\d+)")
94
+ _HTML_VERSION = re.compile(
95
+ r"PLATON version of ([\d/]+);\s*check\.def file version of ([\d/]+)")
96
+ _HTML_SUMMARY = re.compile(r"(\d+)\s*ALERT level ([ABCG])")
97
+ _CHK_SUMMARY = re.compile(r"(\d+)\s+ALERT_Level_([A-Z])\b")
98
+
99
+
100
+ @dataclass
101
+ class CheckCIFParse:
102
+ alerts: list[dict[str, Any]] = field(default_factory=list)
103
+ platon_version: Optional[str] = None
104
+ checkdef_version: Optional[str] = None
105
+ datablock: Optional[str] = None
106
+ syntax_errors_found: Optional[bool] = None
107
+ reported_summary_counts: dict[str, int] = field(default_factory=dict)
108
+
109
+ @property
110
+ def counts(self) -> dict[str, int]:
111
+ out = {"A": 0, "B": 0, "C": 0, "G": 0}
112
+ for alert in self.alerts:
113
+ out[alert["level"]] += 1
114
+ return out
115
+
116
+ @property
117
+ def version_complete(self) -> bool:
118
+ """CLM-006-01: an alert record without the checker identity and
119
+ the PLATON + check.def versions is not evidence."""
120
+ return bool(self.platon_version and self.checkdef_version)
121
+
122
+ def as_dict(self) -> dict[str, Any]:
123
+ return {
124
+ "alert_counts": self.counts, "alerts": self.alerts,
125
+ "platon_version": self.platon_version,
126
+ "checkdef_version": self.checkdef_version,
127
+ "datablock": self.datablock,
128
+ "syntax_errors_found": self.syntax_errors_found,
129
+ "reported_summary_counts": self.reported_summary_counts,
130
+ "summary_agrees_with_alerts":
131
+ (self.reported_summary_counts == self.counts)
132
+ if self.reported_summary_counts else None,
133
+ }
134
+
135
+
136
+ def _plain_text(text: str) -> str:
137
+ # Tag-stripping only for actual HTML reports: PLATON .chk text uses
138
+ # '>>>' section markers and '<' in prose, and treating those as tags
139
+ # would eat alert lines.
140
+ if re.search(r"<!DOCTYPE|<html[\s>]", text, re.IGNORECASE):
141
+ text = re.sub(r"<[^>]+>", " ", text)
142
+ return html_mod.unescape(text)
143
+
144
+
145
+ def parse_checkcif_output(text: str) -> CheckCIFParse:
146
+ """Parse a checkCIF/PLATON report (IUCr service HTML or PLATON
147
+ .chk plain text) into alert records. Alert lines govern; the
148
+ report's own summary counts are recorded verbatim as a cross-check
149
+ (they can disagree with the alert listing — PLATON's .chk summary
150
+ is known to bucket levels differently — and the disagreement is
151
+ part of the record, never silently resolved)."""
152
+ plain = _plain_text(text)
153
+ parse = CheckCIFParse()
154
+ seen: set[tuple[str, str]] = set()
155
+ for line in plain.splitlines():
156
+ m = _ALERT_LINE.search(line)
157
+ if m:
158
+ code, atype, level, message = m.group(1), int(m.group(2)), m.group(3), m.group(4)
159
+ message = re.sub(r"\s+", " ", message).strip()
160
+ key = (code, message)
161
+ if key not in seen:
162
+ seen.add(key)
163
+ parse.alerts.append({
164
+ "code": code, "alert_type": atype, "level": level,
165
+ "message": message,
166
+ })
167
+ for sm in _HTML_SUMMARY.finditer(line):
168
+ parse.reported_summary_counts[sm.group(2)] = int(sm.group(1))
169
+ for sm in _CHK_SUMMARY.finditer(line):
170
+ parse.reported_summary_counts[sm.group(2)] = int(sm.group(1))
171
+ vm = _CHK_VERSION.search(plain) or _HTML_VERSION.search(plain)
172
+ if vm:
173
+ parse.platon_version, parse.checkdef_version = vm.group(1), vm.group(2)
174
+ dm = re.search(r"Datablock:\s*(\S+)", plain) or re.search(r"[Ee]ntry:\s*(\S+)", plain)
175
+ if dm:
176
+ parse.datablock = dm.group(1)
177
+ if "No syntax errors found" in plain:
178
+ parse.syntax_errors_found = False
179
+ elif re.search(r"syntax error", plain, re.IGNORECASE):
180
+ parse.syntax_errors_found = True
181
+ return parse
182
+
183
+
184
+ # ------------------------------------------------------------------ mapping
185
+
186
+ def map_alert_counts(counts: dict[str, int]) -> dict[str, Any]:
187
+ """The registered CLM-006-03 mapping, as a pure function of the
188
+ parsed counts. `rule` names the row applied, for the record."""
189
+ if counts.get("A", 0) >= 1:
190
+ return {"stance": "contradicts", "support_mass": 0.0,
191
+ "contradiction_mass": 1.00, "falsifier_observation": True,
192
+ "rule": "A>=1: serious problem — full contradiction, falsifying observation"}
193
+ if counts.get("B", 0) >= 1:
194
+ return {"stance": "contradicts", "support_mass": 0.0,
195
+ "contradiction_mass": 0.50, "falsifier_observation": False,
196
+ "rule": "B>=1: potentially serious — partial contradiction"}
197
+ return {"stance": "contextual", "support_mass": 0.0,
198
+ "contradiction_mass": 0.0, "falsifier_observation": False,
199
+ "rule": "C/G only or clean: context only — a pass is not support (CLM-006-02)"}
200
+
201
+
202
+ def checkcif_evidence(parse: CheckCIFParse, *, item_id: str,
203
+ origin_group: str) -> EvidenceItem:
204
+ mapping = map_alert_counts(parse.counts)
205
+ return EvidenceItem(
206
+ item_id=item_id, origin_group=origin_group,
207
+ stance=mapping["stance"], support_mass=mapping["support_mass"],
208
+ contradiction_mass=mapping["contradiction_mass"],
209
+ could_falsify=True, falsifier_observation=mapping["falsifier_observation"],
210
+ verified=True, hosted_ok=True, metadata_only=False,
211
+ )
212
+
213
+
214
+ # ------------------------------------------------------------------- runner
215
+
216
+ class PlatonRunError(RuntimeError):
217
+ pass
218
+
219
+
220
+ class PlatonRunner:
221
+ """Local PLATON invocation (`platon -u <cif>` writes a .chk file).
222
+
223
+ Discovery: explicit path, else $TEKMERION_PLATON_BIN, else PATH.
224
+ Absence is a state the caller records (`not_runnable_here`),
225
+ never an exception and never a pass.
226
+ """
227
+
228
+ def __init__(self, binary: Optional[str] = None) -> None:
229
+ self.binary = (binary or os.environ.get("TEKMERION_PLATON_BIN")
230
+ or shutil.which("platon"))
231
+
232
+ def available(self) -> bool:
233
+ return bool(self.binary) and Path(self.binary).exists()
234
+
235
+ def run(self, cif_bytes: bytes, cif_name: str, *, timeout: int = 300) -> str:
236
+ """Run the checker on the artifact bytes; return the .chk
237
+ text. Raises PlatonRunError on any environment failure —
238
+ distinguished from an alert outcome: a failed run is not a
239
+ finding about the artifact."""
240
+ if not self.available():
241
+ raise PlatonRunError("no PLATON binary available")
242
+ with tempfile.TemporaryDirectory(prefix="tekmerion-checkcif-") as td:
243
+ work = Path(td)
244
+ cif_path = work / cif_name
245
+ cif_path.write_bytes(cif_bytes)
246
+ try:
247
+ subprocess.run(
248
+ [self.binary, "-u", cif_name], cwd=td,
249
+ stdin=subprocess.DEVNULL, capture_output=True,
250
+ text=True, timeout=timeout)
251
+ except subprocess.TimeoutExpired as exc:
252
+ raise PlatonRunError(f"PLATON run exceeded {timeout}s") from exc
253
+ chk_files = sorted(work.glob("*.chk"))
254
+ if not chk_files:
255
+ raise PlatonRunError(
256
+ "PLATON produced no .chk output file in the run directory")
257
+ return chk_files[0].read_text(encoding="utf-8", errors="replace")
258
+
259
+
260
+ # ---------------------------------------------------------------------- run
261
+
262
+ def run_checkcif(
263
+ store: Any,
264
+ *,
265
+ cif_bytes: Optional[bytes] = None,
266
+ cif_label: str = "",
267
+ actor: str,
268
+ claim_id: Optional[str] = None,
269
+ register: Optional[ClaimRegister] = None,
270
+ item_id: Optional[str] = None,
271
+ origin_group: Optional[str] = None,
272
+ runner: Optional[PlatonRunner] = None,
273
+ recorded_output: Optional[bytes] = None,
274
+ recorded_provenance: Optional[str] = None,
275
+ ) -> AdapterRun:
276
+ """Run (or ingest the recorded output of) checkCIF on a CIF and
277
+ ledger the run; attach evidence when a study context is given and
278
+ the record is complete (CLM-006-01). Returns the run record either
279
+ way — an unavailable checker or an incomplete record is a state in
280
+ the ledger, not an exception and not a pass."""
281
+ run = AdapterRun(
282
+ adapter_id=ADAPTER_ID, pattern=PATTERN, scope_claim_id=SCOPE_CLAIM_ID,
283
+ outcome=NOT_RUNNABLE, checker_name=CHECKER_NAME_RECORDED,
284
+ checker_version=None,
285
+ artifact_sha256=sha256_hex(cif_bytes) if cif_bytes is not None else None,
286
+ artifact_label=cif_label,
287
+ artifact_byte_length=len(cif_bytes) if cif_bytes is not None else None,
288
+ )
289
+ output_text: Optional[str] = None
290
+ runner = runner if runner is not None else PlatonRunner()
291
+ if cif_bytes is not None and runner.available():
292
+ run.checker_name = CHECKER_NAME_LIVE
293
+ try:
294
+ output_text = runner.run(cif_bytes, cif_label or "artifact.cif")
295
+ run.outcome = RUN_BY_US
296
+ except PlatonRunError as exc:
297
+ run.reason = f"PLATON run failed: {exc}"
298
+ if output_text is None and recorded_output is not None:
299
+ output_text = recorded_output.decode("utf-8", errors="replace")
300
+ run.outcome = RECORDED_OUTPUT
301
+ if recorded_provenance:
302
+ run.extra["recorded_provenance"] = recorded_provenance
303
+ if output_text is None:
304
+ if not run.reason:
305
+ run.reason = NO_CHECKER_REASON
306
+ ledger_run(store, run, actor=actor)
307
+ return run
308
+
309
+ output_bytes = output_text.encode("utf-8")
310
+ run.raw_output_sha256 = hashlib.sha256(output_bytes).hexdigest()
311
+ parse = parse_checkcif_output(output_text)
312
+ run.findings = parse.alerts
313
+ run.extra["parse"] = parse.as_dict()
314
+ if parse.version_complete:
315
+ run.checker_version = f"PLATON {parse.platon_version}; check.def {parse.checkdef_version}"
316
+
317
+ wants_evidence = claim_id is not None and register is not None and item_id is not None
318
+ if wants_evidence:
319
+ if not parse.version_complete:
320
+ run.extra["evidence_withheld"] = (
321
+ "recorded output carries no complete PLATON/check.def version "
322
+ "pair — an alert record without checker identity and versions "
323
+ "is not evidence (CLM-006-01)")
324
+ ledger_run(store, run, actor=actor)
325
+ return run
326
+ if cif_bytes is None:
327
+ run.extra["evidence_withheld"] = (
328
+ "artifact bytes not supplied — the evidence item cannot carry "
329
+ "the CIF SHA-256 (CLM-006-01); parsed alerts stand as context")
330
+ ledger_run(store, run, actor=actor)
331
+ return run
332
+ item = checkcif_evidence(parse, item_id=item_id,
333
+ origin_group=origin_group or f"EVG-{item_id}")
334
+ emit_adapter_evidence(store, run=run, register=register,
335
+ claim_id=claim_id, item=item, actor=actor)
336
+ return run
337
+ ledger_run(store, run, actor=actor)
338
+ return run