consiliency-spec 0.1.1__py3-none-any.whl
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.
- consiliency_spec/__init__.py +78 -0
- consiliency_spec/_data/canon/README.md +99 -0
- consiliency_spec/_data/canon/SPEC.md +380 -0
- consiliency_spec/_data/canon/bindings/pyo3/README.md +14 -0
- consiliency_spec/_data/canon/bindings/wasm/README.md +14 -0
- consiliency_spec/_data/canon/conformance/check.sh +78 -0
- consiliency_spec/_data/canon/conformance/check_dabi_cabi.sh +15 -0
- consiliency_spec/_data/canon/conformance/check_published_canon_core.sh +198 -0
- consiliency_spec/_data/canon/conformance/check_xg4_canon_core.sh +146 -0
- consiliency_spec/_data/canon/conformance/emit_published_npm.mjs +118 -0
- consiliency_spec/_data/canon/conformance/emit_published_py.py +116 -0
- consiliency_spec/_data/canon/conformance/emit_pyo3.py +55 -0
- consiliency_spec/_data/canon/conformance/emit_wasm.mjs +54 -0
- consiliency_spec/_data/canon/conformance/engine_boundary_vectors.json +71 -0
- consiliency_spec/_data/canon/conformance/test_ingest_nfc.py +120 -0
- consiliency_spec/_data/canon/conformance/test_unicode_skew.py +164 -0
- consiliency_spec/_data/canon/conformance/wasm_surrogate_finding.mjs +107 -0
- consiliency_spec/_data/canon/core/Cargo.lock +408 -0
- consiliency_spec/_data/canon/core/Cargo.toml +25 -0
- consiliency_spec/_data/canon/core/cbindgen.toml +26 -0
- consiliency_spec/_data/canon/core/include/canon_core.h +84 -0
- consiliency_spec/_data/canon/core/scripts/check_cabi_smoke.sh +49 -0
- consiliency_spec/_data/canon/core/src/bin/canon_core_emit.rs +36 -0
- consiliency_spec/_data/canon/core/src/c_abi.rs +226 -0
- consiliency_spec/_data/canon/core/src/lib.rs +278 -0
- consiliency_spec/_data/canon/core/tests/c_abi_smoke.c +85 -0
- consiliency_spec/_data/canon/core/tests/parity.rs +39 -0
- consiliency_spec/_data/canon/py/canon.py +221 -0
- consiliency_spec/_data/canon/py/canon_ingest.py +103 -0
- consiliency_spec/_data/canon/py/requirements.txt +9 -0
- consiliency_spec/_data/canon/py/test_canon.py +91 -0
- consiliency_spec/_data/canon/ts/canon.test.ts +110 -0
- consiliency_spec/_data/canon/ts/canon.ts +294 -0
- consiliency_spec/_data/canon/vectors/canon-vectors.json +464 -0
- consiliency_spec/_data/canon/vectors/gov-cross-repo-canon-vectors.PROVENANCE.md +29 -0
- consiliency_spec/_data/canon/vectors/gov-cross-repo-canon-vectors.json +467 -0
- consiliency_spec/_data/consiliency-spec.public-manifest.json +211 -0
- consiliency_spec/_data/idmodel/correspondence/schema.json +98 -0
- consiliency_spec/_data/spec-engine/authority/authority-event.schema.json +183 -0
- consiliency_spec/_data/spec-graph/schema/spec-graph.schema.json +236 -0
- consiliency_spec/_data/spec-parity/SEMANTICS.md +716 -0
- consiliency_spec/_data/spec-parity/conformance/check.sh +81 -0
- consiliency_spec/_data/spec-parity/conformance/check_xg0_envelope.sh +113 -0
- consiliency_spec/_data/spec-parity/kind-alignment.json +316 -0
- consiliency_spec/_data/spec-parity/permitted-freedom-vocab.json +36 -0
- consiliency_spec/_data/spec-parity/schemas/certificate.schema.json +106 -0
- consiliency_spec/_data/spec-parity/schemas/kind-alignment.schema.json +107 -0
- consiliency_spec/_data/spec-parity/schemas/permitted-freedom-vocab.schema.json +62 -0
- consiliency_spec/_data/spec-parity/schemas/portal-payload.schema.json +145 -0
- consiliency_spec/_data/spec-parity/schemas/result-state.schema.json +105 -0
- consiliency_spec/_data/spec-parity/schemas/waiver.schema.json +72 -0
- consiliency_spec/py.typed +1 -0
- consiliency_spec-0.1.1.dist-info/METADATA +97 -0
- consiliency_spec-0.1.1.dist-info/RECORD +57 -0
- consiliency_spec-0.1.1.dist-info/WHEEL +4 -0
- consiliency_spec-0.1.1.dist-info/licenses/LICENSE +202 -0
- consiliency_spec-0.1.1.dist-info/licenses/NOTICE +34 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Thin Python reader for the public Consiliency canon package."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from importlib import resources
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
SPEC_PACKAGE = "consiliency-spec"
|
|
11
|
+
SPEC_NPM_PACKAGE = "@consiliency/spec"
|
|
12
|
+
SPEC_VERSION = "0.1.0"
|
|
13
|
+
__version__ = SPEC_VERSION
|
|
14
|
+
|
|
15
|
+
_MANIFEST = "consiliency-spec.public-manifest.json"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _source_root() -> Path:
|
|
19
|
+
return Path(__file__).resolve().parent.parent
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _read_bytes(relative_path: str) -> bytes:
|
|
23
|
+
source_path = _source_root() / relative_path
|
|
24
|
+
if source_path.exists():
|
|
25
|
+
return source_path.read_bytes()
|
|
26
|
+
data_path = resources.files(__package__).joinpath("_data", relative_path)
|
|
27
|
+
return data_path.read_bytes()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _read_text(relative_path: str) -> str:
|
|
31
|
+
return _read_bytes(relative_path).decode("utf-8")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def load_manifest() -> dict[str, Any]:
|
|
35
|
+
return json.loads(_read_text(_MANIFEST))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def list_public_files() -> list[str]:
|
|
39
|
+
return [item["path"] for item in load_manifest()["public_files"]]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def read_public_bytes(relative_path: str) -> bytes:
|
|
43
|
+
if relative_path not in set(list_public_files()):
|
|
44
|
+
raise ValueError(f"Unknown public canon file: {relative_path}")
|
|
45
|
+
return _read_bytes(relative_path)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def read_public_text(relative_path: str) -> str:
|
|
49
|
+
return read_public_bytes(relative_path).decode("utf-8")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def load_json(relative_path: str) -> dict[str, Any]:
|
|
53
|
+
return json.loads(read_public_text(relative_path))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def load_schema(name: str) -> dict[str, Any]:
|
|
57
|
+
if name.endswith(".schema.json"):
|
|
58
|
+
filename = name
|
|
59
|
+
else:
|
|
60
|
+
filename = f"{name}.schema.json"
|
|
61
|
+
for path in list_public_files():
|
|
62
|
+
if path.endswith(f"/{filename}"):
|
|
63
|
+
return load_json(path)
|
|
64
|
+
raise ValueError(f"Unknown public schema: {name}")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
__all__ = [
|
|
68
|
+
"SPEC_PACKAGE",
|
|
69
|
+
"SPEC_NPM_PACKAGE",
|
|
70
|
+
"SPEC_VERSION",
|
|
71
|
+
"__version__",
|
|
72
|
+
"list_public_files",
|
|
73
|
+
"load_json",
|
|
74
|
+
"load_manifest",
|
|
75
|
+
"load_schema",
|
|
76
|
+
"read_public_bytes",
|
|
77
|
+
"read_public_text",
|
|
78
|
+
]
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# canon v2
|
|
2
|
+
|
|
3
|
+
The canonical-serialization + content-addressing contract for `spec` (spine contract **S1**,
|
|
4
|
+
roadmap Phase **0A**). One serialization, identical bytes in every language, content-addressed by
|
|
5
|
+
domain-separated SHA-256.
|
|
6
|
+
|
|
7
|
+
> **canon v1 → v2 (NFCBOUNDARY).** Unicode NFC moved OUT of `canonical_bytes` to the **ingestion
|
|
8
|
+
> boundary** (`py/canon_ingest.py`); canon v2 is Unicode-DB-independent and the digest prefix is
|
|
9
|
+
> `spec-canon:v2:`. v1 certificates stay valid *as v1 records* (never compared to v2; mixed-version
|
|
10
|
+
> graphs rejected). See [`SPEC.md`](./SPEC.md) §5/§8/§9. All other rules are unchanged.
|
|
11
|
+
|
|
12
|
+
- **Normative contract:** [`SPEC.md`](./SPEC.md) — read this first; it is the source of truth.
|
|
13
|
+
- **Conformance vectors:** [`vectors/canon-vectors.json`](./vectors/canon-vectors.json) — the
|
|
14
|
+
executable form of the contract (inputs → exact bytes → exact digest), generated by the Python
|
|
15
|
+
reference impl and reproduced byte-for-byte by the TypeScript one.
|
|
16
|
+
- **Reference impls:** [`py/canon.py`](./py/canon.py), [`ts/canon.ts`](./ts/canon.ts) — clean-room
|
|
17
|
+
custom encoders. Neither uses stdlib JSON (`json.dumps` / `JSON.stringify`) for canonical output.
|
|
18
|
+
|
|
19
|
+
## The contract in one paragraph
|
|
20
|
+
|
|
21
|
+
`canonical_bytes(value)` produces a deterministic UTF-8 byte string for a restricted value domain
|
|
22
|
+
(objects with string keys, arrays, NFC strings, integers, booleans, null). Object keys are sorted
|
|
23
|
+
by **Unicode code point** at every depth; arrays **always** preserve insertion order; strings are
|
|
24
|
+
**already-NFC** (normalized once at the ingest boundary, NOT by canon v2) and emitted as raw UTF-8
|
|
25
|
+
with only `"`, `\`, and control chars U+0000–U+001F escaped (`\uXXXX` lowercase); **floats / NaN /
|
|
26
|
+
Infinity / unpaired surrogates are rejected**.
|
|
27
|
+
`digest(value, profile)` = lowercase-hex `SHA-256("spec-canon:v2:" + profile + "\n" || bytes)` over
|
|
28
|
+
one of four profiles (`semantic-content`, `run`, `artifact-byte`, `certificate`); a top-level
|
|
29
|
+
`digest` field is excluded from its own digest. Volatile/provenance data lives in a non-hashed
|
|
30
|
+
`locator` envelope, never in canonical content. **Produce once, store verbatim, never recompute.**
|
|
31
|
+
|
|
32
|
+
## Quick use
|
|
33
|
+
|
|
34
|
+
Python:
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from canon import canonical_bytes, digest, split_record
|
|
38
|
+
canonical_bytes({"b": 2, "a": 1}) # b'{"a":1,"b":2}'
|
|
39
|
+
digest({"id": "x"}, "semantic-content") # lowercase sha-256 hex
|
|
40
|
+
content, envelope = split_record(rec, ["id", "kind"]) # only `content` is hashed
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
TypeScript (Node v24+, large ints as `bigint`):
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
import { canonicalBytes, digest, splitRecord } from "./canon.ts";
|
|
47
|
+
canonicalBytes({ b: 2n, a: 1n }); // Uint8Array of {"a":1,"b":2}
|
|
48
|
+
digest({ id: "x" }, "semantic-content"); // lowercase sha-256 hex
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Running the conformance gate (THE fleet gate)
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
bash canon/conformance/check.sh
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
It (1) runs the Python impl against the pinned vectors, (2) runs the TypeScript impl against the
|
|
58
|
+
**same** vectors, and (3) asserts both produce **byte-identical** canonical output and identical
|
|
59
|
+
digests on every vector. Exit 0 = green; any divergence prints a diff and exits non-zero.
|
|
60
|
+
|
|
61
|
+
### Prerequisites
|
|
62
|
+
|
|
63
|
+
- **Python 3** (stdlib `hashlib`) **plus `unicodedata2==16.0.0`** — the pinned Unicode 16.0 backport.
|
|
64
|
+
In canon v2, canon itself needs no Unicode DB (it no longer normalizes); the pin is required by the
|
|
65
|
+
**ingest boundary** (`py/canon_ingest.py`), which performs NFC and fail-closed-asserts the pinned
|
|
66
|
+
version at import (see `py/requirements.txt`; `check.sh` installs it). Stdlib `unicodedata` is
|
|
67
|
+
deliberately NOT used for ingest NFC: it is bound to the host CPython's Unicode version and would not
|
|
68
|
+
match the Node port byte-for-byte.
|
|
69
|
+
- **Node v24+** and **`tsx`** to run the `.ts` files. `check.sh` invokes `npx --yes tsx`, which
|
|
70
|
+
**fetches `tsx` from the npm registry on first run** — so the gate needs network access the first
|
|
71
|
+
time (or a pre-installed/vendored `tsx` for an offline CI box). Pin/vendor `tsx` if your CI is
|
|
72
|
+
air-gapped.
|
|
73
|
+
|
|
74
|
+
## Regenerating vectors
|
|
75
|
+
|
|
76
|
+
`vectors/canon-vectors.json` is generated (never hand-authored) from the Python reference impl:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
python3 canon/vectors/gen_vectors.py
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Expected bytes (base64) and digests (hex) are pinned in the file; the TypeScript impl must
|
|
83
|
+
reproduce them. After regenerating, re-run `check.sh`. The file is written ASCII-safe
|
|
84
|
+
(`ensure_ascii=True`) purely as a transport so non-ASCII inputs and the lone-surrogate reject
|
|
85
|
+
vector survive identically under both `json.load` and `JSON.parse` — this does not affect canon
|
|
86
|
+
output, which emits non-ASCII raw.
|
|
87
|
+
|
|
88
|
+
## Known cross-language traps this contract pins (and how it resolves each)
|
|
89
|
+
|
|
90
|
+
| Trap | Resolution |
|
|
91
|
+
|---|---|
|
|
92
|
+
| JS default key sort is UTF-16 code-unit order (wrong for astral) | sort by **code point** (`compareCodePoints`); vector `astral-key-sort` (U+E000 vs U+10000) fails any code-unit impl |
|
|
93
|
+
| `isinstance(True, int)` is True in Python | dispatch tests `bool` **before** `int`; vector `boolean-value` |
|
|
94
|
+
| `json.dumps`/`JSON.stringify` escaping & whitespace differ | custom encoder, no stdlib JSON for canonical output; control chars `\uXXXX` lowercase, no short escapes |
|
|
95
|
+
| `ensure_ascii` divergence | non-ASCII emitted **raw** in both |
|
|
96
|
+
| float formatting differs across langs | **floats forbidden**; caller pre-represents as string/scaled int |
|
|
97
|
+
| `NaN`/`Infinity` not valid JSON; `1.0`→float (py) vs int (js); int > 2^53 precision | vector inputs are **type-tagged** (`$int`/`$float`/`$nan`/`$inf`); TS uses `bigint` |
|
|
98
|
+
| lone surrogate: Python raises, JS emits U+FFFD | **rejected** in both; vector `reject-lone-surrogate` |
|
|
99
|
+
| NFC depends on Unicode version (skew between ports) | RESOLVED then RELOCATED (canon v2): NFC moved out of the hash to the ingest boundary, so canon is Unicode-DB-independent. The `unicodedata2==16.0.0` pin + fail-closed assertion + the `post13-*` discriminators now live in the ingest tests (`test_ingest_nfc.py` / `test_unicode_skew.py`), which prove the pin is load-bearing (the byte-identity gate can't, since canon v2 emits verbatim) |
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
# canon v2 — Canonical Serialization + Content-Addressing Contract
|
|
2
|
+
|
|
3
|
+
Status: **NORMATIVE**, frozen for v2. Owned by `spec` (Consiliency). This is the foundational
|
|
4
|
+
content-addressing contract (**S1**) on which the rest of the engine builds.
|
|
5
|
+
|
|
6
|
+
> **canon v1 → v2 (NFCBOUNDARY).** v2 is identical to v1 except that mandatory Unicode **NFC is no
|
|
7
|
+
> longer applied inside `canonical_bytes`** — it relocated to the **ingestion boundary** (callers
|
|
8
|
+
> deliver already-NFC content; see §5 and `py/canon_ingest.py`). canon v2 is therefore **independent
|
|
9
|
+
> of any Unicode DB version**: the `unicodedata2` pin and the fail-closed version assertion moved to
|
|
10
|
+
> the ingest boundary. The digest **domain prefix changed `spec-canon:v1:` → `spec-canon:v2:`** (§8),
|
|
11
|
+
> so a v2 digest can never collide with a v1 digest of the same input. Every other rule — key
|
|
12
|
+
> code-point sort, integers-only, lone-surrogate rejection, minimal escaping, the four profiles, the
|
|
13
|
+
> content/envelope split — is **unchanged**. Historical v1 certificates remain valid *as v1 records*;
|
|
14
|
+
> see §9. Driver: escape the Unicode-16.0 *in-hash* NFC pin so canon's byte-identity gate is no longer
|
|
15
|
+
> Unicode-version-coupled.
|
|
16
|
+
|
|
17
|
+
This document is the single source of truth. A conformance vector suite
|
|
18
|
+
(`vectors/canon-vectors.json`) ships with it and is the executable form of every rule below.
|
|
19
|
+
Two reference ports (Python `py/canon.py`, TypeScript `ts/canon.ts`) MUST agree **byte-for-byte**
|
|
20
|
+
on every vector. That cross-language byte-identity is the exit gate.
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## 0. Scope and the one hard guarantee
|
|
25
|
+
|
|
26
|
+
`canon` defines **one** function over a restricted value domain:
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
canonical_bytes(value) -> bytes # deterministic, language-independent UTF-8
|
|
30
|
+
digest(value, profile) -> hex string # SHA-256 over a domain-separated wrapping of those bytes
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The hard guarantee: **for any value in the supported domain, every conforming implementation in
|
|
34
|
+
every language produces the identical byte string and the identical digest.** Nothing else in the
|
|
35
|
+
system may content-address by re-serializing; see §9 (produce once, store verbatim).
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## 1. Supported value domain
|
|
40
|
+
|
|
41
|
+
Canonical content is built only from these JSON-like types:
|
|
42
|
+
|
|
43
|
+
| Type | Notes |
|
|
44
|
+
|-------------|-------|
|
|
45
|
+
| object/map | string keys only; sorted by key (see §3) |
|
|
46
|
+
| array/list | insertion order preserved ALWAYS (see §4) |
|
|
47
|
+
| string | already-NFC (normalized at ingest, not by canon), UTF-8 (see §5) |
|
|
48
|
+
| integer | arbitrary precision; NO floats (see §6) |
|
|
49
|
+
| boolean | `true` / `false` lowercase |
|
|
50
|
+
| null | `null` lowercase |
|
|
51
|
+
|
|
52
|
+
**Explicitly rejected** (encoder MUST raise a clear error, never coerce):
|
|
53
|
+
floats / real numbers, `NaN`, `+Infinity`, `-Infinity`, non-string object keys, **unpaired
|
|
54
|
+
surrogate code points U+D800–U+DFFF in any string** (see §5), and any language-native type not in
|
|
55
|
+
the table (dates, sets, bytes, undefined, functions, etc.).
|
|
56
|
+
Decimals are the caller's responsibility — pre-represent them as strings or scaled integers
|
|
57
|
+
**before** handing a value to `canon`. This deliberately sidesteps cross-language float formatting,
|
|
58
|
+
the single largest source of divergence, for v1.
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## 2. The vector-input encoding (type-tagged) — why JSON can't carry the inputs directly
|
|
63
|
+
|
|
64
|
+
The conformance vectors live in a JSON file, but **raw JSON cannot losslessly carry the inputs we
|
|
65
|
+
must test**, and the failure is silent and language-asymmetric:
|
|
66
|
+
|
|
67
|
+
- `NaN` / `Infinity` are not valid JSON: `JSON.parse` throws, Python `json.loads` accepts them.
|
|
68
|
+
- `1.0` parses to a float in Python (rejectable) but to the integer `1` in JS (`Number.isInteger`
|
|
69
|
+
is true) — so an "int vs float" vector would diverge purely from *parsing*, before the encoder runs.
|
|
70
|
+
- Integers above 2^53 lose precision under `JSON.parse`.
|
|
71
|
+
|
|
72
|
+
Therefore the `input` field of every vector is a **type-tagged tree** decoded by a tiny shared
|
|
73
|
+
decoder that is identical in both languages. Tags:
|
|
74
|
+
|
|
75
|
+
| Tag form | Decodes to |
|
|
76
|
+
|-------------------------|-----------|
|
|
77
|
+
| `{"$int": "123"}` | integer (decimal string, arbitrary precision, optional leading `-`) |
|
|
78
|
+
| `{"$float": "1.0"}` | a float marker — the encoder MUST reject it |
|
|
79
|
+
| `{"$nan": true}` | NaN marker — encoder MUST reject |
|
|
80
|
+
| `{"$inf": 1}` / `{"$inf": -1}` | +/-Infinity marker — encoder MUST reject |
|
|
81
|
+
| `{"$str": "..."}` | string (explicit; also used to carry a key that looks like a tag) |
|
|
82
|
+
| `{"$bool": true}` | boolean (explicit) |
|
|
83
|
+
| `{"$null": true}` | null (explicit) |
|
|
84
|
+
| `{"$obj": {k: <tagged>, ...}}` | object; keys are literal strings, values tagged |
|
|
85
|
+
| `{"$arr": [<tagged>, ...]}` | array; elements tagged |
|
|
86
|
+
|
|
87
|
+
A bare JSON `true`/`false`/`null`/string/array/object is also accepted by the decoder as the
|
|
88
|
+
obvious thing, but the canonical vectors use explicit tags wherever type intent is load-bearing
|
|
89
|
+
(numbers, booleans, the NaN/Inf/float rejections) so the file is unambiguous in every language.
|
|
90
|
+
|
|
91
|
+
The decoder is part of the **test harness**, not the canon contract; `canonical_bytes` operates on
|
|
92
|
+
already-decoded native values. But both ports MUST decode the file identically, so the decoder is
|
|
93
|
+
specified here and implemented identically in `py/canon.py` and `ts/canon.ts`.
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## 3. Object keys — sorted by Unicode code point, at every depth
|
|
98
|
+
|
|
99
|
+
Keys are sorted ascending by **Unicode code point**, recursively at every nesting level.
|
|
100
|
+
|
|
101
|
+
**Trap (load-bearing):** JavaScript's default string comparison (`Array.prototype.sort` /
|
|
102
|
+
`<`) compares **UTF-16 code units**, which is WRONG for astral-plane characters (U+10000 and
|
|
103
|
+
above). A high-BMP character such as U+E000 must sort *before* an astral character U+10000, but
|
|
104
|
+
under code-unit comparison the astral char's leading surrogate (0xD800) compares as less than
|
|
105
|
+
0xE000, flipping the order. Implementations MUST compare by code point (iterate code points /
|
|
106
|
+
`codePointAt`), never by raw UTF-16 unit. Python's default `str` comparison is already by code
|
|
107
|
+
point. The `astral-key-sort` vector exists specifically to fail any code-unit implementation.
|
|
108
|
+
|
|
109
|
+
Keys MUST be **already NFC** when they reach canon (canon v2 does NOT normalize — see §5). The
|
|
110
|
+
ingestion boundary (`py/canon_ingest.py`) NFC-normalizes keys **before** canon sees them, and that
|
|
111
|
+
is where the **key-collision check** now lives: if two distinct input keys normalize to the same NFC
|
|
112
|
+
string, the ingest boundary MUST raise an error (key collision), never silently overwrite. canon v2
|
|
113
|
+
sorts the already-NFC keys by code point as-is.
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## 4. Arrays — insertion order preserved ALWAYS
|
|
118
|
+
|
|
119
|
+
Array element order is **never** changed by the serializer. Reordering an array yields different
|
|
120
|
+
canonical bytes and a different digest; this is intentional and tested (`array-order-matters`).
|
|
121
|
+
The only lists that may be ordered differently are those a *schema* explicitly declares
|
|
122
|
+
order-insensitive (e.g. candidate-id sets) — and that ordering is performed by the schema/caller
|
|
123
|
+
*before* canon sees the value. The serializer itself sorts nothing in arrays.
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## 5. Strings — already-NFC, raw UTF-8, minimal escaping
|
|
128
|
+
|
|
129
|
+
0. **Reject unpaired surrogates** (any code point U+D800–U+DFFF not part of a valid pair). This is
|
|
130
|
+
load-bearing for byte-identity: a lone surrogate makes Python's UTF-8 encoder *raise* while
|
|
131
|
+
JavaScript's `TextEncoder` silently emits U+FFFD (`EF BF BD`) — a silent cross-language byte
|
|
132
|
+
divergence. canon forbids it in both (vector `reject-lone-surrogate`). Surrogate rejection is a
|
|
133
|
+
validity rule and STAYS in canon v2 (it is not NFC).
|
|
134
|
+
1. **canon v2 does NOT normalize NFC.** Strings reaching canon MUST already be NFC. NFC is applied
|
|
135
|
+
once at the **ingestion boundary** (`py/canon_ingest.py.normalize_string` / `normalize_tree`),
|
|
136
|
+
before any value reaches canon — see the boundary note below. (canon v1 normalized here; v2
|
|
137
|
+
relocated it to escape the in-hash Unicode-version pin.)
|
|
138
|
+
2. Emit as UTF-8.
|
|
139
|
+
3. **Escaping — escape only what is required:**
|
|
140
|
+
- `"` -> `\"`
|
|
141
|
+
- `\` -> `\\`
|
|
142
|
+
- every control character U+0000–U+001F -> `\uXXXX` with **lowercase** hex (e.g. newline U+000A
|
|
143
|
+
is emitted as the six-character sequence backslash-u-0-0-0-a, NOT `\n`). There are **no**
|
|
144
|
+
short escapes (`\n`, `\t`, `\b`, `\f`, `\r` are NOT produced).
|
|
145
|
+
- every other character, including all non-ASCII (é, 日本語, emoji, astral), is emitted as
|
|
146
|
+
**raw UTF-8** — never `\uXXXX`. This is the `ensure_ascii=False` equivalent. U+007F (DEL)
|
|
147
|
+
and other non-control characters are emitted raw; only U+0000–U+001F are escaped.
|
|
148
|
+
|
|
149
|
+
String output is `"` + escaped-contents + `"`.
|
|
150
|
+
|
|
151
|
+
> **Unicode-database pin (NFC determinism) — RELOCATED to the ingestion boundary (canon v2).** NFC
|
|
152
|
+
> is defined against a Unicode version, so wherever it is applied the implementations must use the
|
|
153
|
+
> SAME Unicode DB or NFC can diverge for codepoints assigned after the older DB. In canon **v1** NFC
|
|
154
|
+
> ran *inside* the hash, so `canonical_bytes` depended on the pinned Unicode DB. **canon v2 performs
|
|
155
|
+
> no NFC and depends on no Unicode DB version** — the pin moved to the ingest boundary
|
|
156
|
+
> (`py/canon_ingest.py`), which **pins Python to the `unicodedata2==16.0.0` backport** (NOT stdlib
|
|
157
|
+
> `unicodedata`, which is bound to the host CPython build — 13.0.0 on CPython 3.10) and **asserts at
|
|
158
|
+
> import time, fail-closed**, that its Unicode version reduces to `16.0`. All production ingest is
|
|
159
|
+
> Python; the TypeScript canon port is the conformance reference and needs no ingest normalizer.
|
|
160
|
+
> `conformance/check.sh` installs the pin and runs (a) canon self-conformance + cross-language
|
|
161
|
+
> byte-identity, and (b) the ingest-boundary tests — `test_ingest_nfc.py` (feed NON-NFC input through
|
|
162
|
+
> ingest → canon and assert the normalized form) and `test_unicode_skew.py` (the fail-closed assertion
|
|
163
|
+
> must fire on a simulated stale DB; the `post13-*` inputs must byte-diverge between a Unicode-13 DB
|
|
164
|
+
> and the pinned Unicode-16 DB). The ingest tests are load-bearing precisely because the byte-identity
|
|
165
|
+
> gate **cannot** catch a missing/wrong pin on its own: canon v2 passes bytes through verbatim, so
|
|
166
|
+
> both ports stay byte-identical even if ingest NFC were absent. The `post13-*` inputs exercise exactly
|
|
167
|
+
> the codepoints (U+0C3C ccc=7, U+0897 ccc=230, U+1715 ccc=9) that would diverge under the old skew.
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
## 6. Numbers — integers only
|
|
172
|
+
|
|
173
|
+
- **Integers only.** Emitted as the shortest decimal representation: optional leading `-`, then
|
|
174
|
+
digits with no leading zeros (except `0` itself), no `+`, no exponent, no decimal point, no
|
|
175
|
+
trailing `.0`. So both `1` and a hypothetical `1.0` input would have to be the *integer* `1`;
|
|
176
|
+
there is no `1.0` form because floats are rejected.
|
|
177
|
+
- **Floats, `NaN`, `+Infinity`, `-Infinity` are REJECTED** with a clear error (`allow_nan=false`
|
|
178
|
+
and more — no real numbers at all). The caller pre-represents decimals as strings or scaled
|
|
179
|
+
integers.
|
|
180
|
+
- Arbitrary precision: integers beyond 2^53 are supported. Python uses native `int`. TypeScript
|
|
181
|
+
uses `bigint` for emission so large integers do not lose precision; the `$int` decoder produces
|
|
182
|
+
a `bigint`, and the encoder accepts both `bigint` and a safe-range `number` that is an integer.
|
|
183
|
+
A non-integer `number` in TS (e.g. `1.5`) is treated as a float and REJECTED.
|
|
184
|
+
|
|
185
|
+
`true` / `false` / `null` are lowercase literals.
|
|
186
|
+
|
|
187
|
+
> **Python boolean trap (load-bearing):** `isinstance(True, int)` is `True` in Python, so the
|
|
188
|
+
> type dispatch MUST test `bool` **before** `int`, or `true` would serialize as `1`. JS has no
|
|
189
|
+
> such overlap. The `boolean-value` vector guards this asymmetry.
|
|
190
|
+
|
|
191
|
+
---
|
|
192
|
+
|
|
193
|
+
## 7. Structural separators
|
|
194
|
+
|
|
195
|
+
- Object: `{` + entries joined by `,` + `}`, each entry is `key-string` + `:` + value.
|
|
196
|
+
- Array: `[` + elements joined by `,` + `]`.
|
|
197
|
+
- **No insignificant whitespace anywhere** — no spaces after `:` or `,`, no newlines, no indent.
|
|
198
|
+
- Empty object is `{}`, empty array is `[]`.
|
|
199
|
+
|
|
200
|
+
---
|
|
201
|
+
|
|
202
|
+
## 8. Digest — domain-separated SHA-256
|
|
203
|
+
|
|
204
|
+
```
|
|
205
|
+
digest(value, profile) = lowercase_hex( SHA-256( domain_prefix(profile) || canonical_bytes(value) ) )
|
|
206
|
+
domain_prefix(profile) = ASCII("spec-canon:v2:" + profile + "\n")
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
- The `\n` is a single ASCII line feed (0x0a). The prefix is pure ASCII bytes, prepended to the
|
|
210
|
+
canonical UTF-8 bytes **before** hashing. Domain separation prevents a digest under one profile
|
|
211
|
+
from ever colliding with another profile's digest of the same content.
|
|
212
|
+
- **The `v2` literal in the prefix is the canon-version domain separator.** Because the prefix is
|
|
213
|
+
part of the digest preimage, a `spec-canon:v2:` digest can **never** collide with a `spec-canon:v1:`
|
|
214
|
+
digest of the same input — even for already-NFC content where canon v1 and v2 emit identical
|
|
215
|
+
`canonical_bytes`. This is what makes the v1→v2 boundary observable and the historical-cert policy
|
|
216
|
+
(§9) well-defined.
|
|
217
|
+
- **Digest profiles (the verbatim v1 contract set):**
|
|
218
|
+
- `semantic-content` — the meaning-bearing content of an artifact.
|
|
219
|
+
- `run` — a run record (kept separate from semantic content).
|
|
220
|
+
- `artifact-byte` — the byte identity of a stored artifact.
|
|
221
|
+
- `certificate` — a parity certificate.
|
|
222
|
+
- Hex is **lowercase**.
|
|
223
|
+
- **Digest-field exclusion:** if the top-level value is an object containing a key named `digest`,
|
|
224
|
+
that key/value pair is removed before canonicalization so a record can carry its own digest
|
|
225
|
+
without the digest depending on itself. Exclusion is **top-level only** (a nested object's
|
|
226
|
+
`digest` key is ordinary content). This is documented and tested (`digest-field-excluded`).
|
|
227
|
+
- **Preimage caveat (adopters read this):** exclusion happens **only in `digest()`**, not in
|
|
228
|
+
`canonical_bytes()`. So for a digest-bearing record, `canonical_bytes(record)` is **NOT** the
|
|
229
|
+
digest preimage — the preimage is `canonical_bytes(record_without_top_level_digest)`. The
|
|
230
|
+
`digest-field-excluded` vector reflects this: its stored `expected_canonical_bytes_b64`
|
|
231
|
+
*includes* the `digest` key, while its `expected_digest_hex` is computed over the stripped
|
|
232
|
+
record (and equals `digest-field-excluded-baseline`). Do not expect
|
|
233
|
+
`sha256(prefix || stored_bytes) == stored_digest` for such a record.
|
|
234
|
+
|
|
235
|
+
### Non-hashed `locator` envelope
|
|
236
|
+
|
|
237
|
+
`locator` is **not** a digest profile — it is the documented home for volatile / provenance data
|
|
238
|
+
that MUST NEVER enter canonical content: wall-clock timestamps, absolute filesystem paths,
|
|
239
|
+
locale-dependent collation, hostnames, PIDs, ephemeral run ids. See §10.
|
|
240
|
+
|
|
241
|
+
---
|
|
242
|
+
|
|
243
|
+
## 9. Produce once, store verbatim, never recompute
|
|
244
|
+
|
|
245
|
+
A digest and its canonical bytes are produced **once**, at authorship, and stored verbatim.
|
|
246
|
+
Consumers compare the **stored** bytes/digest; **any consumer that re-serializes a value to
|
|
247
|
+
compare it is a bug.** The public API is intentionally just `canonical_bytes(value)` and
|
|
248
|
+
`digest(value, profile)` — there is no "re-canonicalize and diff" helper, by design.
|
|
249
|
+
|
|
250
|
+
### Historical v1 certificates (canon v1→v2 policy — NORMATIVE)
|
|
251
|
+
|
|
252
|
+
The canon v1→v2 boundary is a **certificate-version boundary**. The policy (chosen for NFCBOUNDARY):
|
|
253
|
+
|
|
254
|
+
- **A v1 certificate stays valid *as a v1 record*.** It is identified by `canon_version = "v1"` and a
|
|
255
|
+
`spec-canon:v1:` digest domain. v2 is the new authoring default (`canon_version = "v2"`,
|
|
256
|
+
`spec-canon:v2:`); v1 artifacts are read-only history.
|
|
257
|
+
- **A v1 digest is NEVER compared against a v2 digest.** Per "produce once, store verbatim, never
|
|
258
|
+
recompute" above, a stored v1 digest is never re-serialized under v2 (and the `v2` prefix makes the
|
|
259
|
+
two domains provably disjoint, §8). Consumers branch on `canon_version`.
|
|
260
|
+
- **A mixed-`canon_version` graph is REJECTED at the consumer** (single-`canon_version` invariant) —
|
|
261
|
+
never silently re-hashed to reconcile the two.
|
|
262
|
+
|
|
263
|
+
Downstream certificate consumers cut over to v2 **wholesale**;
|
|
264
|
+
the v1→v2 boundary is a metadata-only notification to them, not an in-place re-hash.
|
|
265
|
+
|
|
266
|
+
---
|
|
267
|
+
|
|
268
|
+
## 10. Splitting content from envelope (volatile data)
|
|
269
|
+
|
|
270
|
+
Volatile/provenance data must live outside hashed content. The reference impls provide a
|
|
271
|
+
documented helper:
|
|
272
|
+
|
|
273
|
+
```
|
|
274
|
+
split_record(record, content_keys) -> (content, envelope)
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
`content` holds only the keys in `content_keys` (hashed via `canonical_bytes` / `digest`);
|
|
278
|
+
`envelope` holds everything else (the non-hashed locator: wall-clock, abs paths, locale, host,
|
|
279
|
+
pid, etc.). Changing envelope fields MUST NOT change the digest — proven by the
|
|
280
|
+
`content-envelope-split` vector, where the same content with two different envelopes yields the
|
|
281
|
+
identical `semantic-content` digest.
|
|
282
|
+
|
|
283
|
+
---
|
|
284
|
+
|
|
285
|
+
## 11. Conformance and the exit gate
|
|
286
|
+
|
|
287
|
+
`vectors/canon-vectors.json` is an array of:
|
|
288
|
+
|
|
289
|
+
```json
|
|
290
|
+
{
|
|
291
|
+
"name": "...",
|
|
292
|
+
"input": <type-tagged tree, or {"$error": "..."} for reject cases>,
|
|
293
|
+
"profile": "semantic-content | run | artifact-byte | certificate",
|
|
294
|
+
"expected_canonical_bytes_b64": "<base64 of the canonical UTF-8 bytes>",
|
|
295
|
+
"expected_digest_hex": "<lowercase sha-256 hex>"
|
|
296
|
+
}
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
Reject vectors (float/NaN/Inf) carry `"expect_error": true` instead of expected bytes/digest;
|
|
300
|
+
both ports MUST raise.
|
|
301
|
+
|
|
302
|
+
The expected values are **generated by the Python reference impl** (never hand-authored base64/hex)
|
|
303
|
+
and pinned in the file. The TS impl must reproduce them.
|
|
304
|
+
|
|
305
|
+
**EXIT GATE** — `bash conformance/check.sh` (canon v2):
|
|
306
|
+
0. installs the ingest-boundary Unicode pin (`unicodedata2`), then
|
|
307
|
+
1. runs `py/test_canon.py` (Python canon vs the pinned vectors), and
|
|
308
|
+
2. runs `ts/canon.test.ts` (TS canon vs the **same** pinned vectors), and
|
|
309
|
+
3. runs the **ingest-boundary** tests — `conformance/test_ingest_nfc.py` (NON-NFC input through
|
|
310
|
+
ingest → canon yields the normalized form) and `conformance/test_unicode_skew.py` (the fail-closed
|
|
311
|
+
pin assertion fires on a stale DB; the `post13-*` inputs are live U13-vs-U16 discriminators), and
|
|
312
|
+
4. asserts Python and TS canon produced byte-identical canonical output and identical digests on
|
|
313
|
+
**every** vector.
|
|
314
|
+
|
|
315
|
+
Step 3 is load-bearing and not redundant: canon v2 emits bytes verbatim, so the cross-language
|
|
316
|
+
byte-identity check (step 4) would stay green even if ingest NFC were missing entirely — only the
|
|
317
|
+
ingest tests prove NFC enforcement actually lives at the boundary and that its pin is load-bearing.
|
|
318
|
+
|
|
319
|
+
If any vector cannot be made byte-identical across languages, that is a contract defect to be
|
|
320
|
+
reported and fixed — never papered over.
|
|
321
|
+
|
|
322
|
+
## 12. canon-core API freeze (IF-0-XG4-1)
|
|
323
|
+
|
|
324
|
+
`canon/core` is the single portable implementation of the canon v2 algorithm. The frozen core
|
|
325
|
+
surface is:
|
|
326
|
+
|
|
327
|
+
- `canonical_bytes(value) -> bytes`
|
|
328
|
+
- `digest(value, profile) -> lowercase sha256 hex`
|
|
329
|
+
- `canonical_bytes_from_json(tagged_json) -> bytes`
|
|
330
|
+
- `digest_from_json(tagged_json, profile) -> lowercase sha256 hex`
|
|
331
|
+
|
|
332
|
+
The JSON entrypoints consume the same type-tagged tree used by `vectors/canon-vectors.json`.
|
|
333
|
+
The Rust core keeps NFC out of the hash exactly like the Python and TypeScript references: callers
|
|
334
|
+
deliver already-normalized content from the ingest boundary, and `digest` applies
|
|
335
|
+
`spec-canon:v2:<profile>\n || canonical_bytes(strip_top_level_digest(value))`.
|
|
336
|
+
|
|
337
|
+
The binding surfaces are thin wrappers over that core:
|
|
338
|
+
|
|
339
|
+
- WASM exports `canonicalBytesFromJson` and `digestFromJson`.
|
|
340
|
+
- PyO3 exports `canonical_bytes_from_json` and `digest_from_json` from module `canon_core`.
|
|
341
|
+
|
|
342
|
+
`conformance/check_xg4_canon_core.sh` is the XG4 exit gate. It runs the existing Python/TypeScript
|
|
343
|
+
canon gate, runs Rust vector tests, diffs Python/TypeScript/Rust emitted bytes and digests over the
|
|
344
|
+
full corpus, and compiles both binding surfaces. Consumers must dual-run against this corpus before
|
|
345
|
+
removing a vendored implementation.
|
|
346
|
+
|
|
347
|
+
## Open items (0A follow-ups, tracked)
|
|
348
|
+
- **In-hash Unicode-version coupling. — RESOLVED by canon v2 / NFCBOUNDARY (2026-06).** Even after the
|
|
349
|
+
0A pin below, canon v1's `canonical_bytes` *itself* still depended on the pinned Unicode DB (NFC ran
|
|
350
|
+
in-hash), so the byte-identity gate stayed Unicode-version-coupled. **canon v2 relocates NFC to the
|
|
351
|
+
ingestion boundary** (`py/canon_ingest.py`): canon no longer normalizes and no longer depends on any
|
|
352
|
+
Unicode DB; the `unicodedata2==16.0.0` pin + fail-closed assertion + the post-13 discriminators moved
|
|
353
|
+
to the ingest tests, and the digest prefix bumped `v1`→`v2` (§8). The pin **relocated, it did not
|
|
354
|
+
vanish** — a deterministic Unicode DB is still required at ingest for cross-language NFC. See §5, §8,
|
|
355
|
+
§9 (historical-cert policy).
|
|
356
|
+
- **`idmodel` boundary-id NFC (follow-on, NOT addressed here).** Removing canon's in-hash NFC *unmasks*
|
|
357
|
+
`idmodel`'s own NFC: `idmodel/py/idmodel.py` normalizes occurrence-id content with **stdlib**
|
|
358
|
+
`unicodedata` (host-CPython Unicode version), and under v1 canon (unicodedata2 16.0) re-normalized that
|
|
359
|
+
content so canon was authoritative. Under v2 canon no longer re-NFCs, so idmodel's stdlib NFC becomes
|
|
360
|
+
authoritative for occurrence-ids — a possibly-unpinned Unicode dependency. All committed idmodel content
|
|
361
|
+
is NFC-invariant (verified), so no current artifact is affected; harmonizing idmodel onto the same
|
|
362
|
+
pinned ingest normalizer is a named follow-on, out of NFCBOUNDARY's "NFC placement only" scope.
|
|
363
|
+
- **Unicode DB version skew (0A). — RESOLVED (2026-06).** Previously: Python stdlib `unicodedata` (13.0.0 on
|
|
364
|
+
CPython 3.10) vs Node Unicode 16.0, leaving a determinism hole for identifiers using codepoints assigned
|
|
365
|
+
*after* Unicode 13 (their NFC could differ between ports). Resolution (canon v1; the pin now lives at the
|
|
366
|
+
v2 ingest boundary):
|
|
367
|
+
- **Pinned Unicode version: 16.0** (matches Node's `process.versions.unicode`).
|
|
368
|
+
- **Python NFC now uses the `unicodedata2==16.0.0` backport** (exact pin in `py/requirements.txt`), NOT
|
|
369
|
+
stdlib `unicodedata`. `unicodedata2.unidata_version` is `16.0.0` → reduces to `16.0`, byte-identical NFC
|
|
370
|
+
to Node ICU 16.0 (confirmed on every vector by the cross-language gate).
|
|
371
|
+
- **Fail-closed version assertion at import time** in BOTH ports (`canon.py` and `canon.ts`): the Unicode
|
|
372
|
+
version they use must reduce to `16.0` or they refuse to load — a mismatch can never silently diverge.
|
|
373
|
+
- **Post-13 conformance vectors** added (`post13-nfc-reorder-telugu-nukta`, `…-arabic-pepet`,
|
|
374
|
+
`…-two-new-marks`) using combining marks assigned in Unicode 15.0/16.0 (U+0C3C ccc=7, U+0897 ccc=230,
|
|
375
|
+
U+1715 ccc=9). Their NFC canonical-reordering differs under a Unicode-13 DB (those marks read as ccc=0)
|
|
376
|
+
vs the pinned Unicode-16 DB — verified byte-divergent by `conformance/test_unicode_skew.py`. They pass
|
|
377
|
+
now because both ports use 16.0.
|
|
378
|
+
- **Gate enforcement:** `conformance/check.sh` installs the pin, hard-asserts both ports report `16.0`, runs
|
|
379
|
+
the negative tests (simulated stale DB must trip the assertion; post-13 vectors must byte-diverge under a
|
|
380
|
+
U13 DB), then runs the cross-language byte-identity check over all vectors.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# canon-core PyO3 binding
|
|
2
|
+
|
|
3
|
+
The canonical algorithm lives in `canon/core`. Build the PyO3 surface with:
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
cargo build --manifest-path canon/core/Cargo.toml --features pyo3-binding
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
The Python module is named `canon_core` and exposes:
|
|
10
|
+
|
|
11
|
+
- `canonical_bytes_from_json(tagged_json: str) -> bytes`
|
|
12
|
+
- `digest_from_json(tagged_json: str, profile: str) -> str`
|
|
13
|
+
|
|
14
|
+
Inputs are the existing type-tagged vector JSON shape used by `canon/vectors/canon-vectors.json`.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# canon-core WASM binding
|
|
2
|
+
|
|
3
|
+
The canonical algorithm lives in `canon/core`. Build the WASM surface with:
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
cargo build --manifest-path canon/core/Cargo.toml --features wasm-binding --target wasm32-unknown-unknown
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
The exported JS names are:
|
|
10
|
+
|
|
11
|
+
- `canonicalBytesFromJson(taggedJson: string): Uint8Array`
|
|
12
|
+
- `digestFromJson(taggedJson: string, profile: string): string`
|
|
13
|
+
|
|
14
|
+
Inputs are the existing type-tagged vector JSON shape used by `canon/vectors/canon-vectors.json`.
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# canon v2 EXIT GATE (THE fleet gate, SPEC.md section 11).
|
|
4
|
+
#
|
|
5
|
+
# canon v2 relocated mandatory Unicode NFC OUT of the hash to the ingestion boundary
|
|
6
|
+
# (canon/py/ingest.py), so canon itself is Unicode-DB-independent. The gate asserts:
|
|
7
|
+
# 1. Python canon passes its own conformance (verbatim bytes + digest match the pinned vectors).
|
|
8
|
+
# 2. TypeScript canon passes its own conformance (same pinned vectors).
|
|
9
|
+
# 3. INGEST-boundary NFC is enforced and load-bearing: non-NFC input is normalized at ingest
|
|
10
|
+
# (test_ingest_nfc.py), and the pinned Unicode DB / post-13 skew discriminator is live and the
|
|
11
|
+
# fail-closed version assertion fires (test_unicode_skew.py). This is what the byte-identity
|
|
12
|
+
# check below STRUCTURALLY CANNOT prove — canon v2 passes bytes through verbatim, so both ports
|
|
13
|
+
# stay byte-identical even if ingest NFC were missing entirely.
|
|
14
|
+
# 4. Python and TypeScript canon produce BYTE-IDENTICAL output + IDENTICAL digests on every vector
|
|
15
|
+
# (the cross-language byte-identity gate).
|
|
16
|
+
#
|
|
17
|
+
# Exit 0 = gate green. Any divergence -> non-zero with a diff. No fudging.
|
|
18
|
+
|
|
19
|
+
set -euo pipefail
|
|
20
|
+
|
|
21
|
+
CANON_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
22
|
+
PY="$CANON_DIR/py"
|
|
23
|
+
TS="$CANON_DIR/ts"
|
|
24
|
+
TMP="$(mktemp -d)"
|
|
25
|
+
trap 'rm -rf "$TMP"' EXIT
|
|
26
|
+
|
|
27
|
+
echo "== canon v2 conformance gate =="
|
|
28
|
+
echo "canon dir: $CANON_DIR"
|
|
29
|
+
echo
|
|
30
|
+
|
|
31
|
+
# --- 0. Pinned Unicode DB for the INGEST boundary (fail-closed) ---
|
|
32
|
+
# canon v2 no longer needs a Unicode DB, but the INGEST boundary (canon/py/ingest.py) does — that is
|
|
33
|
+
# where NFC now lives, pinned to Node's Unicode version. Install the exact pin so the ingest tests in
|
|
34
|
+
# step 3 are reproducible in a clean CI (not just where it happens to be present).
|
|
35
|
+
echo "[0/4] Pinned Unicode DB for the ingest boundary (unicodedata2)"
|
|
36
|
+
python3 -m pip install --quiet --disable-pip-version-check -r "$PY/requirements.txt"
|
|
37
|
+
python3 -c "import unicodedata2; print(' unicodedata2', unicodedata2.unidata_version)"
|
|
38
|
+
echo
|
|
39
|
+
|
|
40
|
+
# --- 1. Python canon self-conformance ---
|
|
41
|
+
echo "[1/4] Python canon conformance (vs pinned vectors)"
|
|
42
|
+
python3 "$PY/test_canon.py"
|
|
43
|
+
echo
|
|
44
|
+
|
|
45
|
+
# --- 2. TypeScript canon self-conformance ---
|
|
46
|
+
echo "[2/4] TypeScript canon conformance (vs pinned vectors)"
|
|
47
|
+
npx --yes tsx "$TS/canon.test.ts"
|
|
48
|
+
echo
|
|
49
|
+
|
|
50
|
+
# --- 3. Ingest-boundary NFC enforcement (the byte-identity gate CANNOT prove this) ---
|
|
51
|
+
# canon v2 emits bytes verbatim, so step 4 would stay green even if ingest NFC vanished. These two
|
|
52
|
+
# tests are the ONLY proof that NFC enforcement actually lives at the ingest boundary and that its
|
|
53
|
+
# pinned Unicode DB is load-bearing: test_ingest_nfc feeds NON-NFC input through ingest->canon and
|
|
54
|
+
# asserts the normalized form; test_unicode_skew proves the fail-closed pin assertion fires on a
|
|
55
|
+
# stale DB and that the post-13 inputs are live U13-vs-U16 discriminators.
|
|
56
|
+
echo "[3/4] Ingest-boundary NFC enforcement + pinned-DB skew proof"
|
|
57
|
+
python3 "$CANON_DIR/conformance/test_ingest_nfc.py"
|
|
58
|
+
python3 "$CANON_DIR/conformance/test_unicode_skew.py"
|
|
59
|
+
echo
|
|
60
|
+
|
|
61
|
+
# --- 4. Cross-language byte-identity ---
|
|
62
|
+
echo "[4/4] Cross-language byte-identity (Python emit vs TypeScript emit)"
|
|
63
|
+
python3 "$PY/test_canon.py" --emit > "$TMP/py.json"
|
|
64
|
+
npx --yes tsx "$TS/canon.test.ts" --emit > "$TMP/ts.json"
|
|
65
|
+
|
|
66
|
+
if diff -u "$TMP/py.json" "$TMP/ts.json" > "$TMP/diff.txt"; then
|
|
67
|
+
N=$(wc -l < "$TMP/py.json" | tr -d ' ')
|
|
68
|
+
echo " Python and TypeScript agree byte-for-byte on all $N vectors."
|
|
69
|
+
echo
|
|
70
|
+
echo "CANON v2 CONFORMANCE GATE: PASS"
|
|
71
|
+
exit 0
|
|
72
|
+
else
|
|
73
|
+
echo " DIVERGENCE between Python and TypeScript:"
|
|
74
|
+
cat "$TMP/diff.txt"
|
|
75
|
+
echo
|
|
76
|
+
echo "CANON v2 CONFORMANCE GATE: FAIL"
|
|
77
|
+
exit 1
|
|
78
|
+
fi
|