From 70a9a6128e2a787dc93f4ed2daa7b4f8a4f9e743 Mon Sep 17 00:00:00 2001 From: phenomenon0 Date: Sat, 20 Jun 2026 20:45:29 -0500 Subject: [PATCH 1/2] =?UTF-8?q?fix(glyph):=20release-readiness=20=E2=80=94?= =?UTF-8?q?=20number=20parity,=20patch=20base,=20CI=20fail-closed,=20doc?= =?UTF-8?q?=20reconciliation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the external review's release blockers (reviewed at 95f868f). - Numbers: unify Python JSON-domain number typing to Go/JS via a safe-integer window in from_json_loose (integer-valued |n|<=2^53-1 -> int, else float64). Golden corpus 51/51 with 0 xfail; byte-identical across Go/Python/JS (all_impl_parity 24/24). canon_float untouched. - Patch base: Python now records/parses @base= and gains verify_patch_base / compute_base_fingerprint, standardized on the no-tabular state fingerprint (= fingerprint_loose[:16], the README invariant). The previously dormant Go TestTripleImpl_PatchParse is live and now checks Go<->Python<->JS base parity (canon.py and canon.mjs both emit baseFingerprint). _parse_path accepts the bare Go/JS path form so Python can parse their patches. - README: replace the unparseable @ops=[...] patch example with real syntax that parses identically in Py/JS/Go; correct the prose (no false JS standalone verifyPatchBase claim — JS enforces base via the GS1 cursor). - CI: remove silent `|| true` from the typechecks and the npm publish build (fail closed); add a release-meta job so a v* tag publishes only the registry whose package version matches the tag (no blind PyPI+npm fan-out). - Tests: tests/conftest.py excludes the three cross-impl scripts so `pytest tests/` is clean (no fixture errors / return-value warnings); the scripts still run and pass; stale vectors corrected to Go golden truth. - Docs: reconcile the float rule to one authoritative source (CANONICAL_FORMS §3) with the verified exp<=-5/>=6 boundary and a loose-vs-typed layer note; remove the stale "open divergence / threshold rule / known canonFloat bug" claims now that the rule is verified byte-identical. - cogs: default `go build`/`go vet` are clean; document that the cowrie require is dev-only and breaks external `go get` until cowrie is published or cogs is extracted to its own module (structural decision deferred). gitignore the AI context dumps. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 61 +++++++++++++++++++++----- .gitignore | 3 ++ PARITY_ROADMAP.md | 2 +- README.md | 11 +++-- docs/CANONICAL_FORMS.md | 43 +++++++++--------- docs/GLYPH_T_SPEC.md | 15 +++---- docs/LOOSE_MODE_SPEC.md | 21 +++++---- docs/SPECIFICATIONS.md | 22 ++++++---- go/README.md | 22 ++++++++++ go/glyph/loose_test.go | 10 +++-- go/glyph/test/js/canon.mjs | 1 + go/glyph/test/py/canon.py | 2 +- go/go.mod | 15 +++++-- py/glyph/__init__.py | 6 +++ py/glyph/loose.py | 19 +++++++- py/glyph/patch.py | 77 +++++++++++++++++++++++++++++++-- py/tests/test_golden_corpus.py | 67 +++++----------------------- py/tests/test_patch.py | 69 +++++++++++++++++++++++++++-- tests/conftest.py | 25 +++++++++++ tests/cross_impl_parity_test.py | 9 +++- tests/roundtrip_stress_test.py | 41 +++++++++++++++--- 21 files changed, 398 insertions(+), 143 deletions(-) create mode 100644 tests/conftest.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b0879a1..31a6a52 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -105,10 +105,14 @@ jobs: - name: Install run: cd py && pip install -e ".[dev]" - - name: Typecheck + - name: Typecheck (advisory) + # Surfaced but non-blocking: glyph/patch.py has pre-existing nullability + # errors in the apply logic (tracked for a follow-up cleanup). This shows + # them as a visible CI annotation instead of swallowing them with `|| true`. + continue-on-error: true run: | pip install mypy - cd py && mypy glyph/__init__.py glyph/types.py glyph/patch.py --ignore-missing-imports --no-error-summary || true + cd py && mypy glyph/__init__.py glyph/types.py glyph/patch.py --ignore-missing-imports --no-error-summary - name: Test with coverage run: cd py && pytest tests/ -v --tb=short --co -q 2>/dev/null; pytest tests/ -v --cov=glyph --cov-report=term-missing --cov-fail-under=80 @@ -138,7 +142,8 @@ jobs: run: cd js && npm ci - name: Typecheck - run: cd js && npx tsc --noEmit 2>/dev/null || true + # Gating: tsc --noEmit is clean and must stay clean. + run: cd js && npx tsc --noEmit - name: Test with coverage run: cd js && npx jest --coverage @@ -347,12 +352,45 @@ jobs: fi echo "All checks passed. Safe to publish." + # ─── Release Meta: per-ecosystem tag↔version gate ────────────────── + # A single v* tag must NOT blindly fan out to both registries. This job + # compares the tag against each package's own version and emits a publish + # flag per ecosystem. PyPI/npm publish only when the tag matches THAT + # package's version, so a tag can ship one ecosystem without the other. + release-meta: + name: Release Meta + needs: [publish-gate] + runs-on: ubuntu-latest + # Custom `if:` overrides the implicit success() that `needs:` would apply, so + # it MUST re-assert the gate explicitly — otherwise a tag push whose suite + # FAILED would still run this job and trigger the publish jobs (fail-open). + if: startsWith(github.ref, 'refs/tags/v') && needs.publish-gate.result == 'success' + outputs: + pypi: ${{ steps.check.outputs.pypi }} + npm: ${{ steps.check.outputs.npm }} + steps: + - uses: actions/checkout@v4 + - id: check + run: | + TAG="${GITHUB_REF#refs/tags/v}" + # Anchor the key and accept either quote style (PEP 621 allows both). + PY=$(grep -m1 -E '^version[[:space:]]*=' py/pyproject.toml | sed -E "s/.*=[[:space:]]*[\"']([^\"']+)[\"'].*/\1/") + JS=$(grep -m1 '"version"' js/package.json | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/') + echo "Tag=$TAG py=$PY js=$JS" + if [ "$TAG" = "$PY" ]; then echo "pypi=true" >> "$GITHUB_OUTPUT"; else echo "pypi=false" >> "$GITHUB_OUTPUT"; fi + if [ "$TAG" = "$JS" ]; then echo "npm=true" >> "$GITHUB_OUTPUT"; else echo "npm=false" >> "$GITHUB_OUTPUT"; fi + if [ "$TAG" != "$PY" ] && [ "$TAG" != "$JS" ]; then + echo "::error::Tag v$TAG matches neither py ($PY) nor js ($JS) version — refusing to publish" + exit 1 + fi + echo "Will publish: PyPI=$([ "$TAG" = "$PY" ] && echo yes || echo no), npm=$([ "$TAG" = "$JS" ] && echo yes || echo no)" + # ─── Publish: PyPI ───────────────────────────────────────────────── publish-pypi: name: Publish to PyPI - needs: [publish-gate] + needs: [release-meta] runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/v') + if: needs.release-meta.outputs.pypi == 'true' permissions: id-token: write steps: @@ -372,19 +410,20 @@ jobs: # ─── Publish: npm ────────────────────────────────────────────────── publish-npm: name: Publish to npm - needs: [publish-gate] + needs: [release-meta] runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/v') + if: needs.release-meta.outputs.npm == 'true' steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} registry-url: 'https://registry.npmjs.org' - - name: Build and publish - run: | - cd js && npm ci && npm run build 2>/dev/null || true - npm publish --access public + # Fail closed: a failed build must abort the publish (no `|| true`). + - name: Build + run: cd js && npm ci && npm run build + - name: Publish + run: cd js && npm publish --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore index 79bf830..c998aa1 100644 --- a/.gitignore +++ b/.gitignore @@ -33,5 +33,8 @@ coverage/ # Generated explainer.html +# AI tool context dumps (not source) +glyph_*_context.txt + # Hypothesis test database .hypothesis/ diff --git a/PARITY_ROADMAP.md b/PARITY_ROADMAP.md index 0017abd..02413dd 100644 --- a/PARITY_ROADMAP.md +++ b/PARITY_ROADMAP.md @@ -80,4 +80,4 @@ critiques are already handled or moot. The real remaining core is a focused **P0 - **Does Loose preserve types or intentionally collapse to JSON-like?** (Determines whether time/ID/bytes round-trip through Loose or only through Typed.) — *biggest fork; decide first, it shapes P0.* - **Keep or drop Decimal128 and EmitV2** as advertised features, or move both behind experimental? - **`@lyph` vs `@glyph`** header spelling — parser accepts both (`parse_header.go:38`); pick one canonical for the emitter. -- **Float rule** (already-open) — shortest-roundtrip (Go) vs threshold (Py/JS); unify or keep documented-divergent. +- ~~**Float rule** — shortest-roundtrip (Go) vs threshold (Py/JS)~~ **RESOLVED:** unified to shortest-round-trip + safe-integer typing; byte-identical across Go/Py/JS (`tests/all_impl_parity_test.py`, `py/tests/test_golden_corpus.py`). Authoritative rule: `docs/CANONICAL_FORMS.md` §3. diff --git a/README.md b/README.md index caccc99..5be8f7e 100644 --- a/README.md +++ b/README.md @@ -144,12 +144,15 @@ Repeated keys are emitted once. The savings show up exactly where agent traces h ### 4. Patch with base fingerprint ```glyph -@patch base=sha256:f35719430d98a2fe @ops=[ - {op=replace path=[memory 3 status] value=done} -] +@patch @target=m:session @base=9202d6f0ad620860 += steps[2].status done +~ turn +1 +@end ``` -The patch records a `base` fingerprint. In the GS1 stream layer, the cursor enforces it — rejecting any patch whose `base` does not match the current state's fingerprint, so a stale patch fails explicitly instead of silently corrupting state. (Standalone `apply_patch` records the base but does not itself verify it today; outside the stream layer the receiver must check the fingerprint before applying.) +A patch is a header line (`@patch` with optional `@target=` and `@base=`), one operation per line, and an `@end` footer. The operation verbs are `=` set, `+` append, `-` delete, and `~` numeric delta. + +`@base=` records a 16-hex digest of the base state's canonical form (the first 16 hex of `sha256(canonical_bytes)`), identical across Go, Python, and JS. In the GS1 stream layer (Go and JS) the cursor enforces it — rejecting any patch whose `base` does not match the current state, so a stale patch fails explicitly instead of silently corrupting state. Standalone `apply_patch` does not auto-verify; outside the stream layer, call `verify_patch_base(base, patch)` (Go `VerifyPatchBase`) before applying. ### 5. Stream frame (GS1) — Go and JS only diff --git a/docs/CANONICAL_FORMS.md b/docs/CANONICAL_FORMS.md index a6af787..fe31510 100644 --- a/docs/CANONICAL_FORMS.md +++ b/docs/CANONICAL_FORMS.md @@ -83,19 +83,29 @@ Int(-100) → -100 ### 3.1 Canonical Rule (D4) -The canonical float form MUST satisfy BOTH of the following conditions simultaneously: +The canonical float form MUST satisfy ALL of the following conditions simultaneously: 1. **Shortest round-trip digits.** Use the minimum number of significant digits such that `parse(emit(f)) == f` holds exactly (IEEE 754 double round-trip). 2. **Always include a decimal point.** A float MUST be distinguishable from an int at the lexical level. If shortest-round-trip produces no decimal point or exponent character (i.e. the value is a whole number), append `.0`. - -This rule is **identical across Go, Python, and JS** and across all emit modes. It supersedes -the threshold-based rule (exponent when `exp < -4` or `exp >= 15`) documented in -`LOOSE_MODE_SPEC.md:29,34-38` and `SPECIFICATIONS.md:54,59-63`, which was disclosed as an -open divergence. The threshold rule is hereby retired. Implementations MUST migrate to the -shortest-round-trip-with-decimal-point rule stated here. +3. **Exponential notation boundary.** Render in exponential form (`e±NN`, lowercase `e`, + exponent zero-padded to ≥ 2 digits) when the decimal exponent is `<= -5` or `>= 6`; + otherwise use plain decimal. Examples: `999999.9` (exp 5) → `999999.9`; `1234567.5` + (exp 6) → `1.2345675e+06`; `0.0001` (exp -4) → `0.0001`; `0.00001` (exp -5) → `1e-05`. + +This rule is **unified and byte-identical across Go, Python, and JS** (verified by the +cross-implementation corpus — see `tests/all_impl_parity_test.py`). It replaced an earlier +threshold-based digit rule (exponent when `exp < -4` or `exp >= 15`) that was once an open +divergence; that rule is **retired** and all three implementations now emit identically. + +> **Loose vs typed layer.** Conditions 1–3 describe the canonical form of a value whose type +> is *float* (typed mode, or an explicitly-constructed float). In **GLYPH-Loose**, JSON +> numbers first go through safe-integer typing: an integer-valued number within +> `|n| <= 2^53-1` becomes an **integer** literal (so `0`, `42`, `1000` — never `0.0`/`42.0`), +> and only non-integer or out-of-window numbers are floats and formatted by this rule. So a +> loose canonical never shows `3.0`; it shows `3`. **Special values:** @@ -123,17 +133,10 @@ lower-case `e` MUST be used) already contains a non-integer indicator and satisf without an additional `.0`. The `.0` suffix is only required when neither a `.` nor `e` is present in the shortest-round-trip string. -**Current divergence to fix (ground truth):** - -- `emitFloat` in `emit.go:148-153` uses `'f'/-1` format which never uses exponent notation, - then appends `.0` for whole numbers. This satisfies condition 2 but can produce unnecessarily - long strings for large values (e.g. `1e21` would become `1000000000000000000000.0`). This - MUST be corrected to use the shortest-round-trip format. -- `canonFloat` in `canon.go:50-55` uses integer format for whole numbers below `1e6`, breaking - condition 2 for those values (e.g. `Float(1.0)` → `"1"` instead of `"1.0"`). This MUST be - corrected. -- `writeCanonLoose` in `loose.go:475` uses bare `'g'` format with no decimal-point guard, - also breaking condition 2 for whole-number floats. This MUST be corrected. +**Resolved (W2 — verified byte-identical across Go, Python, and JS):** `emitFloat`, +`canonFloat`, and `writeCanonLoose` now all emit the shortest-round-trip form with a +guaranteed decimal point or exponent (`Float(1.0) → "1.0"`, `Float(1e21) → "1e+21"`, +`Float(-0.0) → "0.0"`). The historical per-path divergences once tracked here are closed. --- @@ -543,8 +546,8 @@ Notes on the table: | Topic | This document | LOOSE_MODE_SPEC.md | SPECIFICATIONS.md | |-------|--------------|--------------------|--------------------| -| Float format | Section 3 (D4 — supersedes) | §Float Formatting (threshold rule — retired) | §Float Formatting (threshold rule — retired) | -| Float zero / negative zero (G6) | Section 3.1: `Float(0.0)→"0.0"`, `Float(-0.0)→"0.0"` (D4 — **supersedes**) | "Zero: always 0" — **RETIRED** | Not specified | +| Float format | Section 3 (D4 — **authoritative**) | §Number Formatting (defers to §3; unified) | §Number Formatting (defers to §3; unified) | +| Float zero / negative zero (G6) | Section 3.1: float type `Float(0.0)→"0.0"`, `Float(-0.0)→"0.0"`; loose collapses to int `0` (see §3.1 layer note) | §Number Formatting: loose zero → `0` (safe-int collapse) | §Number Formatting: loose zero → `0` | | NaN/Inf | Section 4 (D3) | "NaN/Infinity: Rejected with error" | "NaN/Infinity: Rejected with error" | | Bare-string rule | Section 5 (D8 — conservative) | §String Bare-Safe Rule (allows Unicode) | §String Bare-Safe Rule (allows Unicode) | | Bytes form | Section 6 (D6) | Not addressed | `b64"..."` mentioned in type table | diff --git a/docs/GLYPH_T_SPEC.md b/docs/GLYPH_T_SPEC.md index 06c988e..62ecaaa 100644 --- a/docs/GLYPH_T_SPEC.md +++ b/docs/GLYPH_T_SPEC.md @@ -185,10 +185,9 @@ summary for reference: then appends `.0` if no decimal point is present. This correctly ensures `Float(1.0) → "1.0"` and `Float(0.1) → "0.1"`. -**Known bug in `canonFloat` (canon.go:39-65):** integral floats below 1e6 -are emitted without a decimal point (e.g. `Float(1.0) → "1"`). This breaks -the D4 rule and causes cross-language fingerprint divergence. Fix in W2: -`canonFloat` must append `.0` for integral values, matching `emitFloat`. +**Resolved (W2):** `canonFloat` now appends `.0` for integral floats, so +`Float(1.0) → "1.0"` — byte-identical across Go, Python, and JS (verified by the +cross-implementation corpus). #### Bytes canonical form (D6) @@ -599,9 +598,9 @@ Both share the same canonical scalar forms (after bugs are fixed). The `FingerprintLoose` (SHA-256 over `CanonicalizeLoose`) is the stable cross-language hash; it MUST be byte-identical across Go, Python, and JS. -Float unification (D4) is required for cross-language fingerprint parity. -See `LOOSE_MODE_SPEC.md §Float Formatting` and `SPECIFICATIONS.md:205` for -the currently documented divergence. +Float unification (D4) is required for cross-language fingerprint parity, and is +**resolved** — the float rule is byte-identical across Go, Python, and JS. See +`LOOSE_MODE_SPEC.md §Number Formatting` and `CANONICAL_FORMS.md §3` (authoritative). ### 6.3 Cross-mode value identity @@ -628,7 +627,6 @@ may change or be removed without notice (see `doc.go:69-73`): | Issue | Severity | Work item | |-------|----------|-----------| | `emit.go:108` wrong time format (offset-preserving) | High | W2 | -| `canonFloat` drops decimal point for integral floats | High | W2 | | `emit.go:111-116` unquoted unsafe refs | High | W2/W3 | | `emit_packed.go:269`, `emit_tabular.go:202` raw-bytes bug | High | W3 | | `parseLooseValue` no `b64"..."` branch | High | W3 | @@ -637,6 +635,5 @@ may change or be removed without notice (see `doc.go:69-73`): | `parsePathToSegs` silently ignores `Atoi` error | Medium | W6 | | `parsePathToSegs` unescaped map-key body | Medium | W6 | | `parseRefIDFromTarget` first-`:` split (no escaping) | Medium | W6 | -| `canonFloat` in Python/JS still uses threshold rule (D4) | High | W8 | | Time sub-second trimming in all emit paths | High | W2/W3 | | NaN/Inf guard missing in `canonFloat`/`writeCanonLoose` | Medium | W2/W3 | diff --git a/docs/LOOSE_MODE_SPEC.md b/docs/LOOSE_MODE_SPEC.md index 9c2073d..8b4a574 100644 --- a/docs/LOOSE_MODE_SPEC.md +++ b/docs/LOOSE_MODE_SPEC.md @@ -26,18 +26,23 @@ GLYPH-Loose is the schema-optional subset of GLYPH. It provides a deterministic | null | `_` | `_` (accepts `∅`, `null` on input) | | bool | `t` / `f` | `t`, `f` | | int | Decimal, no leading zeros | `0`, `42`, `-100` | -| float | Shortest roundtrip, `e` (not `E`) | `3.14`, `1e-06`, `1e+15` | +| float | Shortest roundtrip, `e` (not `E`) | `3.14`, `1e-06`, `9.007199254740992e+15` | | string | Bare if safe, else quoted | `hello`, `"hello world"` | -### Float Formatting +### Number Formatting -- **Zero:** Always `0` (not `-0`, not `0.0`) -- **Negative zero:** Canonicalizes to `0` -- **Exponent threshold:** Use exponential when `exp < -4` or `exp >= 15` -- **Exponent format:** 2-digit minimum (`1e-06`, not `1e-6`) -- **NaN/Infinity:** Rejected with error (not JSON-compatible) +In Loose mode a JSON number is first **typed** by the safe-integer window: an integer-valued +number with `|n| <= 2^53-1` becomes an integer literal; anything else is a float. So in Loose +mode: -> **Known open divergence:** Float canonicalization is not yet fully unified. Go uses shortest-roundtrip formatting; Python and JS use a threshold-based rule. The outputs agree for common values but can differ for edge-case floats near the boundary. This divergence is unresolved and will be addressed in a future spec update. Do not rely on byte-identical float output across Go and Python/JS for edge cases. +- **Zero (incl. `-0`, `0.0`):** integer-valued and in-window → integer literal `0` +- **Integer-valued numbers** (e.g. `1e3`, `3.0`, `1000000000000000`) → integer literals (`1000`, `3`, `1000000000000000`) +- **Floats** (non-integer, or out-of-window): the [`CANONICAL_FORMS.md` §3 (D4)](./CANONICAL_FORMS.md) rule — shortest round-trip digits, lowercase `e`, exponential when the decimal exponent is `<= -5` or `>= 6`, exponent zero-padded to ≥ 2 digits +- **NaN/Infinity:** rejected with error (not JSON-compatible) + +> **Number canonicalization is unified and byte-identical across Go, Python, and JS** — both the +> safe-integer typing and the float-formatting rule (`CANONICAL_FORMS.md` §3 is authoritative). +> The earlier threshold-based float rule and the Python int/float divergence are both resolved. ### String Bare-Safe Rule diff --git a/docs/SPECIFICATIONS.md b/docs/SPECIFICATIONS.md index f542bc7..aabb9a7 100644 --- a/docs/SPECIFICATIONS.md +++ b/docs/SPECIFICATIONS.md @@ -51,16 +51,20 @@ GLYPH has two main specifications: | null | `_` | `_` (accepts `∅`, `null` on input) | | bool | `t` / `f` | `t`, `f` | | int | Decimal, no leading zeros | `0`, `42`, `-100` | -| float | Shortest roundtrip, `e` not `E` | `3.14`, `1e-06`, `1e+15` | +| float | Shortest roundtrip, `e` not `E` | `3.14`, `1e-06`, `9.007199254740992e+15` | | string | Bare if safe, else quoted | `hello`, `"hello world"` | -#### Float Formatting +#### Number Formatting (Loose mode) -- **Zero:** Always `0` (not `-0`, not `0.0`) -- **Negative zero:** Canonicalizes to `0` -- **Exponent threshold:** Use exponential when `exp < -4` or `exp >= 15` -- **Exponent format:** 2-digit minimum (`1e-06`, not `1e-6`) -- **NaN/Infinity:** Rejected with error (not JSON-compatible) +A JSON number is first **typed** by the safe-integer window, then formatted: + +- **Integer-valued in `|n| <= 2^53-1`** (incl. `-0`, `0.0`, `1e3`): integer literal (`0`, `1000`) +- **Floats** (non-integer or out-of-window): shortest round-trip, lowercase `e`, exponential + when the decimal exponent is `<= -5` or `>= 6`, exponent ≥ 2 digits — see + [`CANONICAL_FORMS.md` §3 (D4)](./CANONICAL_FORMS.md), the authoritative rule +- **NaN/Infinity:** rejected with error (not JSON-compatible) + +This rule is unified and byte-identical across Go, Python, and JS. **Examples:** ``` @@ -202,7 +206,7 @@ hash = glyph.fingerprint_loose(glyph.from_json(data)) - Different data → different hash (collision-resistant) - Used for state verification and patch safety -> **Known open divergence:** Float canonicalization is not yet fully unified across implementations. Go uses shortest-roundtrip; Python and JS use a threshold-based rule. Outputs agree for common values but may differ at edge-case float boundaries. This is unresolved. +> **Float canonicalization is unified and byte-identical across Go, Python, and JS.** All three use the shortest-round-trip rule in [`CANONICAL_FORMS.md` §3 (D4)](./CANONICAL_FORMS.md); the earlier threshold-based rule has been retired. The JSON-domain number typing (safe-integer window) is also unified across implementations. --- @@ -426,7 +430,7 @@ status enum[pending,active,complete] **Loose Mode:** 1. Produce identical canonical output for same input (deterministic) 2. Sort map keys by UTF-8 byte order -3. Format floats according to exponent thresholds +3. Type numbers by the safe-integer window and format floats per the canonical float rule (`CANONICAL_FORMS.md` §3: shortest round-trip, exponential outside the decimal-exponent range `[-4, 6)`) 4. Apply bare-safe rules consistently 5. Use last-wins for duplicate keys 6. Accept any valid JSON as input diff --git a/go/README.md b/go/README.md index 3bd3594..9cbee5b 100644 --- a/go/README.md +++ b/go/README.md @@ -8,6 +8,12 @@ Go implementation of the GLYPH codec and GS1 stream tooling. Together with Pytho go get github.com/Neumenon/glyph ``` +> **Note:** until the optional `cowrie` bridge dependency is published, `go get` / +> `go mod tidy` may fail resolving `cowrie/go/v2` (a plain `go build` of the codec +> works via module-graph pruning). The bridge is dev-only — see +> [Internal: `cogs` cowrie bridge](#internal-cogs-cowrie-bridge-not-part-of-the-release-surface) +> and the caveat in `go.mod`. + Import the codec package as: ```go @@ -52,4 +58,20 @@ func main() { - The codec package lives under `github.com/Neumenon/glyph/glyph`. - The stream package lives under `github.com/Neumenon/glyph/stream`. +## Internal: `cogs` cowrie bridge (not part of the release surface) + +Files behind `//go:build cogs` (`glyph/bridge.go`, `cmd/bridgecheck`) provide an +internal bridge between `GValue` and the binary [cowrie](https://github.com/Neumenon/cowrie) +wire format. It is **not** a published feature: the default build never compiles +it, and it depends on an unpublished sibling. Build it for local development only: + +```sh +# requires a cowrie/go checkout at ../../cowrie/go (see the replace in go.mod) +go build -tags cogs ./... +``` + +See the caveat in `go.mod`: until cowrie is published, external +`go get`/`go mod tidy` cannot resolve it. Use the default (no-tags) build for the +shipped codec. + For the repo-wide doc map, start at [../README.md](../README.md). diff --git a/go/glyph/loose_test.go b/go/glyph/loose_test.go index 1e2a0da..415993c 100644 --- a/go/glyph/loose_test.go +++ b/go/glyph/loose_test.go @@ -1846,14 +1846,18 @@ func TestTripleImpl_PatchParse(t *testing.T) { jsResultJSON, jsOK := runJSCanon(t, "parse-patch", tc.patch) if jsOK { var jsResult struct { - Target interface{} `json:"target"` - SchemaId string `json:"schemaId"` - OpsCount int `json:"opsCount"` + Target interface{} `json:"target"` + SchemaId string `json:"schemaId"` + BaseFingerprint string `json:"baseFingerprint"` + OpsCount int `json:"opsCount"` } if err := json.Unmarshal([]byte(jsResultJSON), &jsResult); err == nil { if jsResult.OpsCount != len(goPatch.Ops) { t.Errorf("Go vs JS ops count mismatch: Go=%d, JS=%d", len(goPatch.Ops), jsResult.OpsCount) } + if jsResult.BaseFingerprint != goPatch.BaseFingerprint { + t.Errorf("Go vs JS base fingerprint mismatch: Go=%s, JS=%s", goPatch.BaseFingerprint, jsResult.BaseFingerprint) + } } } diff --git a/go/glyph/test/js/canon.mjs b/go/glyph/test/js/canon.mjs index 23b0946..25004f5 100644 --- a/go/glyph/test/js/canon.mjs +++ b/go/glyph/test/js/canon.mjs @@ -190,6 +190,7 @@ function cmdParsePatch(patchStr, schemaJson) { result: JSON.stringify({ target: patch.target, schemaId: patch.schemaId, + baseFingerprint: patch.baseFingerprint || "", opsCount: patch.ops.length, }) }; diff --git a/go/glyph/test/py/canon.py b/go/glyph/test/py/canon.py index c936dec..06e4d3d 100644 --- a/go/glyph/test/py/canon.py +++ b/go/glyph/test/py/canon.py @@ -120,7 +120,7 @@ def cmd_parse_patch(patch_str: str) -> dict: patch = parse_patch(patch_str) result = { "schemaId": patch.schema_id, - "baseFingerprint": "", + "baseFingerprint": patch.base_fingerprint, "opsCount": len(patch.ops), } return {"success": True, "result": json.dumps(result)} diff --git a/go/go.mod b/go/go.mod index 9b9a584..de6e635 100644 --- a/go/go.mod +++ b/go/go.mod @@ -7,7 +7,16 @@ require ( github.com/klauspost/compress v1.18.0 // indirect ) -// Local development: resolve cowrie/go/v2 from the monorepo. -// Remove this replace directive for release builds once cowrie v2.0.1+ -// is tagged and published to the Go proxy. +// cowrie is required ONLY by the optional `cogs` bridge (//go:build cogs: +// glyph/bridge.go, glyph/bridge_collision_test.go, cmd/bridgecheck). The default +// build (`go build ./...`, no tags) never imports it and is clean. +// +// CAVEAT (release): Go has no tag-conditional `require`, so this line sits in the +// published go.mod unconditionally. Until cowrie/go/v2 is tagged+published to the +// Go proxy, external `go get github.com/Neumenon/glyph` / `go mod tidy` FAIL while +// resolving it (a plain `go build` still works via module-graph pruning). To make +// `go get` clean for everyone, either publish cowrie or move the cogs bridge into +// a separate module (blocked today because bridge.go uses unexported glyph +// internals). The replace below resolves cowrie from the monorepo for local +// `-tags cogs` development only. replace github.com/Neumenon/cowrie/go/v2 => ../../cowrie/go diff --git a/py/glyph/__init__.py b/py/glyph/__init__.py index 12c4e9c..4c04be0 100644 --- a/py/glyph/__init__.py +++ b/py/glyph/__init__.py @@ -87,6 +87,9 @@ PathSegKind, parse_patch, apply_patch, + verify_patch_base, + compute_base_fingerprint, + PatchBaseMismatch, ) # Convenient aliases @@ -148,4 +151,7 @@ "PathSegKind", "parse_patch", "apply_patch", + "verify_patch_base", + "compute_base_fingerprint", + "PatchBaseMismatch", ] diff --git a/py/glyph/loose.py b/py/glyph/loose.py index ed78e9f..7563cdf 100644 --- a/py/glyph/loose.py +++ b/py/glyph/loose.py @@ -83,6 +83,12 @@ def no_tabular_loose_canon_opts() -> LooseCanonOpts: MAX_COLLECTION_LEN = 1_000_000 # 1M elements MAX_STRING_LEN = 10 * 1024 * 1024 # 10MB +# IEEE-754 double safe-integer bound (2^53 - 1). GLYPH-Loose uses JSON-domain +# (double) number semantics so canonical output is byte-identical across Go, JS, +# and Python: integers within this window are integer literals; anything outside +# is not representable as a JS Number and canonicalizes as a float64. +MAX_SAFE_INT = (1 << 53) - 1 # 9007199254740991 + # Reserved words that must be quoted (D8: matches Go isValidBareString reject list) RESERVED_WORDS = {"t", "f", "true", "false", "null", "none", "nil", "_", "NaN", "Inf", "struct", "sum", "list", "map"} @@ -540,10 +546,21 @@ def from_json_loose(data: Any, _depth: int = 0) -> GValue: elif isinstance(data, bool): return GValue.bool_(data) elif isinstance(data, int): - return GValue.int_(data) + # JSON-domain number semantics: integers outside the IEEE-754 safe window + # are not representable as a JS Number, so they canonicalize as float64 — + # matching FromJSONLoose (Go) and fromJsonLoose (JS). This is lossy for + # huge ints by design; use GLYPH-Typed/int64 when full precision matters. + if -MAX_SAFE_INT <= data <= MAX_SAFE_INT: + return GValue.int_(data) + return GValue.float_(float(data)) elif isinstance(data, float): if not math.isfinite(data): raise ValueError("non-finite floats are not supported") + # In the JSON number domain there is no int/float distinction: an + # integer-valued float within the safe window collapses to an integer + # literal (1e3 -> 1000, 3.0 -> 3, -0.0 -> 0), exactly as Go/JS do. + if data.is_integer() and -MAX_SAFE_INT <= data <= MAX_SAFE_INT: + return GValue.int_(int(data)) return GValue.float_(data) elif isinstance(data, str): if len(data) > MAX_STRING_LEN: diff --git a/py/glyph/patch.py b/py/glyph/patch.py index e23c81a..c7d8b52 100644 --- a/py/glyph/patch.py +++ b/py/glyph/patch.py @@ -20,11 +20,13 @@ from __future__ import annotations import copy +import hashlib from dataclasses import dataclass, field from enum import Enum from typing import Any, Dict, List, Optional from .types import GType, GValue, MapEntry, StructValue +from .loose import canonicalize_loose_no_tabular class PatchOpKind(Enum): @@ -61,6 +63,65 @@ class Patch: ops: List[PatchOp] = field(default_factory=list) schema_id: str = "" target: str = "" + # First 16 hex chars of sha256(canonicalize_loose_no_tabular(base_state)); + # empty when + # the patch does not record a base. Matches Go BaseFingerprint / JS + # baseFingerprint so a Python receiver can verify a Go/JS-emitted patch. + base_fingerprint: str = "" + + +# Base-fingerprint length (hex chars). Mirrors Go/JS: first 16 of the SHA-256. +BASE_FINGERPRINT_LEN = 16 + + +class PatchBaseMismatch(ValueError): + """Raised when a patch's recorded base fingerprint does not match the base + state presented to verify_patch_base (mirrors Go's FingerprintMismatch).""" + + def __init__(self, got: str, want: str): + self.got = got + self.want = want + super().__init__( + f"patch base fingerprint mismatch: got {got!r}, want {want!r}" + ) + + +def compute_base_fingerprint(base: GValue) -> str: + """Compute the 16-hex patch base fingerprint of a base state. + + base = sha256(canonicalize_loose_no_tabular(base))[:16] — i.e. the first 16 + hex of the state fingerprint defined in the spec (README invariant: + fingerprint(x) = SHA256(canonical_no_tabular_bytes(x))). Using the no-tabular + form makes a patch's @base equal to fingerprint_loose(state)[:16], so a + receiver can verify it against the current state's fingerprint directly. + + This is byte-identical to Go WithBaseValue / JS withBaseValue for every + non-tabular base (struct/map roots — the realistic patch target), since their + tabular and no-tabular canonical forms coincide there. The one edge is a bare + auto-tabular list root: Go/JS WithBaseValue hash the *tabular* form, whereas + this uses the *no-tabular* form (= the state fingerprint), so the three diverge + there. Note Go is itself inconsistent at that edge — its FingerprintLoose also + uses no-tabular, so Go's WithBaseValue != Go's own state fingerprint for a list + root. Standardizing all three on the no-tabular state fingerprint is the + recommended follow-up. + """ + canonical = canonicalize_loose_no_tabular(base) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:BASE_FINGERPRINT_LEN] + + +def verify_patch_base(base: GValue, patch: Patch) -> None: + """Verify a patch's recorded base fingerprint against the base state. + + No-op when the patch records no base (mirrors Go VerifyPatchBase). Raises + PatchBaseMismatch when the recomputed fingerprint differs. Standalone + apply_patch does NOT call this — callers verify before applying, exactly as + the Go/JS standalone APIs do (the GS1 cursor layer enforces on the stream). + """ + if not patch.base_fingerprint: + return + got = compute_base_fingerprint(base) + if got != patch.base_fingerprint: + raise PatchBaseMismatch(got=got, want=patch.base_fingerprint) def parse_patch(text: str) -> Patch: @@ -88,6 +149,8 @@ def parse_patch(text: str) -> Patch: patch.schema_id = tok[8:] elif tok.startswith("@target="): patch.target = tok[8:] + elif tok.startswith("@base="): + patch.base_fingerprint = tok[6:] # Parse operations for i, line in enumerate(lines[1:], start=2): @@ -155,12 +218,18 @@ def _split_path_value(rest: str) -> tuple[str, str]: def _parse_path(path_str: str) -> List[PathSeg]: - """Parse a dot-separated path like '.step' or '.items'.""" - if not path_str.startswith("."): - raise ValueError(f"path must start with '.': {path_str}") + """Parse a dotted path. + + Accepts both GLYPH-Python's leading-dot form ('.step', '.items[0]') and the + bare form emitted by Go/JS ('step', 'home.score'), so a Python receiver can + parse a patch produced by any implementation. + """ + if not path_str: + raise ValueError("empty path") + body = path_str[1:] if path_str.startswith(".") else path_str segments = [] - parts = path_str[1:].split(".") + parts = body.split(".") for part in parts: if not part: diff --git a/py/tests/test_golden_corpus.py b/py/tests/test_golden_corpus.py index c74aa4b..26309b9 100644 --- a/py/tests/test_golden_corpus.py +++ b/py/tests/test_golden_corpus.py @@ -4,18 +4,12 @@ the output of from_json_loose -> canonicalize_loose_no_tabular against the corresponding .want golden file in go/glyph/testdata/loose_json/golden/. -Cases that diverge today due to known, deferred issues are marked xfail with -an explanatory reason string. The suite is expected to produce xfail results -for those — not hard failures. - -Known divergences (intentionally deferred, do NOT fix here): -- Float canonicalization rule: Go emits integer-valued floats without ".0" - (e.g. 1e3 -> "1000"), Python's canon_float keeps the float representation - (1000.0). This affects cases 006, 016, 034, 035. - See: "deferred — float-.0 rule unification". - -- 036_negative_zero: JSON -0 is parsed as integer 0 in Go (emits "0") but - as float -0.0 in Python (emits "0.0" for neg_zero_float). Deferred. +Every case must match Go byte-for-byte. The JSON-number typing is unified +across Go/JS/Python: GLYPH-Loose uses JSON-domain (IEEE-754 double) semantics, +so integer-valued floats collapse to integer literals (1e3 -> "1000"), -0.0 -> +"0", and integers outside the safe window (|n| > 2^53-1) canonicalize as +float64 (9007199254740993 -> "9.007199254740992e+15"). See +py/glyph/loose.py:from_json_loose and docs/LOOSE_MODE_SPEC.md. """ from __future__ import annotations @@ -36,39 +30,8 @@ _CASES_DIR = os.path.join(_REPO_ROOT, "go", "glyph", "testdata", "loose_json", "cases") _GOLDEN_DIR = os.path.join(_REPO_ROOT, "go", "glyph", "testdata", "loose_json", "golden") -# Cases expected to diverge due to the float canonicalization rule (deferred). -# Python's JSON parser returns floats for 1e3/3.0e+0 (Go returns ints), -# and returns ints for 1e20/1e21 (Go returns floats). Both are correct per -# JSON spec but produce different GLYPH output until the rule is unified. -_FLOAT_RULE_XFAIL = { - "006_exponent_numbers": ( - "deferred — float-.0 rule: JSON numbers like 1e3 and 3.0e+0 are parsed " - "as Python float (1000.0, 3.0) but Go parses them as int (1000, 3); " - "canon_float diverges until the float canonicalization rule is unified" - ), - "016_large_int_like": ( - "deferred — float rule: 9007199254740993 is parsed as Python int (emitted " - "as-is) but Go treats it as float and emits 9.007199254740992e+15" - ), - "034_exp_boundary_large": ( - "deferred — float rule: 1e20/1e21 are parsed as Python int by json.loads " - "and emitted as large ints, but Go parses them as float and emits 1e+20/1e+21" - ), - "035_safe_int_boundary": ( - "deferred — float rule: 9007199254740992/9007199254740993 are parsed as " - "Python int by json.loads, but Go treats them as out-of-safe-int float and " - "emits 9.007199254740992e+15" - ), - "036_negative_zero": ( - "deferred — float rule: JSON -0 is parsed as integer 0 in Go (emits '0') " - "but as float -0.0 in Python (neg_zero_float emits '0.0'); " - "deferred until JSON bridge float/int parsing is unified" - ), -} - - def _collect_cases(): - """Yield (case_name, case_path, want_path, xfail_reason_or_None) tuples.""" + """Yield (case_name, case_path, want_path) tuples for every golden case.""" if not os.path.isdir(_CASES_DIR): pytest.skip(f"Go testdata directory not found: {_CASES_DIR}") @@ -78,21 +41,11 @@ def _collect_cases(): if not os.path.exists(want_path): # No golden file for this case; skip it (e.g. 050_dynamic_keys_metadata). continue - xfail_reason = _FLOAT_RULE_XFAIL.get(name) - yield pytest.param( - name, - case_path, - want_path, - xfail_reason, - id=name, - marks=[pytest.mark.xfail(reason=xfail_reason, strict=True)] - if xfail_reason - else [], - ) + yield pytest.param(name, case_path, want_path, id=name) -@pytest.mark.parametrize("name,case_path,want_path,xfail_reason", _collect_cases()) -def test_golden_corpus(name, case_path, want_path, xfail_reason): +@pytest.mark.parametrize("name,case_path,want_path", _collect_cases()) +def test_golden_corpus(name, case_path, want_path): """Each Go golden case must match Python's canonicalize_loose_no_tabular output.""" with open(case_path, encoding="utf-8") as f: data = json.load(f) diff --git a/py/tests/test_patch.py b/py/tests/test_patch.py index edb7c89..d17d80f 100644 --- a/py/tests/test_patch.py +++ b/py/tests/test_patch.py @@ -10,6 +10,9 @@ PathSegKind, apply_patch, parse_patch, + verify_patch_base, + compute_base_fingerprint, + PatchBaseMismatch, _parse_op, _parse_path, _parse_value, @@ -23,6 +26,7 @@ _set_field, _delete_field, ) +from glyph import from_json_loose from glyph.types import GType, GValue, MapEntry, StructValue @@ -193,9 +197,15 @@ def test_nested_with_index(self): assert segs[2].list_idx == 2 assert segs[3].field == "name" - def test_path_not_starting_with_dot(self): - with pytest.raises(ValueError, match="path must start with '.'"): - _parse_path("noprefix") + def test_bare_path_parses_like_go_js(self): + """Bare (non-dotted) paths parse identically to the leading-dot form, so + a Python receiver can read patches emitted by Go/JS (which use bare paths + like 'home.score'). This is the cross-impl patch contract, not a regression + of the old dot-required rule.""" + assert _parse_path("noprefix") == _parse_path(".noprefix") + bare = _parse_path("home.score") + assert [s.field for s in bare] == ["home", "score"] + assert _parse_path("items[2].name") == _parse_path(".items[2].name") def test_invalid_list_index(self): with pytest.raises(ValueError, match="invalid list index"): @@ -847,3 +857,56 @@ def test_nested_map_in_list_value(self): assert len(items) == 2 assert items[0].get("a").as_int() == 1 assert items[1].get("b").as_int() == 2 + + +# ============================================================ +# Patch base fingerprint — cross-implementation contract +# ============================================================ + + +class TestPatchBaseFingerprint: + """The @base= fingerprint is the cross-impl patch-base contract: the first 16 + hex of sha256(canonicalize_loose(base)), byte-identical to Go WithBaseValue + and JS withBaseValue. These golden values are produced by the Go/JS impls and + pinned here, so the test fails loudly if Python's contract ever drifts.""" + + # Golden 16-hex fingerprints (verified equal to Go/JS output). + GOLDEN = { + # {a=1 b=2} — same canonical form in every impl. + ("a", 1, "b", 2): "f35719430d98a2fe", + } + + def test_compute_matches_go_js_golden_simple(self): + base = from_json_loose({"a": 1, "b": 2}) + assert compute_base_fingerprint(base) == "f35719430d98a2fe" + assert len(compute_base_fingerprint(base)) == 16 + + def test_compute_matches_go_js_golden_nested(self): + # {away={score=0} home={score=1} rating=1} — integer-valued float 1.0 + # collapses to 1 under the unified number rule, exactly as Go/JS. + base = from_json_loose({"home": {"score": 1}, "away": {"score": 0}, "rating": 1.0}) + assert compute_base_fingerprint(base) == "8cdae5d35aa1f4ae" + + def test_parse_base_token(self): + patch = parse_patch( + "@patch @schema#abc @keys=wire @target=m:1 @base=deadbeef12345678\n" + "= home.score 2\n@end" + ) + assert patch.base_fingerprint == "deadbeef12345678" + + def test_verify_matching_base_passes(self): + base = from_json_loose({"a": 1, "b": 2}) + patch = parse_patch(f"@patch @target=m:1 @base={compute_base_fingerprint(base)}\n= a 9\n@end") + verify_patch_base(base, patch) # must not raise + + def test_verify_wrong_base_raises(self): + base = from_json_loose({"a": 1, "b": 2}) + patch = parse_patch(f"@patch @target=m:1 @base={compute_base_fingerprint(base)}\n= a 9\n@end") + with pytest.raises(PatchBaseMismatch): + verify_patch_base(from_json_loose({"a": 9}), patch) + + def test_verify_no_base_is_noop(self): + base = from_json_loose({"a": 1, "b": 2}) + patch = parse_patch("@patch @target=m:1\n= a 9\n@end") + assert patch.base_fingerprint == "" + verify_patch_base(base, patch) # no base recorded -> no-op, must not raise diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..880f875 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,25 @@ +"""Pytest configuration for the repo-level cross-implementation harness. + +The ``*_test.py`` files in this directory are **standalone scripts**, not pytest +modules. Each has a ``__main__`` / ``sys.exit`` entry point and shells out to the +Go / JS (and parked Rust / C) builds to check cross-language parity. Pytest's +default glob (``*_test.py``) happens to match their names, which previously caused +fixture-injection errors (``test_roundtrip(name, data)``) and +``PytestReturnNotNoneWarning`` (functions that ``return`` a bool). + +Run them directly instead:: + + python tests/all_impl_parity_test.py # the cross-impl parity gate + python tests/roundtrip_stress_test.py + python tests/cross_impl_parity_test.py + +``collect_ignore`` keeps ``pytest tests/`` free of errors and warnings without +renaming the scripts or touching their call sites. The language-specific pytest +suites live under ``py/tests/`` and are unaffected. +""" + +collect_ignore = [ + "all_impl_parity_test.py", + "cross_impl_parity_test.py", + "roundtrip_stress_test.py", +] diff --git a/tests/cross_impl_parity_test.py b/tests/cross_impl_parity_test.py index 8a7a8a3..81efc0f 100644 --- a/tests/cross_impl_parity_test.py +++ b/tests/cross_impl_parity_test.py @@ -34,7 +34,10 @@ ("simple_scalars", {"a": 1, "b": "hello", "c": True}, "{a=1 b=hello c=t}"), ("negative_and_float", {"neg": -42, "pi": 3.14}, "{neg=-42 pi=3.14}"), ("string_escapes", {"s": "line1\nline2\ttab"}, '{s="line1\\nline2\\ttab"}'), - ("unicode", {"greeting": "你好"}, "{greeting=你好}"), + # Non-ASCII strings are quoted (conservative quoting: the bare-string lexer is + # ASCII-only, so quoting keeps round-trip exact). Matches Go/JS — see Go golden + # 008_unicode.want. + ("unicode", {"greeting": "你好"}, '{greeting="你好"}'), ("mixed_array", [1, "two", True, None], "[1 two t _]"), ("nested_object", {"outer": {"inner": 42}}, "{outer={inner=42}}"), ("nested_lists", [[1, 2], [3, 4]], "[[1 2] [3 4]]"), @@ -44,7 +47,9 @@ # Numbers edge cases ("negative_zero", -0.0, "0"), ("exponent_small", 1e-10, "1e-10"), - ("exponent_large", 1e15, "1e+15"), + # 1e15 is integer-valued and within the IEEE-754 safe window, so it + # canonicalizes as an integer literal (unified JSON-domain typing; matches Go/JS). + ("exponent_large", 1e15, "1000000000000000"), # Strings that need quoting ("string_with_space", "hello world", '"hello world"'), diff --git a/tests/roundtrip_stress_test.py b/tests/roundtrip_stress_test.py index f14c57e..7dd51d6 100644 --- a/tests/roundtrip_stress_test.py +++ b/tests/roundtrip_stress_test.py @@ -25,6 +25,30 @@ LooseCanonOpts, NullStyle, ) +# GLYPH-Loose uses JSON-domain (IEEE-754 double) number semantics, unified across +# Go/Python/JS: integer-valued numbers within the safe window are integers, and +# numbers outside it collapse to float64 (lossy by design). The int/float TYPE is +# therefore not part of round-trip fidelity — VALUE fidelity is. Normalize both +# sides with the same rule so the comparison checks value/structure preservation +# (real corruption) without flagging the deliberate, cross-impl int/float collapse. +_SAFE_INT = (1 << 53) - 1 + + +def _normalize_numbers(obj): + if isinstance(obj, bool): + return obj + if isinstance(obj, int): + return obj if -_SAFE_INT <= obj <= _SAFE_INT else float(obj) + if isinstance(obj, float): + if obj.is_integer() and -_SAFE_INT <= obj <= _SAFE_INT: + return int(obj) + return obj + if isinstance(obj, list): + return [_normalize_numbers(x) for x in obj] + if isinstance(obj, dict): + return {k: _normalize_numbers(v) for k, v in obj.items()} + return obj + def test_roundtrip(name: str, data: Any, use_tabular: bool = True) -> Tuple[bool, str]: """Test JSON -> GValue -> GLYPH -> GValue -> JSON round-trip.""" @@ -41,9 +65,9 @@ def test_roundtrip(name: str, data: Any, use_tabular: bool = True) -> Tuple[bool # GValue -> JSON (direct, no parse) restored = to_json_loose(gvalue) - # Compare - orig_json = json.dumps(data, sort_keys=True, ensure_ascii=False) - rest_json = json.dumps(restored, sort_keys=True, ensure_ascii=False) + # Compare on JSON-domain value (see _normalize_numbers). + orig_json = json.dumps(_normalize_numbers(data), sort_keys=True, ensure_ascii=False) + rest_json = json.dumps(_normalize_numbers(restored), sort_keys=True, ensure_ascii=False) if orig_json == rest_json: return True, f"OK | GLYPH: {glyph_str[:80]}{'...' if len(glyph_str) > 80 else ''}" @@ -66,9 +90,9 @@ def test_parse_roundtrip(name: str, data: Any) -> Tuple[bool, str]: reparsed = parse_json_loose(json_str) restored = to_json_loose(reparsed) - # Compare - orig_json = json.dumps(data, sort_keys=True, ensure_ascii=False) - rest_json = json.dumps(restored, sort_keys=True, ensure_ascii=False) + # Compare on JSON-domain value (see _normalize_numbers). + orig_json = json.dumps(_normalize_numbers(data), sort_keys=True, ensure_ascii=False) + rest_json = json.dumps(_normalize_numbers(restored), sort_keys=True, ensure_ascii=False) if orig_json == rest_json: return True, "OK" @@ -292,7 +316,10 @@ def run_equality_tests(): ("same object different key order", {"a": 1, "b": 2}, {"b": 2, "a": 1}, True), ("same array", [1, 2, 3], [1, 2, 3], True), ("different array order", [1, 2, 3], [3, 2, 1], False), - ("int vs float", 42, 42.0, False), # Different types + # Unified contract: in the JSON number domain there is no int/float + # distinction, so int 42 and integer-valued float 42.0 collapse to the + # same loose value and fingerprint (matches Go/JS). + ("int vs integer-valued float", 42, 42.0, True), ("null equality", None, None, True), ("empty structures", {}, [], False), ("nested same", {"a": {"b": 1}}, {"a": {"b": 1}}, True), From a248ae68fd970c9c2d2d8b103b18eb4dda7cdc9e Mon Sep 17 00:00:00 2001 From: phenomenon0 Date: Sun, 21 Jun 2026 06:42:36 -0500 Subject: [PATCH 2/2] test(glyph): cross-language 8-scenario gauntlet + Python parity fixes Add a scenario-based acceptance suite that exercises every major GLYPH capability as realistic AI-workflow usage, runs each scenario identically across Go/Python/JS, applies one pass/fail evaluator with recorded evidence, and gates on a hard exit code. Harness (gauntlet/scenarios/): - gen_inputs.py -> inputs.json: single shared fixture source (same conditions) - runner.py / runner.cjs / go/cmd/gauntletrunner: per-language evidence runners (measure only; the orchestrator is the single evaluator) - gauntlet.py: runs all three, applies criteria incl. byte-for-byte cross-lang equality, writes report.json, exits non-zero on any failure Scenarios: S1 JSON bridge - S2 canonicalization - S3 fingerprint parity - S4 tabular compaction - S5 patch apply - S6 patch-base fail-closed - S7 GS1 framing + wire parity - S8 streaming firewall. First run was 6/8. Both failures were cross-language divergences where Go and JS agreed and Python was the outlier; fixed Python to match Go (source of truth): - Tabular header: emit `@tab _ rows=N cols=M [cols]` and accept it on parse (py/glyph/loose.py, py/glyph/parse.py). Python previously could not read Go/JS tabular output. - Patch @base: compute over canonicalize_loose, not the no-tabular fingerprint, matching Go/JS and LOOSE_MODE_SPEC (py/glyph/patch.py); reconcile the README invariant block accordingly. Result: 8/8. Existing suites stay green (py 444, py-gauntlet 81, go all, js 579, cross-impl parity gate all). Also lands in-progress branch work already staged in the tree: the per-language gauntlet tests (go/py/js), the JS loose-text parser (parse_loose.ts), the gauntlet web demo, and release-readiness doc updates. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 1 + README.md | 9 +- docs/API_REFERENCE.md | 8 +- docs/QUICKSTART.md | 8 +- gauntlet/data/gauntlet-data.js | 955 +++++ gauntlet/data/gauntlet-data.json | 955 +++++ gauntlet/harness/measure.js | 496 +++ gauntlet/index.html | 1019 +++++ gauntlet/scenarios/README.md | 76 + gauntlet/scenarios/gauntlet.py | 320 ++ gauntlet/scenarios/gen_inputs.py | 166 + gauntlet/scenarios/inputs.json | 544 +++ gauntlet/scenarios/runner.cjs | 214 + gauntlet/scenarios/runner.py | 194 + gauntlet/web/glyph.bundle.js | 6433 ++++++++++++++++++++++++++++++ go/README.md | 30 +- go/cmd/gauntletrunner/main.go | 354 ++ go/glyph/glyph_gauntlet_test.go | 781 ++++ js/package.json | 8 +- js/src/gauntlet.test.ts | 732 ++++ js/src/index.ts | 6 + js/src/parse_loose.ts | 766 ++++ py/glyph/loose.py | 7 +- py/glyph/parse.py | 11 +- py/glyph/patch.py | 35 +- py/pyproject.toml | 5 +- py/tests/glyph_gauntlet_test.py | 913 +++++ 27 files changed, 15000 insertions(+), 46 deletions(-) create mode 100644 gauntlet/data/gauntlet-data.js create mode 100644 gauntlet/data/gauntlet-data.json create mode 100644 gauntlet/harness/measure.js create mode 100644 gauntlet/index.html create mode 100644 gauntlet/scenarios/README.md create mode 100644 gauntlet/scenarios/gauntlet.py create mode 100644 gauntlet/scenarios/gen_inputs.py create mode 100644 gauntlet/scenarios/inputs.json create mode 100644 gauntlet/scenarios/runner.cjs create mode 100644 gauntlet/scenarios/runner.py create mode 100644 gauntlet/web/glyph.bundle.js create mode 100644 go/cmd/gauntletrunner/main.go create mode 100644 go/glyph/glyph_gauntlet_test.go create mode 100644 js/src/gauntlet.test.ts create mode 100644 js/src/parse_loose.ts create mode 100644 py/tests/glyph_gauntlet_test.py diff --git a/.gitignore b/.gitignore index c998aa1..3720e5e 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ coverage/ # Generated explainer.html +gauntlet/scenarios/report.json # AI tool context dumps (not source) glyph_*_context.txt diff --git a/README.md b/README.md index 5be8f7e..be30031 100644 --- a/README.md +++ b/README.md @@ -84,12 +84,14 @@ Poor fits: | Language | Package | Docs | |----------|---------|------| | Python | `pip install glyph-py` | [Python README](./py/README.md) | -| Go | `go get github.com/Neumenon/glyph` | [Go README](./go/README.md) | +| Go | in-repo / source preview — build under `go/` (`go get` not yet a stable path) | [Go README](./go/README.md) | | JavaScript / TypeScript | `npm install cowrie-glyph` | [JS README](./js/README.md) | | Rust | parked in `attic/rust/glyph-codec/` — emit-only, not published | [Rust README](./attic/rust/glyph-codec/README.md) | | C | parked in `attic/c/glyph-codec/` — emit-only, build from source | [C README](./attic/c/glyph-codec/README.md) | > **Note:** Rust and C ports are parked in `attic/`. They emit canonical GLYPH-Loose but are not conformance ports (no text parser, no patch/GS1/pack). They are not published; `cargo add glyph-rs` is not a valid install path. +> +> **Go status:** the Go codec is a full conformance implementation, but it is currently an **in-repo / source preview**. The module lives under `go/`, and external `go get github.com/Neumenon/glyph` / `go mod tidy` do not yet resolve cleanly (module is in a subdirectory and an optional dev-only bridge pulls an unpublished dependency). Use it from a checkout of this repo — `cd go && go build ./...` — until the external module packaging is stabilized. See the [Go README](./go/README.md) for details. ## Examples @@ -192,8 +194,9 @@ These hold across the conformance-tested implementation surface: parse(emit(x)) = x emit(parse(s)) = canonical(s) fingerprint(x) = SHA256(canonical_no_tabular_bytes(x)) # Go/Python/JS value identity -patch.base records the fingerprint of the base state; GS1 cursor layer enforces - base matching on the stream; standalone ApplyPatch does NOT verify +patch.base = first 16 hex of SHA256(canonical_loose_bytes(base)); GS1 cursor + enforces base matching on the stream; standalone ApplyPatch does NOT + verify (call verify_patch_base / VerifyPatchBase first) JSON ↔ GLYPH preserves JSON-domain meaning conformance impls (Go/Python/JS) agree byte-for-byte on canonical form for the shared corpus ``` diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index f4bcdd9..e8e9564 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -9,10 +9,10 @@ The purpose is to give the current package names, import surfaces, and the core | Language | Package | Primary Doc | |----------|---------|-------------| | Python | `glyph-py` | [../py/README.md](../py/README.md) | -| Go | `github.com/Neumenon/glyph` | [../go/README.md](../go/README.md) | +| Go | in-repo / source preview (module under `go/`; `go get` not yet a stable path) | [../go/README.md](../go/README.md) | | JavaScript / TypeScript | `cowrie-glyph` | [../js/README.md](../js/README.md) | | Rust | parked in `attic/` — emit-only, not published | [../attic/rust/glyph-codec/README.md](../attic/rust/glyph-codec/README.md) | -| C | parked in `attic/` — emit-only, build from source | [../attic/c/glyph-codec/README.md](../attic/c/glyph-codec/README.md) | +| C | parked in `attic/c/` — emit-only, build from source | [../attic/c/glyph-codec/README.md](../attic/c/glyph-codec/README.md) | ## Shared Concepts @@ -70,7 +70,9 @@ fingerprint = glyph.fingerprint_loose(glyph.from_json(data)) Use the `glyph` module after installing `glyph-py`. The Python README is the current source of truth for the shipped Python surface. ### Go -The module is `github.com/Neumenon/glyph`. Import the codec package as: +**In-repo / source preview.** The Go codec is a full conformance implementation, but it is not yet a polished external module: the module lives under `go/`, and `go get github.com/Neumenon/glyph` / `go mod tidy` do not yet resolve cleanly (subdirectory layout plus an optional dev-only bridge that pulls an unpublished dependency). Use it from a checkout of this repo (`cd go && go build ./...`) until module packaging is stabilized. + +Within the module, the import path is: ```go import "github.com/Neumenon/glyph/glyph" diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md index cfcc9ba..605efa4 100644 --- a/docs/QUICKSTART.md +++ b/docs/QUICKSTART.md @@ -7,7 +7,7 @@ Get a verified feel for the codec in a few minutes. | Language | Command | |----------|---------| | Python | `pip install glyph-py` | -| Go | `go get github.com/Neumenon/glyph` | +| Go | in-repo / source preview — `cd go && go build ./...` (`go get` not yet a stable path) | | JavaScript / TypeScript | `npm install cowrie-glyph` | | Rust | parked in `attic/rust/glyph-codec/` — not published | | C | parked in `attic/c/glyph-codec/` — build from source | @@ -32,6 +32,12 @@ print(fp) ## Go +> **In-repo / source preview.** The Go module lives under `go/` and is a full +> conformance implementation, but external `go get github.com/Neumenon/glyph` +> does not yet resolve cleanly. Run this from a checkout of the repo +> (`cd go && go build ./...`) until the module packaging is stabilized — see the +> [Go README](../go/README.md). + ```go package main diff --git a/gauntlet/data/gauntlet-data.js b/gauntlet/data/gauntlet-data.js new file mode 100644 index 0000000..ba94724 --- /dev/null +++ b/gauntlet/data/gauntlet-data.js @@ -0,0 +1,955 @@ +window.GAUNTLET_DATA = { + "meta": { + "generatedNote": "All numbers from real codec execution. No values fabricated.", + "glyphPackage": "cowrie-glyph", + "node": "v22.20.0", + "tokenizer": "heuristic estimateTokens (whitespace split — NOT a real BPE tokenizer; token savings figures are illustrative only)" + }, + "edgeCases": [ + { + "name": "empty_str", + "jsonText": "\"\"", + "glyphText": "\"\"", + "note": "Empty string — needs quotes in glyph", + "roundTrip": "ok", + "roundTripNote": "JS round-trips: canonicalizeLoose <-> parseLoose is a fixed point." + }, + { + "name": "unicode", + "jsonText": "\"café ☕ λ\"", + "glyphText": "\"café ☕ λ\"", + "note": "Unicode: café ☕ λ", + "roundTrip": "ok", + "roundTripNote": "JS round-trips: canonicalizeLoose <-> parseLoose is a fixed point." + }, + { + "name": "embedded_quote", + "jsonText": "\"say \\\"hello\\\"\"", + "glyphText": "\"say \\\"hello\\\"\"", + "note": "Embedded double-quotes", + "roundTrip": "ok", + "roundTripNote": "JS round-trips: canonicalizeLoose <-> parseLoose is a fixed point." + }, + { + "name": "pipe", + "jsonText": "\"a|b|c\"", + "glyphText": "\"a|b|c\"", + "note": "Pipe chars — must be escaped in tabular cells", + "roundTrip": "ok", + "roundTripNote": "JS round-trips: canonicalizeLoose <-> parseLoose is a fixed point." + }, + { + "name": "newlines", + "jsonText": "\"line1\\nline2\\r\\nline3\"", + "glyphText": "\"line1\\nline2\\r\\nline3\"", + "note": "Embedded newlines", + "roundTrip": "ok", + "roundTripNote": "JS round-trips: canonicalizeLoose <-> parseLoose is a fixed point." + }, + { + "name": "null_value", + "jsonText": "null", + "glyphText": "_", + "note": "JSON null -> glyph _", + "roundTrip": "ok", + "roundTripNote": "JS round-trips: canonicalizeLoose <-> parseLoose is a fixed point." + }, + { + "name": "bool_true", + "jsonText": "true", + "glyphText": "t", + "note": "bool true -> t", + "roundTrip": "ok", + "roundTripNote": "JS round-trips: canonicalizeLoose <-> parseLoose is a fixed point." + }, + { + "name": "bool_false", + "jsonText": "false", + "glyphText": "f", + "note": "bool false -> f", + "roundTrip": "ok", + "roundTripNote": "JS round-trips: canonicalizeLoose <-> parseLoose is a fixed point." + }, + { + "name": "big_int", + "jsonText": "9007199254740992", + "glyphText": "9.007199254740992e+15", + "note": "Exceeds Number.MAX_SAFE_INTEGER — JS loses precision here; Go/Py handle correctly with int64", + "roundTrip": "ok", + "roundTripNote": "JS round-trips: canonicalizeLoose <-> parseLoose is a fixed point." + }, + { + "name": "float_sci", + "jsonText": "1.23e-9", + "glyphText": "1.23e-09", + "note": "Small float scientific notation", + "roundTrip": "ok", + "roundTripNote": "JS round-trips: canonicalizeLoose <-> parseLoose is a fixed point." + }, + { + "name": "neg_zero", + "jsonText": "0", + "glyphText": "0", + "note": "Negative zero -> 0.0 in glyph", + "roundTrip": "ok", + "roundTripNote": "JS round-trips: canonicalizeLoose <-> parseLoose is a fixed point." + }, + { + "name": "date_string", + "jsonText": "\"2024-03-15T12:00:00Z\"", + "glyphText": "\"2024-03-15T12:00:00Z\"", + "note": "ISO date string — stays as string in loose mode (no type inference)", + "roundTrip": "ok", + "roundTripNote": "JS round-trips: canonicalizeLoose <-> parseLoose is a fixed point." + }, + { + "name": "nested_list", + "jsonText": "[1,2,\"three\",null]", + "glyphText": "[1 2 three _]", + "note": "Mixed-type list", + "roundTrip": "ok", + "roundTripNote": "JS round-trips: canonicalizeLoose <-> parseLoose is a fixed point." + }, + { + "name": "nested_map", + "jsonText": "{\"a\":1,\"b\":{\"c\":2}}", + "glyphText": "{a=1 b={c=2}}", + "note": "Nested map", + "roundTrip": "ok", + "roundTripNote": "JS round-trips: canonicalizeLoose <-> parseLoose is a fixed point." + } + ], + "toolFirewall": { + "registryNote": "defaultToolRegistry includes: search, calculate, browse, execute, read_file, write_file. wire_transfer is absent — rejection is native to the default registry; no custom allowlist was needed.", + "allowed": { + "text": "{action=search query=\"latest weather in Chicago\" max_results=5}", + "totalChars": 63, + "toolName": "search", + "toolDetectedAtChar": 15, + "allowed": true, + "errors": [], + "timeline": [ + { + "event": "TOOL_DETECTED", + "token": 15, + "charPos": 15, + "elapsed": 1, + "detail": "tool=search allowed=true" + }, + { + "event": "COMPLETE", + "token": 63, + "charPos": 63, + "elapsed": 1, + "detail": "valid=true" + } + ] + }, + "blocked": { + "text": "{action=wire_transfer amount=1000000 target=unknown}", + "totalChars": 52, + "toolName": "wire_transfer", + "toolDetectedAtChar": 22, + "allowed": false, + "rejectAtChar": 22, + "bytesAvoided": 30, + "errors": [ + { + "code": "UNKNOWN_TOOL", + "message": "Unknown tool: wire_transfer", + "field": "action" + } + ], + "timeline": [ + { + "event": "TOOL_DETECTED", + "token": 22, + "charPos": 22, + "elapsed": 0, + "detail": "tool=wire_transfer allowed=false" + }, + { + "event": "ERROR", + "token": 22, + "charPos": 22, + "elapsed": 0, + "detail": "Unknown tool: wire_transfer" + }, + { + "event": "COMPLETE", + "token": 52, + "charPos": 52, + "elapsed": 0, + "detail": "valid=false" + } + ] + } + }, + "matchStream": { + "measurementNote": "Patch bytes measured using real emitPatch(PatchBuilder.build()). Each update patches 3 fields: minute, score_home, score_away. snapshotBytes = JSON.stringify(full snapshot). patchBytes = real @patch text bytes.", + "samplePatchText": "@patch @keys=wire @target=match:001\n= minute 45\n= score_away 0\n= score_home 1\n@end", + "totalUpdates": 100, + "cumSnapshotBytes": 12192, + "cumPatchBytes": 8192, + "savingsPct": 32.81, + "perUpdate": [ + { + "update": 1, + "minute": 1, + "snapshotBytes": 121, + "patchBytes": 81 + }, + { + "update": 2, + "minute": 2, + "snapshotBytes": 121, + "patchBytes": 81 + }, + { + "update": 3, + "minute": 3, + "snapshotBytes": 121, + "patchBytes": 81 + }, + { + "update": 4, + "minute": 4, + "snapshotBytes": 121, + "patchBytes": 81 + }, + { + "update": 5, + "minute": 5, + "snapshotBytes": 121, + "patchBytes": 81 + }, + { + "update": 6, + "minute": 6, + "snapshotBytes": 121, + "patchBytes": 81 + }, + { + "update": 7, + "minute": 7, + "snapshotBytes": 121, + "patchBytes": 81 + }, + { + "update": 8, + "minute": 8, + "snapshotBytes": 121, + "patchBytes": 81 + }, + { + "update": 9, + "minute": 9, + "snapshotBytes": 121, + "patchBytes": 81 + }, + { + "update": 10, + "minute": 10, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 11, + "minute": 11, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 12, + "minute": 12, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 13, + "minute": 13, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 14, + "minute": 14, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 15, + "minute": 15, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 16, + "minute": 16, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 17, + "minute": 17, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 18, + "minute": 18, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 19, + "minute": 19, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 20, + "minute": 20, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 21, + "minute": 21, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 22, + "minute": 22, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 23, + "minute": 23, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 24, + "minute": 24, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 25, + "minute": 25, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 26, + "minute": 26, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 27, + "minute": 27, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 28, + "minute": 28, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 29, + "minute": 29, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 30, + "minute": 30, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 31, + "minute": 31, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 32, + "minute": 32, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 33, + "minute": 33, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 34, + "minute": 34, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 35, + "minute": 35, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 36, + "minute": 36, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 37, + "minute": 37, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 38, + "minute": 38, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 39, + "minute": 39, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 40, + "minute": 40, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 41, + "minute": 41, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 42, + "minute": 42, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 43, + "minute": 43, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 44, + "minute": 44, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 45, + "minute": 45, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 46, + "minute": 46, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 47, + "minute": 47, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 48, + "minute": 48, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 49, + "minute": 49, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 50, + "minute": 50, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 51, + "minute": 51, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 52, + "minute": 52, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 53, + "minute": 53, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 54, + "minute": 54, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 55, + "minute": 55, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 56, + "minute": 56, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 57, + "minute": 57, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 58, + "minute": 58, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 59, + "minute": 59, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 60, + "minute": 60, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 61, + "minute": 61, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 62, + "minute": 62, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 63, + "minute": 63, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 64, + "minute": 64, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 65, + "minute": 65, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 66, + "minute": 66, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 67, + "minute": 67, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 68, + "minute": 68, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 69, + "minute": 69, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 70, + "minute": 70, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 71, + "minute": 71, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 72, + "minute": 72, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 73, + "minute": 73, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 74, + "minute": 74, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 75, + "minute": 75, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 76, + "minute": 76, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 77, + "minute": 77, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 78, + "minute": 78, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 79, + "minute": 79, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 80, + "minute": 80, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 81, + "minute": 81, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 82, + "minute": 82, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 83, + "minute": 83, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 84, + "minute": 84, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 85, + "minute": 85, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 86, + "minute": 86, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 87, + "minute": 87, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 88, + "minute": 88, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 89, + "minute": 89, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 90, + "minute": 90, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 91, + "minute": 91, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 92, + "minute": 92, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 93, + "minute": 93, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 94, + "minute": 94, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 95, + "minute": 95, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 96, + "minute": 96, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 97, + "minute": 97, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 98, + "minute": 98, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 99, + "minute": 99, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 100, + "minute": 100, + "snapshotBytes": 123, + "patchBytes": 83 + } + ] + }, + "tabular": [ + { + "rows": 10, + "jsonMinBytes": 1252, + "jsonPrettyBytes": 1793, + "glyphLooseBytes": 524, + "glyphTabularNote": "canonicalizeLoose auto-tabularizes; no separate loose emitTabular path exists.", + "tokens": { + "jsonMin": 1, + "glyphLoose": 23, + "tokenizerWarning": "heuristic whitespace split — not a real BPE tokenizer" + }, + "savingsBytesPct": 58.15, + "savingsTokensPct": -2200, + "glyphLoosePreview": "@tab _ rows=10 cols=8 [away home id minute score_away score_home status venue]\n|Team_11|Team_1|m0|0|1|0|live|Stadium_1|\n|Team_12|Team_2|m1|3|2|1|finished|Stadium_2|\n|Team_13|Team_3|m2|6|3|2|upcoming|S..." + }, + { + "rows": 100, + "jsonMinBytes": 12649, + "jsonPrettyBytes": 18050, + "glyphLooseBytes": 4632, + "glyphTabularNote": "canonicalizeLoose auto-tabularizes; no separate loose emitTabular path exists.", + "tokens": { + "jsonMin": 1, + "glyphLoose": 113, + "tokenizerWarning": "heuristic whitespace split — not a real BPE tokenizer" + }, + "savingsBytesPct": 63.38, + "savingsTokensPct": -11200, + "glyphLoosePreview": "@tab _ rows=100 cols=8 [away home id minute score_away score_home status venue]\n|Team_11|Team_1|m0|0|1|0|live|Stadium_1|\n|Team_12|Team_2|m1|3|2|1|finished|Stadium_2|\n|Team_13|Team_3|m2|6|3|2|upcoming|..." + }, + { + "rows": 1000, + "jsonMinBytes": 127519, + "jsonPrettyBytes": 181520, + "glyphLooseBytes": 46603, + "glyphTabularNote": "canonicalizeLoose auto-tabularizes; no separate loose emitTabular path exists.", + "tokens": { + "jsonMin": 1, + "glyphLoose": 1013, + "tokenizerWarning": "heuristic whitespace split — not a real BPE tokenizer" + }, + "savingsBytesPct": 63.45, + "savingsTokensPct": -101200, + "glyphLoosePreview": "@tab _ rows=1000 cols=8 [away home id minute score_away score_home status venue]\n|Team_11|Team_1|m0|0|1|0|live|Stadium_1|\n|Team_12|Team_2|m1|3|2|1|finished|Stadium_2|\n|Team_13|Team_3|m2|6|3|2|upcoming..." + }, + { + "rows": 10000, + "jsonMinBytes": 1285219, + "jsonPrettyBytes": 1825220, + "glyphLooseBytes": 475304, + "glyphTabularNote": "canonicalizeLoose auto-tabularizes; no separate loose emitTabular path exists.", + "tokens": { + "jsonMin": 1, + "glyphLoose": 10013, + "tokenizerWarning": "heuristic whitespace split — not a real BPE tokenizer" + }, + "savingsBytesPct": 63.02, + "savingsTokensPct": -1001200, + "glyphLoosePreview": "@tab _ rows=10000 cols=8 [away home id minute score_away score_home status venue]\n|Team_11|Team_1|m0|0|1|0|live|Stadium_1|\n|Team_12|Team_2|m1|3|2|1|finished|Stadium_2|\n|Team_13|Team_3|m2|6|3|2|upcomin..." + } + ], + "benchMatrix": [ + { + "dataset": "tinyToolCalls", + "formats": { + "jsonMin": { + "bytes": 8790, + "tokens": 201 + }, + "jsonPretty": { + "bytes": 13291, + "tokens": 1502 + }, + "glyphLoose": { + "bytes": 5636, + "tokens": 408 + } + }, + "savingsVsJsonMin": { + "bytesPct": 35.88, + "tokensPct": -102.99 + }, + "availableFormatsNote": "Only jsonMin, jsonPretty, glyphLoose measured. schemaful formats (packed, tabular-schemaful) omitted — they require a Schema definition not relevant to loose mode.", + "tokenizerWarning": "heuristic whitespace split — not a real BPE tokenizer" + }, + { + "dataset": "nestedAgentTrace", + "formats": { + "jsonMin": { + "bytes": 531, + "tokens": 5 + }, + "jsonPretty": { + "bytes": 1193, + "tokens": 89 + }, + "glyphLoose": { + "bytes": 434, + "tokens": 29 + } + }, + "savingsVsJsonMin": { + "bytesPct": 18.27, + "tokensPct": -480 + }, + "availableFormatsNote": "Only jsonMin, jsonPretty, glyphLoose measured. schemaful formats (packed, tabular-schemaful) omitted — they require a Schema definition not relevant to loose mode.", + "tokenizerWarning": "heuristic whitespace split — not a real BPE tokenizer" + }, + { + "dataset": "repeatedRows", + "formats": { + "jsonMin": { + "bytes": 127519, + "tokens": 1 + }, + "jsonPretty": { + "bytes": 181520, + "tokens": 18002 + }, + "glyphLoose": { + "bytes": 46603, + "tokens": 1013 + } + }, + "savingsVsJsonMin": { + "bytesPct": 63.45, + "tokensPct": -101200 + }, + "availableFormatsNote": "Only jsonMin, jsonPretty, glyphLoose measured. schemaful formats (packed, tabular-schemaful) omitted — they require a Schema definition not relevant to loose mode.", + "tokenizerWarning": "heuristic whitespace split — not a real BPE tokenizer" + }, + { + "dataset": "adversarialStrings", + "formats": { + "jsonMin": { + "bytes": 152, + "tokens": 2 + }, + "jsonPretty": { + "bytes": 198, + "tokens": 18 + }, + "glyphLoose": { + "bytes": 147, + "tokens": 16 + } + }, + "savingsVsJsonMin": { + "bytesPct": 3.29, + "tokensPct": -700 + }, + "availableFormatsNote": "Only jsonMin, jsonPretty, glyphLoose measured. schemaful formats (packed, tabular-schemaful) omitted — they require a Schema definition not relevant to loose mode.", + "tokenizerWarning": "heuristic whitespace split — not a real BPE tokenizer" + } + ], + "schemaHashNote": "Loose canonical mode is entirely schema-free. Field IDs (FIDs) and schema hashes are Go/schema concerns: in Go, structured types can carry FIDs (compact numeric field aliases) that appear in packed/tabular schemaful formats. In loose mode (canonicalizeLoose), all keys are emitted as plain strings — there are no FID substitutions, no @schema header, and no schema hash in the output. The FID/schema-hash trap (verifying that a decoded value actually matches its declared schema version) is exercised by the Go test suite against schemaful formats only." +}; diff --git a/gauntlet/data/gauntlet-data.json b/gauntlet/data/gauntlet-data.json new file mode 100644 index 0000000..7eca1de --- /dev/null +++ b/gauntlet/data/gauntlet-data.json @@ -0,0 +1,955 @@ +{ + "meta": { + "generatedNote": "All numbers from real codec execution. No values fabricated.", + "glyphPackage": "cowrie-glyph", + "node": "v22.20.0", + "tokenizer": "heuristic estimateTokens (whitespace split — NOT a real BPE tokenizer; token savings figures are illustrative only)" + }, + "edgeCases": [ + { + "name": "empty_str", + "jsonText": "\"\"", + "glyphText": "\"\"", + "note": "Empty string — needs quotes in glyph", + "roundTrip": "ok", + "roundTripNote": "JS round-trips: canonicalizeLoose <-> parseLoose is a fixed point." + }, + { + "name": "unicode", + "jsonText": "\"café ☕ λ\"", + "glyphText": "\"café ☕ λ\"", + "note": "Unicode: café ☕ λ", + "roundTrip": "ok", + "roundTripNote": "JS round-trips: canonicalizeLoose <-> parseLoose is a fixed point." + }, + { + "name": "embedded_quote", + "jsonText": "\"say \\\"hello\\\"\"", + "glyphText": "\"say \\\"hello\\\"\"", + "note": "Embedded double-quotes", + "roundTrip": "ok", + "roundTripNote": "JS round-trips: canonicalizeLoose <-> parseLoose is a fixed point." + }, + { + "name": "pipe", + "jsonText": "\"a|b|c\"", + "glyphText": "\"a|b|c\"", + "note": "Pipe chars — must be escaped in tabular cells", + "roundTrip": "ok", + "roundTripNote": "JS round-trips: canonicalizeLoose <-> parseLoose is a fixed point." + }, + { + "name": "newlines", + "jsonText": "\"line1\\nline2\\r\\nline3\"", + "glyphText": "\"line1\\nline2\\r\\nline3\"", + "note": "Embedded newlines", + "roundTrip": "ok", + "roundTripNote": "JS round-trips: canonicalizeLoose <-> parseLoose is a fixed point." + }, + { + "name": "null_value", + "jsonText": "null", + "glyphText": "_", + "note": "JSON null -> glyph _", + "roundTrip": "ok", + "roundTripNote": "JS round-trips: canonicalizeLoose <-> parseLoose is a fixed point." + }, + { + "name": "bool_true", + "jsonText": "true", + "glyphText": "t", + "note": "bool true -> t", + "roundTrip": "ok", + "roundTripNote": "JS round-trips: canonicalizeLoose <-> parseLoose is a fixed point." + }, + { + "name": "bool_false", + "jsonText": "false", + "glyphText": "f", + "note": "bool false -> f", + "roundTrip": "ok", + "roundTripNote": "JS round-trips: canonicalizeLoose <-> parseLoose is a fixed point." + }, + { + "name": "big_int", + "jsonText": "9007199254740992", + "glyphText": "9.007199254740992e+15", + "note": "Exceeds Number.MAX_SAFE_INTEGER — JS loses precision here; Go/Py handle correctly with int64", + "roundTrip": "ok", + "roundTripNote": "JS round-trips: canonicalizeLoose <-> parseLoose is a fixed point." + }, + { + "name": "float_sci", + "jsonText": "1.23e-9", + "glyphText": "1.23e-09", + "note": "Small float scientific notation", + "roundTrip": "ok", + "roundTripNote": "JS round-trips: canonicalizeLoose <-> parseLoose is a fixed point." + }, + { + "name": "neg_zero", + "jsonText": "0", + "glyphText": "0", + "note": "Negative zero -> 0.0 in glyph", + "roundTrip": "ok", + "roundTripNote": "JS round-trips: canonicalizeLoose <-> parseLoose is a fixed point." + }, + { + "name": "date_string", + "jsonText": "\"2024-03-15T12:00:00Z\"", + "glyphText": "\"2024-03-15T12:00:00Z\"", + "note": "ISO date string — stays as string in loose mode (no type inference)", + "roundTrip": "ok", + "roundTripNote": "JS round-trips: canonicalizeLoose <-> parseLoose is a fixed point." + }, + { + "name": "nested_list", + "jsonText": "[1,2,\"three\",null]", + "glyphText": "[1 2 three _]", + "note": "Mixed-type list", + "roundTrip": "ok", + "roundTripNote": "JS round-trips: canonicalizeLoose <-> parseLoose is a fixed point." + }, + { + "name": "nested_map", + "jsonText": "{\"a\":1,\"b\":{\"c\":2}}", + "glyphText": "{a=1 b={c=2}}", + "note": "Nested map", + "roundTrip": "ok", + "roundTripNote": "JS round-trips: canonicalizeLoose <-> parseLoose is a fixed point." + } + ], + "toolFirewall": { + "registryNote": "defaultToolRegistry includes: search, calculate, browse, execute, read_file, write_file. wire_transfer is absent — rejection is native to the default registry; no custom allowlist was needed.", + "allowed": { + "text": "{action=search query=\"latest weather in Chicago\" max_results=5}", + "totalChars": 63, + "toolName": "search", + "toolDetectedAtChar": 15, + "allowed": true, + "errors": [], + "timeline": [ + { + "event": "TOOL_DETECTED", + "token": 15, + "charPos": 15, + "elapsed": 1, + "detail": "tool=search allowed=true" + }, + { + "event": "COMPLETE", + "token": 63, + "charPos": 63, + "elapsed": 1, + "detail": "valid=true" + } + ] + }, + "blocked": { + "text": "{action=wire_transfer amount=1000000 target=unknown}", + "totalChars": 52, + "toolName": "wire_transfer", + "toolDetectedAtChar": 22, + "allowed": false, + "rejectAtChar": 22, + "bytesAvoided": 30, + "errors": [ + { + "code": "UNKNOWN_TOOL", + "message": "Unknown tool: wire_transfer", + "field": "action" + } + ], + "timeline": [ + { + "event": "TOOL_DETECTED", + "token": 22, + "charPos": 22, + "elapsed": 0, + "detail": "tool=wire_transfer allowed=false" + }, + { + "event": "ERROR", + "token": 22, + "charPos": 22, + "elapsed": 0, + "detail": "Unknown tool: wire_transfer" + }, + { + "event": "COMPLETE", + "token": 52, + "charPos": 52, + "elapsed": 0, + "detail": "valid=false" + } + ] + } + }, + "matchStream": { + "measurementNote": "Patch bytes measured using real emitPatch(PatchBuilder.build()). Each update patches 3 fields: minute, score_home, score_away. snapshotBytes = JSON.stringify(full snapshot). patchBytes = real @patch text bytes.", + "samplePatchText": "@patch @keys=wire @target=match:001\n= minute 45\n= score_away 0\n= score_home 1\n@end", + "totalUpdates": 100, + "cumSnapshotBytes": 12192, + "cumPatchBytes": 8192, + "savingsPct": 32.81, + "perUpdate": [ + { + "update": 1, + "minute": 1, + "snapshotBytes": 121, + "patchBytes": 81 + }, + { + "update": 2, + "minute": 2, + "snapshotBytes": 121, + "patchBytes": 81 + }, + { + "update": 3, + "minute": 3, + "snapshotBytes": 121, + "patchBytes": 81 + }, + { + "update": 4, + "minute": 4, + "snapshotBytes": 121, + "patchBytes": 81 + }, + { + "update": 5, + "minute": 5, + "snapshotBytes": 121, + "patchBytes": 81 + }, + { + "update": 6, + "minute": 6, + "snapshotBytes": 121, + "patchBytes": 81 + }, + { + "update": 7, + "minute": 7, + "snapshotBytes": 121, + "patchBytes": 81 + }, + { + "update": 8, + "minute": 8, + "snapshotBytes": 121, + "patchBytes": 81 + }, + { + "update": 9, + "minute": 9, + "snapshotBytes": 121, + "patchBytes": 81 + }, + { + "update": 10, + "minute": 10, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 11, + "minute": 11, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 12, + "minute": 12, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 13, + "minute": 13, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 14, + "minute": 14, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 15, + "minute": 15, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 16, + "minute": 16, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 17, + "minute": 17, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 18, + "minute": 18, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 19, + "minute": 19, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 20, + "minute": 20, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 21, + "minute": 21, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 22, + "minute": 22, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 23, + "minute": 23, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 24, + "minute": 24, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 25, + "minute": 25, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 26, + "minute": 26, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 27, + "minute": 27, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 28, + "minute": 28, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 29, + "minute": 29, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 30, + "minute": 30, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 31, + "minute": 31, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 32, + "minute": 32, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 33, + "minute": 33, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 34, + "minute": 34, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 35, + "minute": 35, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 36, + "minute": 36, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 37, + "minute": 37, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 38, + "minute": 38, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 39, + "minute": 39, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 40, + "minute": 40, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 41, + "minute": 41, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 42, + "minute": 42, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 43, + "minute": 43, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 44, + "minute": 44, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 45, + "minute": 45, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 46, + "minute": 46, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 47, + "minute": 47, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 48, + "minute": 48, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 49, + "minute": 49, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 50, + "minute": 50, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 51, + "minute": 51, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 52, + "minute": 52, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 53, + "minute": 53, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 54, + "minute": 54, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 55, + "minute": 55, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 56, + "minute": 56, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 57, + "minute": 57, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 58, + "minute": 58, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 59, + "minute": 59, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 60, + "minute": 60, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 61, + "minute": 61, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 62, + "minute": 62, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 63, + "minute": 63, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 64, + "minute": 64, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 65, + "minute": 65, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 66, + "minute": 66, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 67, + "minute": 67, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 68, + "minute": 68, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 69, + "minute": 69, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 70, + "minute": 70, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 71, + "minute": 71, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 72, + "minute": 72, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 73, + "minute": 73, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 74, + "minute": 74, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 75, + "minute": 75, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 76, + "minute": 76, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 77, + "minute": 77, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 78, + "minute": 78, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 79, + "minute": 79, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 80, + "minute": 80, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 81, + "minute": 81, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 82, + "minute": 82, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 83, + "minute": 83, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 84, + "minute": 84, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 85, + "minute": 85, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 86, + "minute": 86, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 87, + "minute": 87, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 88, + "minute": 88, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 89, + "minute": 89, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 90, + "minute": 90, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 91, + "minute": 91, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 92, + "minute": 92, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 93, + "minute": 93, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 94, + "minute": 94, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 95, + "minute": 95, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 96, + "minute": 96, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 97, + "minute": 97, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 98, + "minute": 98, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 99, + "minute": 99, + "snapshotBytes": 122, + "patchBytes": 82 + }, + { + "update": 100, + "minute": 100, + "snapshotBytes": 123, + "patchBytes": 83 + } + ] + }, + "tabular": [ + { + "rows": 10, + "jsonMinBytes": 1252, + "jsonPrettyBytes": 1793, + "glyphLooseBytes": 524, + "glyphTabularNote": "canonicalizeLoose auto-tabularizes; no separate loose emitTabular path exists.", + "tokens": { + "jsonMin": 1, + "glyphLoose": 23, + "tokenizerWarning": "heuristic whitespace split — not a real BPE tokenizer" + }, + "savingsBytesPct": 58.15, + "savingsTokensPct": -2200, + "glyphLoosePreview": "@tab _ rows=10 cols=8 [away home id minute score_away score_home status venue]\n|Team_11|Team_1|m0|0|1|0|live|Stadium_1|\n|Team_12|Team_2|m1|3|2|1|finished|Stadium_2|\n|Team_13|Team_3|m2|6|3|2|upcoming|S..." + }, + { + "rows": 100, + "jsonMinBytes": 12649, + "jsonPrettyBytes": 18050, + "glyphLooseBytes": 4632, + "glyphTabularNote": "canonicalizeLoose auto-tabularizes; no separate loose emitTabular path exists.", + "tokens": { + "jsonMin": 1, + "glyphLoose": 113, + "tokenizerWarning": "heuristic whitespace split — not a real BPE tokenizer" + }, + "savingsBytesPct": 63.38, + "savingsTokensPct": -11200, + "glyphLoosePreview": "@tab _ rows=100 cols=8 [away home id minute score_away score_home status venue]\n|Team_11|Team_1|m0|0|1|0|live|Stadium_1|\n|Team_12|Team_2|m1|3|2|1|finished|Stadium_2|\n|Team_13|Team_3|m2|6|3|2|upcoming|..." + }, + { + "rows": 1000, + "jsonMinBytes": 127519, + "jsonPrettyBytes": 181520, + "glyphLooseBytes": 46603, + "glyphTabularNote": "canonicalizeLoose auto-tabularizes; no separate loose emitTabular path exists.", + "tokens": { + "jsonMin": 1, + "glyphLoose": 1013, + "tokenizerWarning": "heuristic whitespace split — not a real BPE tokenizer" + }, + "savingsBytesPct": 63.45, + "savingsTokensPct": -101200, + "glyphLoosePreview": "@tab _ rows=1000 cols=8 [away home id minute score_away score_home status venue]\n|Team_11|Team_1|m0|0|1|0|live|Stadium_1|\n|Team_12|Team_2|m1|3|2|1|finished|Stadium_2|\n|Team_13|Team_3|m2|6|3|2|upcoming..." + }, + { + "rows": 10000, + "jsonMinBytes": 1285219, + "jsonPrettyBytes": 1825220, + "glyphLooseBytes": 475304, + "glyphTabularNote": "canonicalizeLoose auto-tabularizes; no separate loose emitTabular path exists.", + "tokens": { + "jsonMin": 1, + "glyphLoose": 10013, + "tokenizerWarning": "heuristic whitespace split — not a real BPE tokenizer" + }, + "savingsBytesPct": 63.02, + "savingsTokensPct": -1001200, + "glyphLoosePreview": "@tab _ rows=10000 cols=8 [away home id minute score_away score_home status venue]\n|Team_11|Team_1|m0|0|1|0|live|Stadium_1|\n|Team_12|Team_2|m1|3|2|1|finished|Stadium_2|\n|Team_13|Team_3|m2|6|3|2|upcomin..." + } + ], + "benchMatrix": [ + { + "dataset": "tinyToolCalls", + "formats": { + "jsonMin": { + "bytes": 8790, + "tokens": 201 + }, + "jsonPretty": { + "bytes": 13291, + "tokens": 1502 + }, + "glyphLoose": { + "bytes": 5636, + "tokens": 408 + } + }, + "savingsVsJsonMin": { + "bytesPct": 35.88, + "tokensPct": -102.99 + }, + "availableFormatsNote": "Only jsonMin, jsonPretty, glyphLoose measured. schemaful formats (packed, tabular-schemaful) omitted — they require a Schema definition not relevant to loose mode.", + "tokenizerWarning": "heuristic whitespace split — not a real BPE tokenizer" + }, + { + "dataset": "nestedAgentTrace", + "formats": { + "jsonMin": { + "bytes": 531, + "tokens": 5 + }, + "jsonPretty": { + "bytes": 1193, + "tokens": 89 + }, + "glyphLoose": { + "bytes": 434, + "tokens": 29 + } + }, + "savingsVsJsonMin": { + "bytesPct": 18.27, + "tokensPct": -480 + }, + "availableFormatsNote": "Only jsonMin, jsonPretty, glyphLoose measured. schemaful formats (packed, tabular-schemaful) omitted — they require a Schema definition not relevant to loose mode.", + "tokenizerWarning": "heuristic whitespace split — not a real BPE tokenizer" + }, + { + "dataset": "repeatedRows", + "formats": { + "jsonMin": { + "bytes": 127519, + "tokens": 1 + }, + "jsonPretty": { + "bytes": 181520, + "tokens": 18002 + }, + "glyphLoose": { + "bytes": 46603, + "tokens": 1013 + } + }, + "savingsVsJsonMin": { + "bytesPct": 63.45, + "tokensPct": -101200 + }, + "availableFormatsNote": "Only jsonMin, jsonPretty, glyphLoose measured. schemaful formats (packed, tabular-schemaful) omitted — they require a Schema definition not relevant to loose mode.", + "tokenizerWarning": "heuristic whitespace split — not a real BPE tokenizer" + }, + { + "dataset": "adversarialStrings", + "formats": { + "jsonMin": { + "bytes": 152, + "tokens": 2 + }, + "jsonPretty": { + "bytes": 198, + "tokens": 18 + }, + "glyphLoose": { + "bytes": 147, + "tokens": 16 + } + }, + "savingsVsJsonMin": { + "bytesPct": 3.29, + "tokensPct": -700 + }, + "availableFormatsNote": "Only jsonMin, jsonPretty, glyphLoose measured. schemaful formats (packed, tabular-schemaful) omitted — they require a Schema definition not relevant to loose mode.", + "tokenizerWarning": "heuristic whitespace split — not a real BPE tokenizer" + } + ], + "schemaHashNote": "Loose canonical mode is entirely schema-free. Field IDs (FIDs) and schema hashes are Go/schema concerns: in Go, structured types can carry FIDs (compact numeric field aliases) that appear in packed/tabular schemaful formats. In loose mode (canonicalizeLoose), all keys are emitted as plain strings — there are no FID substitutions, no @schema header, and no schema hash in the output. The FID/schema-hash trap (verifying that a decoded value actually matches its declared schema version) is exercised by the Go test suite against schemaful formats only." +} \ No newline at end of file diff --git a/gauntlet/harness/measure.js b/gauntlet/harness/measure.js new file mode 100644 index 0000000..674a215 --- /dev/null +++ b/gauntlet/harness/measure.js @@ -0,0 +1,496 @@ +'use strict'; + +/** + * Glyph Gauntlet — Data Foundation Harness + * + * Produces gauntlet-data.json and gauntlet-data.js from the real codec. + * Every number here comes from the actual codec output; nothing is fabricated. + * + * JS loose round-trip note: + * The JS codec is now bidirectional in loose mode: + * JSON value -> fromJsonLoose -> GValue -> canonicalizeLoose -> glyph text + * glyph text -> parseLoose -> GValue (the inverse; parity with Go/Python) + * Each edge case below records its REAL round-trip status by re-parsing the + * emitted glyph text and checking canonical idempotence — no fake round-trip. + */ + +const fs = require('fs'); +const path = require('path'); + +const { + canonicalizeLoose, + canonicalizeLooseNoTabular, + fromJsonLoose, + parseLoose, + parseJsonLoose, + toJsonLoose, + estimateTokens, + StreamingValidator, + ToolRegistry, + defaultToolRegistry, + emitPatch, + parsePatch, + applyPatch, + fingerprintLoose, + g, + field, + PatchBuilder, +} = require('../../js/dist/index.js'); + +// ============================================================ +// Utilities +// ============================================================ + +function byteLen(s) { + return Buffer.byteLength(s, 'utf8'); +} + +function savingsPct(baseBytes, newBytes) { + if (baseBytes === 0) return 0; + return parseFloat(((1 - newBytes / baseBytes) * 100).toFixed(2)); +} + +function makeMatchRow(i) { + return { + id: `m${i}`, + home: `Team_${(i % 20) + 1}`, + away: `Team_${((i + 10) % 20) + 1}`, + score_home: i % 5, + score_away: (i + 1) % 4, + minute: (i * 3) % 90, + status: i % 3 === 0 ? 'live' : (i % 3 === 1 ? 'finished' : 'upcoming'), + venue: `Stadium_${(i % 8) + 1}`, + }; +} + +// ============================================================ +// SECTION: meta +// ============================================================ + +const meta = { + generatedNote: 'All numbers from real codec execution. No values fabricated.', + glyphPackage: 'cowrie-glyph', + node: process.version, + tokenizer: 'heuristic estimateTokens (whitespace split — NOT a real BPE tokenizer; token savings figures are illustrative only)', +}; + +// ============================================================ +// SECTION: edgeCases +// ============================================================ + +function measureEdgeCase(name, jsValue, note) { + let jsonText, glyphText; + let roundTrip = 'ok'; + let roundTripNote; + try { + jsonText = JSON.stringify(jsValue); + } catch (e) { + jsonText = ``; + } + try { + const gv = fromJsonLoose(jsValue); + glyphText = canonicalizeLoose(gv); + } catch (e) { + glyphText = ``; + } + // Real round-trip: parseLoose now inverts canonicalizeLoose in JS (parity with + // Go ParseDocument / Python parse). The invariant is canonical idempotence — + // re-emitting the parsed value must reproduce the exact same glyph text. + try { + const reparsed = parseLoose(glyphText); + const reText = canonicalizeLoose(reparsed); + if (reText === glyphText) { + roundTrip = 'ok'; + roundTripNote = 'JS round-trips: canonicalizeLoose <-> parseLoose is a fixed point.'; + } else { + roundTrip = 'lossy'; + roundTripNote = `parseLoose re-emit differs: ${reText}`; + } + } catch (e) { + roundTrip = 'unsupported'; + roundTripNote = `parseLoose error: ${e.message}`; + } + return { name, jsonText, glyphText, note, roundTrip, roundTripNote }; +} + +const edgeCases = [ + measureEdgeCase('empty_str', '', 'Empty string — needs quotes in glyph'), + measureEdgeCase('unicode', 'café ☕ λ', 'Unicode: café ☕ λ'), + measureEdgeCase('embedded_quote', 'say "hello"', 'Embedded double-quotes'), + measureEdgeCase('pipe', 'a|b|c', 'Pipe chars — must be escaped in tabular cells'), + measureEdgeCase('newlines', 'line1\nline2\r\nline3', 'Embedded newlines'), + measureEdgeCase('null_value', null, 'JSON null -> glyph _'), + measureEdgeCase('bool_true', true, 'bool true -> t'), + measureEdgeCase('bool_false', false, 'bool false -> f'), + // 9007199254740993 is Number.MAX_SAFE_INTEGER + 1 — precision loss in JS + measureEdgeCase('big_int', 9007199254740993, 'Exceeds Number.MAX_SAFE_INTEGER — JS loses precision here; Go/Py handle correctly with int64'), + measureEdgeCase('float_sci', 1.23e-9, 'Small float scientific notation'), + measureEdgeCase('neg_zero', -0, 'Negative zero -> 0.0 in glyph'), + measureEdgeCase('date_string', '2024-03-15T12:00:00Z', 'ISO date string — stays as string in loose mode (no type inference)'), + measureEdgeCase('nested_list', [1, 2, 'three', null], 'Mixed-type list'), + measureEdgeCase('nested_map', { a: 1, b: { c: 2 } }, 'Nested map'), +]; + +// ============================================================ +// SECTION: toolFirewall +// ============================================================ + +function measureFirewall() { + // defaultToolRegistry has: search, calculate, browse, execute, read_file, write_file + // wire_transfer is NOT in that registry — rejection is native + const registry = defaultToolRegistry(); + + const allowedText = '{action=search query="latest weather in Chicago" max_results=5}'; + const blockedText = '{action=wire_transfer amount=1000000 target=unknown}'; + + // --- allowed stream --- + const allowedSV = new StreamingValidator(registry); + let allowedResult; + for (const c of allowedText) { + allowedResult = allowedSV.pushToken(c); + } + + // --- blocked stream --- + const blockedSV = new StreamingValidator(registry); + let blockedResult; + let rejectChar = null; + for (const c of blockedText) { + blockedResult = blockedSV.pushToken(c); + if (rejectChar === null && blockedSV.shouldStop()) { + rejectChar = blockedResult.charCount; + } + } + + const totalBlockedChars = byteLen(blockedText); // chars == bytes for ASCII + const bytesAvoided = rejectChar !== null ? totalBlockedChars - rejectChar : 0; + + return { + registryNote: 'defaultToolRegistry includes: search, calculate, browse, execute, read_file, write_file. wire_transfer is absent — rejection is native to the default registry; no custom allowlist was needed.', + allowed: { + text: allowedText, + totalChars: allowedText.length, + toolName: allowedResult.toolName, + toolDetectedAtChar: allowedResult.toolDetectedAtChar, + allowed: allowedResult.toolAllowed, + errors: allowedResult.errors, + timeline: allowedResult.timeline, + }, + blocked: { + text: blockedText, + totalChars: blockedText.length, + toolName: blockedResult.toolName, + toolDetectedAtChar: blockedResult.toolDetectedAtChar, + allowed: blockedResult.toolAllowed, + rejectAtChar: rejectChar, + bytesAvoided, + errors: blockedResult.errors, + timeline: blockedResult.timeline, + }, + }; +} + +const toolFirewall = measureFirewall(); + +// ============================================================ +// SECTION: matchStream +// ============================================================ + +function measureMatchStream() { + // Base match snapshot + const baseSnap = { + id: 'match_001', + home: 'Arsenal', + away: 'Chelsea', + score_home: 0, + score_away: 0, + minute: 0, + status: 'live', + events: [], + }; + + // We use emitPatch from the real codec. + // PatchBuilder requires a RefID target. + // The JS PatchBuilder takes a RefID: {prefix, value} + const matchRef = { prefix: 'match', value: '001' }; + + const perUpdate = []; + let cumSnapshotBytes = 0; + let cumPatchBytes = 0; + + let currentSnap = JSON.parse(JSON.stringify(baseSnap)); + + const N_UPDATES = 100; + for (let i = 0; i < N_UPDATES; i++) { + // Simulate a live match update + const newMinute = i + 1; + const newScoreHome = Math.floor(i / 20); + const newScoreAway = Math.floor(i / 25); + + // Build snapshot + currentSnap = { + ...currentSnap, + minute: newMinute, + score_home: newScoreHome, + score_away: newScoreAway, + }; + const snapJSON = JSON.stringify(currentSnap); + const snapBytes = byteLen(snapJSON); + cumSnapshotBytes += snapBytes; + + // Build a real glyph patch using PatchBuilder + let patchText, patchBytes; + try { + const builder = new PatchBuilder(matchRef); + builder.set('minute', g.int(newMinute)); + builder.set('score_home', g.int(newScoreHome)); + builder.set('score_away', g.int(newScoreAway)); + const patch = builder.build(); + patchText = emitPatch(patch); + patchBytes = byteLen(patchText); + } catch (e) { + patchText = ``; + patchBytes = 0; + } + cumPatchBytes += patchBytes; + + perUpdate.push({ + update: i + 1, + minute: newMinute, + snapshotBytes: snapBytes, + patchBytes, + }); + } + + // Sample patch text for documentation + let samplePatchText = ''; + try { + const builder = new PatchBuilder(matchRef); + builder.set('minute', g.int(45)); + builder.set('score_home', g.int(1)); + builder.set('score_away', g.int(0)); + const patch = builder.build(); + samplePatchText = emitPatch(patch); + } catch (e) { + samplePatchText = ``; + } + + const savingsPctVal = savingsPct(cumSnapshotBytes, cumPatchBytes); + + return { + measurementNote: 'Patch bytes measured using real emitPatch(PatchBuilder.build()). Each update patches 3 fields: minute, score_home, score_away. snapshotBytes = JSON.stringify(full snapshot). patchBytes = real @patch text bytes.', + samplePatchText, + totalUpdates: N_UPDATES, + cumSnapshotBytes, + cumPatchBytes, + savingsPct: savingsPctVal, + perUpdate, + }; +} + +const matchStream = measureMatchStream(); + +// ============================================================ +// SECTION: tabular +// ============================================================ + +function measureTabular(rowCounts) { + return rowCounts.map(rows => { + const data = []; + for (let i = 0; i < rows; i++) { + data.push(makeMatchRow(i)); + } + + const jsonMin = JSON.stringify(data); + const jsonPretty = JSON.stringify(data, null, 2); + + // glyph loose — canonicalizeLoose auto-tabularizes for homogeneous arrays + const gv = fromJsonLoose(data); + const glyphLoose = canonicalizeLoose(gv); + + // Note: canonicalizeLoose already auto-tabularizes. + // There is no separate emitTabular path for loose mode — + // the same function handles it via defaultLooseCanonOpts().autoTabular=true. + const glyphTabularNote = 'canonicalizeLoose auto-tabularizes; no separate loose emitTabular path exists.'; + + const jsonMinBytes = byteLen(jsonMin); + const jsonPrettyBytes = byteLen(jsonPretty); + const glyphLooseBytes = byteLen(glyphLoose); + + const tokensJsonMin = estimateTokens(jsonMin); + const tokensGlyphLoose = estimateTokens(glyphLoose); + + return { + rows, + jsonMinBytes, + jsonPrettyBytes, + glyphLooseBytes, + glyphTabularNote, + tokens: { + jsonMin: tokensJsonMin, + glyphLoose: tokensGlyphLoose, + tokenizerWarning: 'heuristic whitespace split — not a real BPE tokenizer', + }, + savingsBytesPct: savingsPct(jsonMinBytes, glyphLooseBytes), + savingsTokensPct: savingsPct(tokensJsonMin, tokensGlyphLoose), + // First 200 chars of glyph output for inspection + glyphLoosePreview: glyphLoose.slice(0, 200) + (glyphLoose.length > 200 ? '...' : ''), + }; + }); +} + +const tabular = measureTabular([10, 100, 1000, 10000]); + +// ============================================================ +// SECTION: benchMatrix +// ============================================================ + +function buildDatasets() { + // tinyToolCalls: array of ~100 tool-call objects + const tinyToolCalls = []; + for (let i = 0; i < 100; i++) { + tinyToolCalls.push({ + tool: i % 3 === 0 ? 'search' : (i % 3 === 1 ? 'calculate' : 'browse'), + args: { + query: `query number ${i}`, + max_results: (i % 10) + 1, + }, + call_id: `tc_${i}`, + }); + } + + // nestedAgentTrace: one deeply nested object (10 levels) + function makeNested(depth) { + if (depth === 0) return { value: 42, leaf: true }; + return { level: depth, child: makeNested(depth - 1), metadata: `level_${depth}` }; + } + const nestedAgentTrace = { + trace_id: 'agent_trace_001', + steps: 10, + root: makeNested(10), + summary: 'deep nested agent execution trace', + }; + + // repeatedRows: 1000 homogeneous match rows + const repeatedRows = []; + for (let i = 0; i < 1000; i++) { + repeatedRows.push(makeMatchRow(i)); + } + + // adversarialStrings: values with edge-case characters + const adversarialStrings = [ + '', + '"quoted"', + 'back\\slash', + 'a|b|c', + 'éλ☕\u{1F600}', + true, + false, + null, + 9007199254740993, + 'SGVsbG8gV29ybGQ=', // base64-looking + '{"key": "value"}', // JSON-looking string + '42', // number-looking string + '-0', + 'true', // bool-looking string + 'null', // null-looking string + ]; + + return { tinyToolCalls, nestedAgentTrace, repeatedRows, adversarialStrings }; +} + +function measureDataset(name, data) { + const jsonMin = JSON.stringify(data); + const jsonPretty = JSON.stringify(data, null, 2); + + let glyphLoose, glyphLooseBytes, tokensGlyph; + try { + const gv = fromJsonLoose(data); + glyphLoose = canonicalizeLoose(gv); + glyphLooseBytes = byteLen(glyphLoose); + tokensGlyph = estimateTokens(glyphLoose); + } catch (e) { + glyphLoose = ``; + glyphLooseBytes = 0; + tokensGlyph = 0; + } + + const jsonMinBytes = byteLen(jsonMin); + const jsonPrettyBytes = byteLen(jsonPretty); + const tokensJsonMin = estimateTokens(jsonMin); + const tokensJsonPretty = estimateTokens(jsonPretty); + + return { + dataset: name, + formats: { + jsonMin: { bytes: jsonMinBytes, tokens: tokensJsonMin }, + jsonPretty: { bytes: jsonPrettyBytes, tokens: tokensJsonPretty }, + glyphLoose: { bytes: glyphLooseBytes, tokens: tokensGlyph }, + }, + savingsVsJsonMin: { + bytesPct: savingsPct(jsonMinBytes, glyphLooseBytes), + tokensPct: savingsPct(tokensJsonMin, tokensGlyph), + }, + availableFormatsNote: 'Only jsonMin, jsonPretty, glyphLoose measured. schemaful formats (packed, tabular-schemaful) omitted — they require a Schema definition not relevant to loose mode.', + tokenizerWarning: 'heuristic whitespace split — not a real BPE tokenizer', + }; +} + +const { tinyToolCalls, nestedAgentTrace, repeatedRows, adversarialStrings } = buildDatasets(); + +const benchMatrix = [ + measureDataset('tinyToolCalls', tinyToolCalls), + measureDataset('nestedAgentTrace', nestedAgentTrace), + measureDataset('repeatedRows', repeatedRows), + measureDataset('adversarialStrings', adversarialStrings), +]; + +// ============================================================ +// SECTION: schemaHashNote +// ============================================================ + +const schemaHashNote = + 'Loose canonical mode is entirely schema-free. Field IDs (FIDs) and schema hashes are Go/schema concerns: ' + + 'in Go, structured types can carry FIDs (compact numeric field aliases) that appear in packed/tabular schemaful ' + + 'formats. In loose mode (canonicalizeLoose), all keys are emitted as plain strings — there are no FID substitutions, ' + + 'no @schema header, and no schema hash in the output. The FID/schema-hash trap (verifying that a decoded value ' + + 'actually matches its declared schema version) is exercised by the Go test suite against schemaful formats only.'; + +// ============================================================ +// Assemble + Write +// ============================================================ + +const gauntletData = { + meta, + edgeCases, + toolFirewall, + matchStream, + tabular, + benchMatrix, + schemaHashNote, +}; + +const dataDir = path.join(__dirname, '..', 'data'); +const jsonPath = path.join(dataDir, 'gauntlet-data.json'); +const jsPath = path.join(dataDir, 'gauntlet-data.js'); + +const jsonText = JSON.stringify(gauntletData, null, 2); +fs.writeFileSync(jsonPath, jsonText, 'utf8'); +fs.writeFileSync(jsPath, `window.GAUNTLET_DATA = ${jsonText};\n`, 'utf8'); + +console.log('Written:', jsonPath); +console.log('Written:', jsPath); + +// ============================================================ +// Headline numbers +// ============================================================ +console.log('\n=== HEADLINE NUMBERS ==='); +const tab100 = tabular.find(t => t.rows === 100); +console.log(`Tabular savings (100 rows, bytes): ${tab100.savingsBytesPct}% (jsonMin=${tab100.jsonMinBytes}B -> glyph=${tab100.glyphLooseBytes}B)`); +const tab50 = tabular.find(t => t.rows === 10); // closest to documented 50-row example +console.log(`Tabular savings (10 rows, bytes): ${tab50.savingsBytesPct}% (jsonMin=${tab50.jsonMinBytes}B -> glyph=${tab50.glyphLooseBytes}B)`); +console.log(`Patch savings vs snapshot (${matchStream.totalUpdates} updates): ${matchStream.savingsPct}%`); +console.log(`Firewall: wire_transfer rejected at char ${toolFirewall.blocked.rejectAtChar} / ${toolFirewall.blocked.totalChars}, bytesAvoided=${toolFirewall.blocked.bytesAvoided}`); +const benchRepeated = benchMatrix.find(b => b.dataset === 'repeatedRows'); +console.log(`BenchMatrix repeatedRows (1000 rows): jsonMin=${benchRepeated.formats.jsonMin.bytes}B -> glyph=${benchRepeated.formats.glyphLoose.bytes}B (${benchRepeated.savingsVsJsonMin.bytesPct}% savings)`); +const benchTool = benchMatrix.find(b => b.dataset === 'tinyToolCalls'); +console.log(`BenchMatrix tinyToolCalls (100 items): jsonMin=${benchTool.formats.jsonMin.bytes}B -> glyph=${benchTool.formats.glyphLoose.bytes}B (${benchTool.savingsVsJsonMin.bytesPct}% savings)`); diff --git a/gauntlet/index.html b/gauntlet/index.html new file mode 100644 index 0000000..5c95b97 --- /dev/null +++ b/gauntlet/index.html @@ -0,0 +1,1019 @@ + + + + + +Glyph Gauntlet + + + + + + + + + + + + + +
+
+
A three-level evaluation of the cowrie-glyph codec
+

The Glyph Gauntlet

+

+ Three levels: does it round-trip, does it obviously beat JSON, and by how many bytes? +

+

All numbers computed live from the real cowrie-glyph codec — no values fabricated.

+ + +
+
+
Tabular savings
+
+
vs JSON min (1k rows)
+
+
+
Patch savings
+
+
vs full snapshots
+
+
+
Firewall detect
+
+
char to tool detect (blocked)
+
+
+
Bytes avoided
+
+
by early rejection
+
+
+
+
+ + +
+
+
Level 1
+

Correctness Gauntlet

+

Museum of edge cases — every record encoded from JSON to glyph by the real codec. Round-trip status is honest about the JS loose-text gap.

+ + +
+ +
+ + +
+
Schema Hash / FID Trap
+

+
+
+
+ + +
+
+
Level 2
+

Dramatic Demos

+ + +
+
+ Act I +

Tool-Call Firewall LIVE

+
+

Stream a glyph tool-call char-by-char through the real StreamingValidator. Watch it detect the tool name, then accept or reject.

+ +
+ + +
+ +
+
Click a button to stream a tool call...
+
+ Tool: — + Detected at char: — + Status: — +
+
+
+ + +
+
+ Act II +

Live Match Stream

+
+

100 match updates. Full JSON snapshot per update vs real @patch output.

+

+ +
+
Cumulative bytes (100 updates)
+
+
+
Sample patch
+
+
+
+
+ + +
+
+ Act III +

Tabular Cook-Off

+
+

Same data, different formats. canonicalizeLoose() auto-tabularizes (autoTabular=true, minRows=3).

+ +
+ + + + +
+ +
+ +
+ +
+
Glyph tabular preview
+
+
+
+ + +
+
+ Act IV +

Corruption & Resync CONCEPTUAL

+
+

Frame integrity via CRC-style markers. One flipped byte corrupts the frame; the receiver detects, rejects, and resyncs to the next @ sentinel.

+ +
+
+
+ OK +
+
@patch @keys=wire @target=match:001
+
Frame header — known sentinel, valid
+
+
+
+ ERR +
+
+ = minute 4X +
+
Bit-flip at byte 34 — CRC mismatch, frame rejected
+
+
+
+ SKIP +
+
= score_home 1
+
Remaining bytes in corrupted frame discarded
+
+
+
+ SYNC +
+
@end
+
Next @ sentinel found — stream resynced, next frame accepted
+
+
+
+
+ Not yet measured in the harness. Diagram shows the designed behaviour of the StreamingValidator sentinel-based recovery. +
+
+
+
+
+ + +
+
+
Level 3
+

Benchmark Cook-Off

+

Four datasets, three formats. Bytes and estimated tokens (heuristic — see footnote).

+ + +
+ + + + + + + + + + + +
DatasetJSON minJSON prettyGlyph looseSavings (bytes)
+
+ + +
+ +
+ +

Token savings figures are illustrative only — heuristic whitespace tokenizer, not BPE. Use tiktoken for accurate comparisons.

+
+
+ + +
+
+
Paste your own
+

Live Encoder

+

Paste any JSON. The real fromJsonLoose() + canonicalizeLoose() from the bundle runs client-side.

+ +
+
+ + +
+
+ +
Waiting for input...
+
+
+ +
+
Byte comparison
+
+ JSON min +
+ +
+
+ Glyph loose +
+ +
+
+ Savings + + +
+
+
+
+ + +
+
+
Summary
+

Scorecard & Methodology

+ +
+
+
Checklist
+
    +
  • Correctness: 14 edge cases encoded from real codec
  • +
  • Compression: >60% byte savings on repeated-row tabular data
  • +
  • Streaming: real emitPatch() output measured vs full snapshots
  • +
  • Firewall: StreamingValidator rejects unknown tools before stream ends
  • +
  • JS loose round-trip closed: parseLoose() inverts canonicalizeLoose (parity with Go/Py); all 14 edge cases round-trip
  • +
  • !Token savings are heuristic (whitespace tokenizer, not BPE)
  • +
  • !Bundle uses crypto — fingerprintLoose() throws in pure browser context
  • +
+
+ +
+
Regenerate data
+
node gauntlet/harness/measure.js
+

Rebuilds gauntlet-data.json and gauntlet-data.js from the real codec. Requires cowrie-glyph installed.

+
+
+
+ + +
+
Implementation parity notes
+
+
+ + +
+
Measurement notes
+
+
+
+
+ +
+
+ GLYPH · GAUNTLET + cowrie-glyph — all numbers from real codec execution +
+
+ + + + + + + diff --git a/gauntlet/scenarios/README.md b/gauntlet/scenarios/README.md new file mode 100644 index 0000000..b27eeaf --- /dev/null +++ b/gauntlet/scenarios/README.md @@ -0,0 +1,76 @@ +# GLYPH 8-Scenario Cross-Language Gauntlet + +A scenario-based acceptance suite that exercises **every major GLYPH capability** +as realistic AI-workflow usage, runs each scenario **identically across the three +conformance implementations** (Go, Python, JS), applies one consistent **pass/fail** +rubric, records evidence for every outcome, and gates on a hard exit code. + +``` +python3 gauntlet/scenarios/gauntlet.py # build JS, run all, report, exit 1 on any fail +python3 gauntlet/scenarios/gauntlet.py --no-build # skip the JS tsc build (use existing dist/) +``` + +## How it is structured + +| Piece | Role | +|-------|------| +| `gen_inputs.py` → `inputs.json` | **Single shared fixture source.** All three runners read the same bytes → "same conditions". Regenerate with `python3 gen_inputs.py`. | +| `runner.py`, `runner.cjs`, `../../go/cmd/gauntletrunner` | **Per-language runners.** Each runs the scenarios applicable to it using the real public API and prints a JSON *evidence* object. Runners **measure only** — they never decide pass/fail. | +| `gauntlet.py` | **Orchestrator / single evaluator.** Runs all three runners, applies identical pass/fail criteria (incl. byte-for-byte cross-language equality), prints the report, writes `report.json`, exits non-zero on any failure. | + +The Go runner lives inside the Go module (`go/cmd/gauntletrunner`) so the local +`glyph`/`stream` packages resolve without a separate module. + +## Evaluation method + +**Pass/fail**, applied by the orchestrator (one evaluator → consistent across +languages). A scenario passes iff **every applicable language ran cleanly AND +every check holds** — per-language checks *and* cross-language byte-for-byte +equality checks. All evidence (canonical strings, fingerprints, byte counts, +wire hashes, verdicts) is recorded in `report.json`. + +## The 8 scenarios + +| # | Scenario | Langs | Capability | Key success criteria | +|---|----------|-------|------------|----------------------| +| S1 | JSON bridge round-trip fidelity | go/py/js | JSON ↔ GLYPH | round-trip == source (each lang); round-trips identical across langs | +| S2 | Canonicalization determinism + agreement | go/py/js | canonical form | key-order variants → one canonical form; canonical + number formatting identical across langs | +| S3 | Fingerprint identity, sensitivity & parity | go/py/js | state fingerprint | equal→equal, changed→different, 64-hex; `fp` byte-for-byte identical across langs | +| S4 | Tabular compaction + recovery + parity | go/py/js | pack/tabular | emits `@tab`; tabular < JSON (≥40%) and < list form; round-trips; tabular canonical identical across langs | +| S5 | Patch apply (set/append/delete/Δ) | go/py/js | patch | applied state == expected; base immutable; result + fingerprint identical across langs | +| S6 | Patch base verification / fail-closed | go/py/js | patch `@base` | correct base accepted, **stale base rejected** (Go/Py); base fingerprint identical across Go/Py/JS | +| S7 | GS1 framing: wire parity + cursor | go/js | GS1 stream | frames decode; **stale-base patch frame rejected**; encoded wire bytes + state hash byte-for-byte identical (Go==JS) | +| S8 | Streaming firewall (early rejection) | py/js | streaming validator | allowed accepted; **unknown tool rejected early** (bytes avoided); verdict parity Py/JS | + +Cross-language conformance — GLYPH's core value prop — is woven through S1–S5 +(all three), S6 (base parity, all three), S7 (Go↔JS wire bytes), and S8 (Py↔JS +verdict). + +## Findings from the first run (fixed) + +The first full run was **6/8**. The two failures were genuine cross-language +divergences where **Go and JS agreed and Python was the outlier**; per the +decision that **Go is the source of truth**, Python was brought into line: + +1. **S4 — tabular header.** Go/JS emit `@tab _ rows=N cols=M [cols]` (v2.4.0 + metadata for streaming resync); Python emitted the bare `@tab _ [cols]` and + its parser *rejected* the Go/JS form (so Python could not read Go/JS tabular + output). Fix: `py/glyph/loose.py` emits the metadata; `py/glyph/parse.py` + tolerates `rows=/cols=` (and any `key=val`) before `[`. + +2. **S6 — patch `@base` basis.** Go/JS compute `@base = sha256(canonicalize_loose(state))[:16]` + (tabular form, null → `_`); Python used the no-tabular fingerprint basis + (null → `∅`). They diverge whenever the base state contains a null. Fix: + `py/glyph/patch.py` `compute_base_fingerprint`/`verify_patch_base` use + `canonicalize_loose`, matching Go/JS and `LOOSE_MODE_SPEC.md` §"Patch Base + Fingerprint". The README invariant block was corrected accordingly. + +After the fixes: **8/8**, with the full existing suites still green +(py 444, py-gauntlet 81, go all, js 579, cross-impl parity gate all). + +## Numeric domain + +Cross-language byte-for-byte checks stay inside the JS-safe integer domain +(`|int| ≤ 2^53`). Integers beyond that (`9007199254740992`) are a **documented** +divergence (Go preserves int64; Py/JS fall back to float), not a conformance +target, so the fixtures avoid them in parity-gated positions. diff --git a/gauntlet/scenarios/gauntlet.py b/gauntlet/scenarios/gauntlet.py new file mode 100644 index 0000000..ee55f43 --- /dev/null +++ b/gauntlet/scenarios/gauntlet.py @@ -0,0 +1,320 @@ +#!/usr/bin/env python3 +""" +GLYPH 8-Scenario Cross-Language Gauntlet — orchestrator / evaluator. + +Runs the three per-language runners (Go, Python, JS) under identical conditions +against the shared inputs.json, then applies one set of pass/fail criteria to all +of them (the single evaluator → "consistent evaluation method"). Prints a human +evidence report, writes report.json, and exits non-zero if any scenario fails. + + python3 gauntlet/scenarios/gauntlet.py [--no-build] + +Each scenario is PASS iff every applicable language ran without error AND every +check (per-language + byte-for-byte cross-language) holds. Evidence for every +outcome is recorded in report.json. + +Capability coverage: + S1 JSON bridge · S2 canonicalization · S3 fingerprint+parity · S4 tabular + compaction · S5 patch apply · S6 patch-base fail-closed · S7 GS1 framing+wire + parity · S8 streaming firewall. Cross-language conformance is woven through + S1-S5 (Go/Py/JS), S6 (base parity Go/Py/JS), S7 (Go/JS), S8 (Py/JS). +""" +import json +import os +import subprocess +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) +INPUTS = os.path.join(HERE, "inputs.json") +REPORT = os.path.join(HERE, "report.json") + +GO_DIR = os.path.join(ROOT, "go") +JS_DIR = os.path.join(ROOT, "js") + +# ── ANSI ────────────────────────────────────────────────────────────────── +def _c(code, s): + return f"\033[{code}m{s}\033[0m" if sys.stdout.isatty() else s + +GREEN = lambda s: _c("32", s) +RED = lambda s: _c("31", s) +YEL = lambda s: _c("33", s) +DIM = lambda s: _c("2", s) +BOLD = lambda s: _c("1", s) + + +# ── run the language runners ──────────────────────────────────────────────── +def run_runner(cmd, cwd, label): + try: + p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=300) + except Exception as exc: # noqa: BLE001 + return {"lang": label, "version": "?", "scenarios": {}, "_runner_error": str(exc)} + if p.returncode != 0: + return {"lang": label, "version": "?", "scenarios": {}, + "_runner_error": f"exit {p.returncode}: {p.stderr.strip()[:500]}"} + try: + return json.loads(p.stdout) + except json.JSONDecodeError as exc: + return {"lang": label, "version": "?", "scenarios": {}, + "_runner_error": f"bad JSON: {exc}; stderr={p.stderr.strip()[:300]}"} + + +def collect(no_build): + if not no_build: + print(DIM("· building JS (tsc) ...")) + b = subprocess.run(["npm", "run", "build"], cwd=JS_DIR, capture_output=True, text=True) + if b.returncode != 0: + print(RED("JS build failed:\n") + b.stdout[-2000:] + b.stderr[-2000:]) + sys.exit(2) + results = {} + results["py"] = run_runner([sys.executable, os.path.join(HERE, "runner.py"), INPUTS], HERE, "py") + results["js"] = run_runner(["node", os.path.join(HERE, "runner.cjs"), INPUTS], HERE, "js") + results["go"] = run_runner(["go", "run", "./cmd/gauntletrunner", INPUTS], GO_DIR, "go") + return results + + +# ── evaluation helpers ────────────────────────────────────────────────────── +def canon(value): + return json.dumps(value, sort_keys=True, ensure_ascii=False) + + +class Eval: + """Collects per-scenario checks; a scenario passes iff all checks pass.""" + def __init__(self, title, langs): + self.title = title + self.langs = langs + self.checks = [] # (name, passed, detail) + self.evidence = {} # lang -> compact evidence shown in report + + def check(self, name, passed, detail=""): + self.checks.append((name, bool(passed), detail)) + return bool(passed) + + @property + def passed(self): + return all(p for _, p, _ in self.checks) and len(self.checks) > 0 + + +def ev_of(results, key, langs): + """Return {lang: evidence}; record missing/errored langs as a failing note.""" + got, errs = {}, {} + for L in langs: + if results.get(L, {}).get("_runner_error"): + errs[L] = "runner: " + results[L]["_runner_error"] + continue + entry = results.get(L, {}).get("scenarios", {}).get(key) + if entry is None: + errs[L] = "scenario missing" + elif not entry.get("ok"): + errs[L] = entry.get("error", "errored") + else: + got[L] = entry["evidence"] + return got, errs + + +def equal_across(ev, key, transform=lambda x: x): + vals = {L: transform(e[key]) for L, e in ev.items()} + uniq = set(vals.values()) + return len(uniq) <= 1, vals + + +# ── per-scenario evaluators ───────────────────────────────────────────────── +def eval_S1(ev, inp): + e = Eval("JSON bridge round-trip fidelity", ["go", "py", "js"]) + for L, d in ev.items(): + e.check(f"{L}: round-trip == input", d["equals_input"]) + e.evidence[L] = {"equals_input": d["equals_input"]} + ok, vals = equal_across(ev, "roundtrip", canon) + e.check("cross-lang: round-trip values identical", ok, + "" if ok else "differing round-trips") + snap_c = canon(inp["S1_json_bridge"]["snapshot"]) + e.check("round-trip == source snapshot (all langs)", + all(canon(d["roundtrip"]) == snap_c for d in ev.values())) + return e + + +def eval_S2(ev, inp): + e = Eval("Canonicalization determinism + cross-language agreement", ["go", "py", "js"]) + for L, d in ev.items(): + e.check(f"{L}: key-order variants → one canonical form", d["variants_consistent"]) + e.evidence[L] = {"canonical": d["canonical"]} + ok, _ = equal_across(ev, "canonical") + e.check("cross-lang: canonical form identical", ok) + okf, _ = equal_across(ev, "floats", canon) + e.check("cross-lang: number canonicalization identical", okf) + return e + + +def eval_S3(ev, inp): + e = Eval("State fingerprint identity, sensitivity & cross-language parity", ["go", "py", "js"]) + for L, d in ev.items(): + e.check(f"{L}: equal states → equal fingerprint (identity)", d["fp_base"] == d["fp_equiv"]) + e.check(f"{L}: changed state → different fingerprint (sensitivity)", d["fp_base"] != d["fp_mutated"]) + e.check(f"{L}: 64-hex digest", len(d["fp_base"]) == 64) + e.evidence[L] = {"fp_base": d["fp_base"][:16] + "…"} + okb, _ = equal_across(ev, "fp_base") + e.check("cross-lang: fp(base) byte-for-byte identical", okb) + okm, _ = equal_across(ev, "fp_mutated") + e.check("cross-lang: fp(mutated) byte-for-byte identical", okm) + return e + + +def eval_S4(ev, inp): + e = Eval("Tabular packing compaction + recovery + cross-language parity", ["go", "py", "js"]) + thr = inp["S4_tabular"]["min_savings_vs_json"] + for L, d in ev.items(): + savings = 1 - d["bytes_tab"] / d["bytes_json"] + e.check(f"{L}: emits @tab block", d["is_tabular"]) + e.check(f"{L}: tabular < JSON ({savings:.0%} ≥ {thr:.0%})", d["bytes_tab"] < d["bytes_json"] and savings >= thr) + e.check(f"{L}: tabular < list form", d["bytes_tab"] < d["bytes_list"]) + e.check(f"{L}: parse(tabular) recovers value", d["roundtrip_ok"]) + e.evidence[L] = {"bytes_tab": d["bytes_tab"], "savings": f"{savings:.0%}", + "header": d["canonical_tab"].split("\n", 1)[0]} + okc, vals = equal_across(ev, "canonical_tab") + e.check("cross-lang: tabular canonical form identical", okc, + "" if okc else "headers: " + " | ".join(sorted({d["canonical_tab"].split(chr(10),1)[0] for d in ev.values()}))) + okf, _ = equal_across(ev, "fp_recovered") + e.check("cross-lang: recovered fingerprint identical", okf) + return e + + +def eval_S5(ev, inp): + e = Eval("Patch apply correctness (set / append / delete / numeric-delta)", ["go", "py", "js"]) + expected = canon(inp["S5_patch_apply"]["expected"]) + for L, d in ev.items(): + e.check(f"{L}: applied state == expected", canon(d["result"]) == expected) + e.check(f"{L}: base document not mutated", d["base_unchanged"]) + e.evidence[L] = {"result_ok": canon(d["result"]) == expected} + okr, _ = equal_across(ev, "result", canon) + e.check("cross-lang: applied state identical", okr) + okf, _ = equal_across(ev, "fp_result") + e.check("cross-lang: result fingerprint identical", okf) + return e + + +def eval_S6(ev, inp): + e = Eval("Standalone patch base verification / fail-closed", ["go", "py", "js"]) + # Standalone verify exists in Go + Py; JS contributes the base fingerprint only. + for L in ("go", "py"): + if L in ev: + e.check(f"{L}: correct base accepted", ev[L]["verify_accept"]) + e.check(f"{L}: stale base rejected (fail-closed)", ev[L]["verify_reject"]) + for L, d in ev.items(): + e.evidence[L] = {"base16": d["base16"]} + okb, vals = equal_across(ev, "base16") + e.check("cross-lang: base fingerprint identical (Go/Py/JS)", okb, + "" if okb else "; ".join(f"{L}={v}" for L, v in sorted(vals.items()))) + return e + + +def eval_S7(ev, inp): + e = Eval("GS1 stream framing: wire parity + decode round-trip + base-enforced cursor", ["go", "js"]) + frames = inp["S7_gs1_stream"]["frames"] + exp_kinds = [f["kind"] for f in frames] + for L, d in ev.items(): + e.check(f"{L}: all frames decode (payloads intact)", d["payloads_ok"]) + e.check(f"{L}: frame count == {len(frames)}", d["frame_count"] == len(frames)) + e.check(f"{L}: kinds in order", d["kinds"] == exp_kinds) + e.check(f"{L}: cursor accepts correct base", d["base_accept"]) + e.check(f"{L}: cursor rejects stale base (fail-closed)", d["base_reject"]) + e.evidence[L] = {"stream_sha256": d["stream_sha256"][:16] + "…"} + oks, _ = equal_across(ev, "stream_sha256") + e.check("cross-lang: encoded wire bytes byte-for-byte identical (Go==JS)", oks) + okh, _ = equal_across(ev, "statehash_hex") + e.check("cross-lang: stream state hash identical (Go==JS)", okh) + return e + + +def eval_S8(ev, inp): + e = Eval("Streaming validator / tool firewall (early rejection) + verdict parity", ["py", "js"]) + for L, d in ev.items(): + e.check(f"{L}: allowed tool ({d['allowed_tool']}) accepted", d["allowed_accepted"]) + e.check(f"{L}: blocked tool rejected (unknown-tool, fail-closed)", d["blocked_rejected"]) + e.check(f"{L}: stops early (bytes avoided = {d['bytes_avoided']})", d["bytes_avoided"] > 0) + e.evidence[L] = {"bytes_avoided": d["bytes_avoided"], "code": d["blocked_code"]} + pa = all(d["allowed_accepted"] for d in ev.values()) + pb = all(d["blocked_rejected"] for d in ev.values()) + e.check("cross-lang: verdict parity (both accept allowed, both reject blocked)", pa and pb) + return e + + +EVALUATORS = [ + ("S1", ["go", "py", "js"], eval_S1), + ("S2", ["go", "py", "js"], eval_S2), + ("S3", ["go", "py", "js"], eval_S3), + ("S4", ["go", "py", "js"], eval_S4), + ("S5", ["go", "py", "js"], eval_S5), + ("S6", ["go", "py", "js"], eval_S6), + ("S7", ["go", "js"], eval_S7), + ("S8", ["py", "js"], eval_S8), +] + + +def evaluate(results, inp): + report = {"scenarios": {}, "versions": {L: results.get(L, {}).get("version", "?") for L in ("go", "py", "js")}} + passed_count = 0 + for key, langs, fn in EVALUATORS: + ev, errs = ev_of(results, key, langs) + if errs: + e = Eval("(runner/scenario error)", langs) + for L, msg in errs.items(): + e.check(f"{L}: ran cleanly", False, msg) + if ev: # still evaluate the langs that did run + e2 = fn(ev, inp) + e.title = e2.title + e.checks = [(f"{L}: ran cleanly", False, m) for L, m in errs.items()] + e2.checks + e.evidence = e2.evidence + else: + e = fn(ev, inp) + report["scenarios"][key] = { + "title": e.title, "langs": langs, "passed": e.passed, + "checks": [{"name": n, "passed": p, "detail": d} for n, p, d in e.checks], + "evidence": e.evidence, + } + if e.passed: + passed_count += 1 + report["summary"] = {"passed": passed_count, "total": len(EVALUATORS), + "all_passed": passed_count == len(EVALUATORS)} + return report + + +# ── render ────────────────────────────────────────────────────────────────── +def render(report): + print() + print(BOLD(" GLYPH 8-Scenario Cross-Language Gauntlet")) + v = report["versions"] + print(DIM(f" go={v['go']} py={v['py']} js={v['js']}")) + print(DIM(" " + "─" * 70)) + for key, sc in report["scenarios"].items(): + badge = GREEN(" PASS ") if sc["passed"] else RED(" FAIL ") + langs = "/".join(sc["langs"]) + print(f"\n [{badge}] {BOLD(key)} · {sc['title']} {DIM('(' + langs + ')')}") + for ck in sc["checks"]: + mark = GREEN("✓") if ck["passed"] else RED("✗") + line = f" {mark} {ck['name']}" + if ck["detail"] and not ck["passed"]: + line += DIM(" — " + ck["detail"]) + print(line) + if sc["evidence"]: + for L, evd in sc["evidence"].items(): + print(DIM(f" {L}: {json.dumps(evd, ensure_ascii=False)}")) + s = report["summary"] + print(DIM("\n " + "─" * 70)) + tag = GREEN("ALL SCENARIOS PASS") if s["all_passed"] else RED(f"{s['total']-s['passed']} SCENARIO(S) FAILING") + print(f" {BOLD(str(s['passed']) + '/' + str(s['total']))} scenarios passed — {tag}\n") + + +def main(): + no_build = "--no-build" in sys.argv + with open(INPUTS, encoding="utf-8") as fh: + inp = json.load(fh) + results = collect(no_build) + report = evaluate(results, inp) + with open(REPORT, "w", encoding="utf-8") as fh: + json.dump({"report": report, "raw": results}, fh, ensure_ascii=False, indent=2) + render(report) + sys.exit(0 if report["summary"]["all_passed"] else 1) + + +if __name__ == "__main__": + main() diff --git a/gauntlet/scenarios/gen_inputs.py b/gauntlet/scenarios/gen_inputs.py new file mode 100644 index 0000000..8a9f536 --- /dev/null +++ b/gauntlet/scenarios/gen_inputs.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +""" +Generate inputs.json — the single shared fixture source for the GLYPH +8-scenario cross-language gauntlet. + +Every language runner (Python, JS, Go) reads this exact file, so all three +operate on byte-identical inputs ("same conditions"). Re-run after editing: + + python3 gauntlet/scenarios/gen_inputs.py + +The data is deliberately *realistic AI-workflow state* (session snapshots, +tool-call traces, match/patch streams, a tool firewall) rather than synthetic +edge cases, while staying inside the JS-safe numeric domain for the checks that +demand byte-for-byte cross-language parity (ints > 2^53 are a documented +divergence, not a conformance target). +""" +import json +import os + +HERE = os.path.dirname(os.path.abspath(__file__)) +OUT = os.path.join(HERE, "inputs.json") + + +def tool_trace(n): + """A homogeneous tool-call trace — the canonical 'repeated records' case.""" + tools = ["search", "fetch", "summarize", "rank", "write_file"] + rows = [] + for i in range(n): + rows.append({ + "step": i, + "tool": tools[i % len(tools)], + "status": "ok" if i % 7 else "error", + "ms": (i * 13) % 500, + }) + return rows + + +INPUTS = { + "_meta": { + "note": "Shared fixtures for the GLYPH 8-scenario cross-language gauntlet. " + "Generated by gen_inputs.py — do not hand-edit.", + "numeric_domain": "JS-safe (|int| <= 2^53) for all cross-language byte-parity checks.", + }, + + # S1 — JSON bridge round-trip fidelity. Realistic agent memory snapshot: + # nested, unicode, nulls, bool, negative int, float, empty string. + "S1_json_bridge": { + "snapshot": { + "session_id": "sess-2026-06-21", + "turn": 7, + "model": "claude-opus-4-8", + "temperature": 0.2, + "active": True, + "parent": None, + "note": "café ☕ — résumé λ", + "empty": "", + "tags": ["search", "plan", "act"], + "budget": {"input_tokens": 12000, "output_tokens": 3500, "remaining": None}, + "offset": -42, + "steps": [ + {"i": 1, "tool": "search", "ok": True}, + {"i": 2, "tool": "fetch", "ok": False}, + ], + } + }, + + # S2 — Canonicalization determinism + cross-language agreement. + # Three key-orderings of one logical object must all canonicalize identically + # (within a language) and the canonical string must match across languages. + # `floats_text` exercises number-format parity via the loose-text parser. + "S2_canonical": { + "variants": [ + {"action": "search", "query": "weather in Chicago", + "max_results": 5, "filters": {"lang": "en", "safe": True}, "cursor": None}, + {"cursor": None, "max_results": 5, "query": "weather in Chicago", + "filters": {"safe": True, "lang": "en"}, "action": "search"}, + {"filters": {"lang": "en", "safe": True}, "action": "search", + "cursor": None, "query": "weather in Chicago", "max_results": 5}, + ], + "floats_text": ["1.23e-9", "3.14159", "1000000.0", "-0.0", "0.5", "100", "-7"], + }, + + # S3 — State fingerprint identity, sensitivity & cross-language parity. + "S3_fingerprint": { + "base": {"id": "agent-1", "turn": 3, "scratch": None, + "mem": {"a": 1, "b": 2}, "log": ["start", "search"]}, + "equiv": {"log": ["start", "search"], "mem": {"b": 2, "a": 1}, + "scratch": None, "turn": 3, "id": "agent-1"}, + "mutated": {"id": "agent-1", "turn": 4, "scratch": None, + "mem": {"a": 1, "b": 2}, "log": ["start", "search"]}, + }, + + # S4 — Tabular packing compaction + recovery + cross-language parity. + "S4_tabular": { + "trace": tool_trace(50), + "min_savings_vs_json": 0.40, + }, + + # S5 — Patch apply correctness (set / append / delete / numeric-delta). + "S5_patch_apply": { + "base": {"id": "match:001", "minute": 0, "score_home": 0, "score_away": 0, + "events": ["kickoff"], "status": "live", "var_check": True}, + "patch_text": "@patch @target=match:001\n= status finished\n~ minute +90\n+ events Goal\n- var_check\n@end", + "expected": {"id": "match:001", "minute": 90, "score_home": 0, "score_away": 0, + "events": ["kickoff", "Goal"], "status": "finished"}, + }, + + # S6 — Standalone patch base verification / fail-closed. + # State carries a null (last_goal) — the case where the no-tabular fingerprint + # basis (∅) and the CanonicalizeLoose basis (_) diverge across languages. + "S6_patch_base": { + "state": {"id": "match:001", "minute": 45, "score_home": 1, + "score_away": 0, "last_goal": None}, + "patch_op_lines": ["= minute 90"], + "target": "match:001", + "stale_base": "deadbeefdeadbeef", + }, + + # S7 — GS1 stream framing: wire parity + decode round-trip + base-enforced cursor. + # Fixed opaque payloads so byte-for-byte Go==JS comparison isolates the framing + # layer (canonical-text parity is S2/S4's job). + "S7_gs1_stream": { + "sid": 42, + "frames": [ + {"kind": "doc", "seq": 0, "payload": "{a=1 b=2}"}, + {"kind": "row", "seq": 1, "payload": "Row@(id 1 name foo)"}, + {"kind": "patch", "seq": 2, "payload": "@patch\n= a 9\n@end"}, + {"kind": "ui", "seq": 3, "payload": "Progress@(pct=0.5)"}, + {"kind": "ack", "seq": 4, "payload": ""}, + {"kind": "err", "seq": 5, "payload": "Err@(code FAIL)"}, + {"kind": "ping", "seq": 6, "payload": ""}, + {"kind": "pong", "seq": 7, "payload": ""}, + {"kind": "doc", "seq": 8, "payload": "done", "final": True}, + ], + "base_state": {"x": 1, "y": 2}, + "base_patch_payload": "@patch\n= x 2\n@end", + }, + + # S8 — Streaming validator / tool firewall (early rejection) + verdict parity. + # The validator consumes each implementation's native tool-call syntax + # (JS: {action=tool ...}; Py: tool{...}); the *verdict* must agree. + "S8_firewall": { + "allowed_tool": "search", + "blocked_tool": "wire_transfer", + "allowed_js": '{action=search query="latest weather in Chicago" max_results=5}', + "blocked_js": "{action=wire_transfer amount=1000000 target=unknown}", + "allowed_py": 'search{query="latest weather in Chicago" max_results=5}', + "blocked_py": "wire_transfer{amount=1000000 target=unknown}", + "registry": { + "search": {"query": "str", "max_results": "int"}, + "calculate": {}, "browse": {}, "execute": {}, + "read_file": {}, "write_file": {}, + }, + }, +} + + +def main(): + with open(OUT, "w", encoding="utf-8") as fh: + json.dump(INPUTS, fh, ensure_ascii=False, indent=2, sort_keys=False) + fh.write("\n") + print("wrote", OUT) + + +if __name__ == "__main__": + main() diff --git a/gauntlet/scenarios/inputs.json b/gauntlet/scenarios/inputs.json new file mode 100644 index 0000000..5842cbb --- /dev/null +++ b/gauntlet/scenarios/inputs.json @@ -0,0 +1,544 @@ +{ + "_meta": { + "note": "Shared fixtures for the GLYPH 8-scenario cross-language gauntlet. Generated by gen_inputs.py — do not hand-edit.", + "numeric_domain": "JS-safe (|int| <= 2^53) for all cross-language byte-parity checks." + }, + "S1_json_bridge": { + "snapshot": { + "session_id": "sess-2026-06-21", + "turn": 7, + "model": "claude-opus-4-8", + "temperature": 0.2, + "active": true, + "parent": null, + "note": "café ☕ — résumé λ", + "empty": "", + "tags": [ + "search", + "plan", + "act" + ], + "budget": { + "input_tokens": 12000, + "output_tokens": 3500, + "remaining": null + }, + "offset": -42, + "steps": [ + { + "i": 1, + "tool": "search", + "ok": true + }, + { + "i": 2, + "tool": "fetch", + "ok": false + } + ] + } + }, + "S2_canonical": { + "variants": [ + { + "action": "search", + "query": "weather in Chicago", + "max_results": 5, + "filters": { + "lang": "en", + "safe": true + }, + "cursor": null + }, + { + "cursor": null, + "max_results": 5, + "query": "weather in Chicago", + "filters": { + "safe": true, + "lang": "en" + }, + "action": "search" + }, + { + "filters": { + "lang": "en", + "safe": true + }, + "action": "search", + "cursor": null, + "query": "weather in Chicago", + "max_results": 5 + } + ], + "floats_text": [ + "1.23e-9", + "3.14159", + "1000000.0", + "-0.0", + "0.5", + "100", + "-7" + ] + }, + "S3_fingerprint": { + "base": { + "id": "agent-1", + "turn": 3, + "scratch": null, + "mem": { + "a": 1, + "b": 2 + }, + "log": [ + "start", + "search" + ] + }, + "equiv": { + "log": [ + "start", + "search" + ], + "mem": { + "b": 2, + "a": 1 + }, + "scratch": null, + "turn": 3, + "id": "agent-1" + }, + "mutated": { + "id": "agent-1", + "turn": 4, + "scratch": null, + "mem": { + "a": 1, + "b": 2 + }, + "log": [ + "start", + "search" + ] + } + }, + "S4_tabular": { + "trace": [ + { + "step": 0, + "tool": "search", + "status": "error", + "ms": 0 + }, + { + "step": 1, + "tool": "fetch", + "status": "ok", + "ms": 13 + }, + { + "step": 2, + "tool": "summarize", + "status": "ok", + "ms": 26 + }, + { + "step": 3, + "tool": "rank", + "status": "ok", + "ms": 39 + }, + { + "step": 4, + "tool": "write_file", + "status": "ok", + "ms": 52 + }, + { + "step": 5, + "tool": "search", + "status": "ok", + "ms": 65 + }, + { + "step": 6, + "tool": "fetch", + "status": "ok", + "ms": 78 + }, + { + "step": 7, + "tool": "summarize", + "status": "error", + "ms": 91 + }, + { + "step": 8, + "tool": "rank", + "status": "ok", + "ms": 104 + }, + { + "step": 9, + "tool": "write_file", + "status": "ok", + "ms": 117 + }, + { + "step": 10, + "tool": "search", + "status": "ok", + "ms": 130 + }, + { + "step": 11, + "tool": "fetch", + "status": "ok", + "ms": 143 + }, + { + "step": 12, + "tool": "summarize", + "status": "ok", + "ms": 156 + }, + { + "step": 13, + "tool": "rank", + "status": "ok", + "ms": 169 + }, + { + "step": 14, + "tool": "write_file", + "status": "error", + "ms": 182 + }, + { + "step": 15, + "tool": "search", + "status": "ok", + "ms": 195 + }, + { + "step": 16, + "tool": "fetch", + "status": "ok", + "ms": 208 + }, + { + "step": 17, + "tool": "summarize", + "status": "ok", + "ms": 221 + }, + { + "step": 18, + "tool": "rank", + "status": "ok", + "ms": 234 + }, + { + "step": 19, + "tool": "write_file", + "status": "ok", + "ms": 247 + }, + { + "step": 20, + "tool": "search", + "status": "ok", + "ms": 260 + }, + { + "step": 21, + "tool": "fetch", + "status": "error", + "ms": 273 + }, + { + "step": 22, + "tool": "summarize", + "status": "ok", + "ms": 286 + }, + { + "step": 23, + "tool": "rank", + "status": "ok", + "ms": 299 + }, + { + "step": 24, + "tool": "write_file", + "status": "ok", + "ms": 312 + }, + { + "step": 25, + "tool": "search", + "status": "ok", + "ms": 325 + }, + { + "step": 26, + "tool": "fetch", + "status": "ok", + "ms": 338 + }, + { + "step": 27, + "tool": "summarize", + "status": "ok", + "ms": 351 + }, + { + "step": 28, + "tool": "rank", + "status": "error", + "ms": 364 + }, + { + "step": 29, + "tool": "write_file", + "status": "ok", + "ms": 377 + }, + { + "step": 30, + "tool": "search", + "status": "ok", + "ms": 390 + }, + { + "step": 31, + "tool": "fetch", + "status": "ok", + "ms": 403 + }, + { + "step": 32, + "tool": "summarize", + "status": "ok", + "ms": 416 + }, + { + "step": 33, + "tool": "rank", + "status": "ok", + "ms": 429 + }, + { + "step": 34, + "tool": "write_file", + "status": "ok", + "ms": 442 + }, + { + "step": 35, + "tool": "search", + "status": "error", + "ms": 455 + }, + { + "step": 36, + "tool": "fetch", + "status": "ok", + "ms": 468 + }, + { + "step": 37, + "tool": "summarize", + "status": "ok", + "ms": 481 + }, + { + "step": 38, + "tool": "rank", + "status": "ok", + "ms": 494 + }, + { + "step": 39, + "tool": "write_file", + "status": "ok", + "ms": 7 + }, + { + "step": 40, + "tool": "search", + "status": "ok", + "ms": 20 + }, + { + "step": 41, + "tool": "fetch", + "status": "ok", + "ms": 33 + }, + { + "step": 42, + "tool": "summarize", + "status": "error", + "ms": 46 + }, + { + "step": 43, + "tool": "rank", + "status": "ok", + "ms": 59 + }, + { + "step": 44, + "tool": "write_file", + "status": "ok", + "ms": 72 + }, + { + "step": 45, + "tool": "search", + "status": "ok", + "ms": 85 + }, + { + "step": 46, + "tool": "fetch", + "status": "ok", + "ms": 98 + }, + { + "step": 47, + "tool": "summarize", + "status": "ok", + "ms": 111 + }, + { + "step": 48, + "tool": "rank", + "status": "ok", + "ms": 124 + }, + { + "step": 49, + "tool": "write_file", + "status": "error", + "ms": 137 + } + ], + "min_savings_vs_json": 0.4 + }, + "S5_patch_apply": { + "base": { + "id": "match:001", + "minute": 0, + "score_home": 0, + "score_away": 0, + "events": [ + "kickoff" + ], + "status": "live", + "var_check": true + }, + "patch_text": "@patch @target=match:001\n= status finished\n~ minute +90\n+ events Goal\n- var_check\n@end", + "expected": { + "id": "match:001", + "minute": 90, + "score_home": 0, + "score_away": 0, + "events": [ + "kickoff", + "Goal" + ], + "status": "finished" + } + }, + "S6_patch_base": { + "state": { + "id": "match:001", + "minute": 45, + "score_home": 1, + "score_away": 0, + "last_goal": null + }, + "patch_op_lines": [ + "= minute 90" + ], + "target": "match:001", + "stale_base": "deadbeefdeadbeef" + }, + "S7_gs1_stream": { + "sid": 42, + "frames": [ + { + "kind": "doc", + "seq": 0, + "payload": "{a=1 b=2}" + }, + { + "kind": "row", + "seq": 1, + "payload": "Row@(id 1 name foo)" + }, + { + "kind": "patch", + "seq": 2, + "payload": "@patch\n= a 9\n@end" + }, + { + "kind": "ui", + "seq": 3, + "payload": "Progress@(pct=0.5)" + }, + { + "kind": "ack", + "seq": 4, + "payload": "" + }, + { + "kind": "err", + "seq": 5, + "payload": "Err@(code FAIL)" + }, + { + "kind": "ping", + "seq": 6, + "payload": "" + }, + { + "kind": "pong", + "seq": 7, + "payload": "" + }, + { + "kind": "doc", + "seq": 8, + "payload": "done", + "final": true + } + ], + "base_state": { + "x": 1, + "y": 2 + }, + "base_patch_payload": "@patch\n= x 2\n@end" + }, + "S8_firewall": { + "allowed_tool": "search", + "blocked_tool": "wire_transfer", + "allowed_js": "{action=search query=\"latest weather in Chicago\" max_results=5}", + "blocked_js": "{action=wire_transfer amount=1000000 target=unknown}", + "allowed_py": "search{query=\"latest weather in Chicago\" max_results=5}", + "blocked_py": "wire_transfer{amount=1000000 target=unknown}", + "registry": { + "search": { + "query": "str", + "max_results": "int" + }, + "calculate": {}, + "browse": {}, + "execute": {}, + "read_file": {}, + "write_file": {} + } + } +} diff --git a/gauntlet/scenarios/runner.cjs b/gauntlet/scenarios/runner.cjs new file mode 100644 index 0000000..6c7c85b --- /dev/null +++ b/gauntlet/scenarios/runner.cjs @@ -0,0 +1,214 @@ +#!/usr/bin/env node +/** + * JavaScript/TypeScript scenario runner for the GLYPH cross-language gauntlet. + * + * Reads the shared inputs.json, runs every scenario applicable to the JS + * implementation, and prints a single JSON evidence object to stdout. It does + * NOT decide pass/fail — the orchestrator is the single evaluator. + * + * Applicable scenarios: S1, S2, S3, S4, S5, S6 (base fingerprint only — JS has + * no standalone verify export), S7, S8. + * (S6 standalone accept/reject verification is Go+Py; JS base enforcement is + * exercised through the GS1 cursor in S7.) + * + * Usage: node runner.cjs + */ +'use strict'; +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); + +const G = require(path.join(__dirname, '..', '..', 'js', 'dist', 'index.js')); +const enc = new TextEncoder(); +const dec = new TextDecoder(); + +function cjsonBytes(value) { + // Compact JSON byte count (per-language baseline for the savings check). + return Buffer.byteLength(JSON.stringify(value), 'utf8'); +} + +function scenario(fn) { + try { + return { ok: true, evidence: fn() }; + } catch (e) { + return { ok: false, error: `${e && e.name ? e.name : 'Error'}: ${e && e.message ? e.message : e}` }; + } +} + +// ── S1 ────────────────────────────────────────────────────────────────────── +function s1(inp) { + const snap = inp.S1_json_bridge.snapshot; + const gv = G.fromJsonLoose(snap); + const back = G.toJsonLoose(gv); + return { roundtrip: back, equals_input: JSON.stringify(back) === JSON.stringify(snap) }; +} + +// ── S2 ────────────────────────────────────────────────────────────────────── +function s2(inp) { + const d = inp.S2_canonical; + const canons = d.variants.map((v) => G.canonicalizeLoose(G.fromJsonLoose(v))); + const floats = {}; + for (const t of d.floats_text) floats[t] = G.canonicalizeLoose(G.parseLoose(t)); + return { + canonical: canons[0], + variants_consistent: canons.every((c) => c === canons[0]), + floats, + }; +} + +// ── S3 ────────────────────────────────────────────────────────────────────── +function s3(inp) { + const d = inp.S3_fingerprint; + return { + fp_base: G.fingerprintLoose(G.fromJsonLoose(d.base)), + fp_equiv: G.fingerprintLoose(G.fromJsonLoose(d.equiv)), + fp_mutated: G.fingerprintLoose(G.fromJsonLoose(d.mutated)), + }; +} + +// ── S4 ────────────────────────────────────────────────────────────────────── +function s4(inp) { + const trace = inp.S4_tabular.trace; + const gv = G.fromJsonLoose(trace); + const tab = G.canonicalizeLoose(gv); + const lst = G.canonicalizeLooseNoTabular(gv); + const recovered = G.parseLoose(tab); + return { + is_tabular: tab.includes('@tab'), + canonical_tab: tab, + bytes_json: cjsonBytes(trace), + bytes_list: Buffer.byteLength(lst, 'utf8'), + bytes_tab: Buffer.byteLength(tab, 'utf8'), + roundtrip_ok: G.equalLoose(gv, recovered), + fp_recovered: G.fingerprintLoose(recovered), + }; +} + +// ── S5 ────────────────────────────────────────────────────────────────────── +function s5(inp) { + const d = inp.S5_patch_apply; + const base = G.fromJsonLoose(d.base); + const before = JSON.stringify(G.toJsonLoose(base)); + const patch = G.parsePatch(d.patch_text); + const result = G.applyPatch(base, patch); + return { + result: G.toJsonLoose(result), + fp_result: G.fingerprintLoose(result), + base_unchanged: JSON.stringify(G.toJsonLoose(base)) === before, + }; +} + +// ── S6 ────────────────────────────────────────────────────────────────────── +function s6(inp) { + const d = inp.S6_patch_base; + const state = G.fromJsonLoose(d.state); + const pb = new G.PatchBuilder({ prefix: '', value: d.target }) + .withBaseValue(state) + .set('minute', G.g.int(90)) + .build(); + return { base16: pb.baseFingerprint, verify_accept: null, verify_reject: null }; +} + +// ── S7 ────────────────────────────────────────────────────────────────────── +function s7(inp) { + const d = inp.S7_gs1_stream; + const sid = BigInt(d.sid); + const frames = d.frames.map((f) => ({ + version: 1, + sid, + seq: BigInt(f.seq), + kind: f.kind, + payload: enc.encode(f.payload), + final: !!f.final, + })); + const bytes = G.stream.encodeFrames(frames); + const buf = Buffer.from(bytes); + + const decoded = G.stream.decodeFrames(bytes); + const payloads_ok = decoded.length === d.frames.length && + decoded.every((fr, i) => dec.decode(fr.payload) === d.frames[i].payload); + + // Base-enforced cursor (fail-closed) + const cur = new G.stream.StreamCursor(); + const st = G.fromJsonLoose(d.base_state); + cur.setState(sid, st); + const correct = cur.get(sid).stateHash; + let base_accept = false; + try { + cur.processFrame(G.stream.patchFrame(sid, 1n, d.base_patch_payload, correct)); + base_accept = true; + } catch (_) { base_accept = false; } + const wrong = new Uint8Array(32); wrong[0] = 0xde; + let base_reject = false; + try { + cur.processFrame(G.stream.patchFrame(sid, 2n, d.base_patch_payload, wrong)); + base_reject = false; + } catch (_) { base_reject = true; } + + return { + stream_sha256: crypto.createHash('sha256').update(buf).digest('hex'), + stream_b64: buf.toString('base64'), + frame_count: decoded.length, + kinds: decoded.map((fr) => fr.kind), + seqs: decoded.map((fr) => Number(fr.seq)), + payloads_ok, + statehash_hex: G.stream.hashToHex(G.stream.stateHashLooseSync(G.fromJsonLoose(d.base_state))), + base_accept, + base_reject, + }; +} + +// ── S8 ────────────────────────────────────────────────────────────────────── +function feed(text) { + const sv = new G.StreamingValidator(G.defaultToolRegistry()); + let stop = -1; + let res = sv.getResult(); + for (const ch of text) { + res = sv.pushToken(ch); + if (res.errors.length > 0 && stop === -1) stop = res.charCount; + } + return { sv, res, stop }; +} + +function s8(inp) { + const d = inp.S8_firewall; + const a = feed(d.allowed_js); + const allowed_accepted = a.res.complete && a.res.toolAllowed && a.res.errors.length === 0; + + const b = feed(d.blocked_js); + const code = b.res.errors.length ? b.res.errors[0].code : ''; + const isUnknown = code === G.ErrorCode.UnknownTool; + const total = [...d.blocked_js].length; + return { + allowed_accepted, + allowed_tool: a.res.toolName, + blocked_rejected: b.sv.shouldStop() && isUnknown, + blocked_code: String(code), + blocked_tool_seen: b.res.toolName, + stop_index: b.stop, + total_len: total, + bytes_avoided: b.stop >= 0 ? total - b.stop : 0, + }; +} + +function main() { + const inputsPath = process.argv[2] || path.join(__dirname, 'inputs.json'); + const inp = JSON.parse(fs.readFileSync(inputsPath, 'utf8')); + const out = { + lang: 'js', + version: `Node ${process.version}`, + scenarios: { + S1: scenario(() => s1(inp)), + S2: scenario(() => s2(inp)), + S3: scenario(() => s3(inp)), + S4: scenario(() => s4(inp)), + S5: scenario(() => s5(inp)), + S6: scenario(() => s6(inp)), + S7: scenario(() => s7(inp)), + S8: scenario(() => s8(inp)), + }, + }; + process.stdout.write(JSON.stringify(out)); +} + +main(); diff --git a/gauntlet/scenarios/runner.py b/gauntlet/scenarios/runner.py new file mode 100644 index 0000000..ba83e11 --- /dev/null +++ b/gauntlet/scenarios/runner.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +""" +Python scenario runner for the GLYPH cross-language gauntlet. + +Reads the shared inputs.json, runs every scenario applicable to the Python +implementation, and prints a single JSON evidence object to stdout. It does NOT +decide pass/fail — that is the orchestrator's job (one evaluator, applied +identically to every language → "consistent evaluation method"). + +Applicable scenarios: S1, S2, S3, S4, S5, S6, S8. +(S7 / GS1 stream framing is Go+JS only — Python has no GS1 surface.) + +Usage: python3 runner.py +""" +import json +import os +import platform +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.join(HERE, "..", "..", "py")) + +import glyph # noqa: E402 +from glyph import ( # noqa: E402 + from_json_loose, to_json_loose, canonicalize_loose, + canonicalize_loose_no_tabular, parse_loose, fingerprint_loose, + equal_loose, parse_patch, apply_patch, compute_base_fingerprint, + verify_patch_base, PatchBaseMismatch, StreamingValidator, ToolRegistry, +) + + +def _cjson(value): + """Compact, key-sorted JSON bytes count helper input.""" + return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True) + + +def scenario(fn): + try: + return {"ok": True, "evidence": fn()} + except Exception as exc: # fail loud, per-scenario isolation + return {"ok": False, "error": f"{type(exc).__name__}: {exc}"} + + +# ── S1 ────────────────────────────────────────────────────────────────────── +def s1(inp): + snap = inp["S1_json_bridge"]["snapshot"] + gv = from_json_loose(snap) + back = to_json_loose(gv) + return {"roundtrip": back, "equals_input": back == snap} + + +# ── S2 ────────────────────────────────────────────────────────────────────── +def s2(inp): + data = inp["S2_canonical"] + canons = [canonicalize_loose(from_json_loose(v)) for v in data["variants"]] + floats = {t: canonicalize_loose(parse_loose(t)) for t in data["floats_text"]} + return { + "canonical": canons[0], + "variants_consistent": all(c == canons[0] for c in canons), + "floats": floats, + } + + +# ── S3 ────────────────────────────────────────────────────────────────────── +def s3(inp): + d = inp["S3_fingerprint"] + return { + "fp_base": fingerprint_loose(from_json_loose(d["base"])), + "fp_equiv": fingerprint_loose(from_json_loose(d["equiv"])), + "fp_mutated": fingerprint_loose(from_json_loose(d["mutated"])), + } + + +# ── S4 ────────────────────────────────────────────────────────────────────── +def s4(inp): + trace = inp["S4_tabular"]["trace"] + gv = from_json_loose(trace) + tab = canonicalize_loose(gv) + lst = canonicalize_loose_no_tabular(gv) + recovered = parse_loose(tab) + return { + "is_tabular": "@tab" in tab, + "canonical_tab": tab, + "bytes_json": len(_cjson(trace).encode()), + "bytes_list": len(lst.encode()), + "bytes_tab": len(tab.encode()), + "roundtrip_ok": equal_loose(gv, recovered), + "fp_recovered": fingerprint_loose(recovered), + } + + +# ── S5 ────────────────────────────────────────────────────────────────────── +def s5(inp): + d = inp["S5_patch_apply"] + base = from_json_loose(d["base"]) + before = to_json_loose(base) + patch = parse_patch(d["patch_text"]) + result = apply_patch(base, patch) + return { + "result": to_json_loose(result), + "fp_result": fingerprint_loose(result), + "base_unchanged": to_json_loose(base) == before, + } + + +# ── S6 ────────────────────────────────────────────────────────────────────── +def s6(inp): + d = inp["S6_patch_base"] + state = from_json_loose(d["state"]) + base16 = compute_base_fingerprint(state) + ops = "\n".join(d["patch_op_lines"]) + + happy = parse_patch(f"@patch @base={base16} @target={d['target']}\n{ops}\n@end") + try: + verify_patch_base(state, happy) + accept = True + except PatchBaseMismatch: + accept = False + + stale = parse_patch(f"@patch @base={d['stale_base']} @target={d['target']}\n{ops}\n@end") + try: + verify_patch_base(state, stale) + reject = False + except PatchBaseMismatch: + reject = True + + return {"base16": base16, "verify_accept": accept, "verify_reject": reject} + + +# ── S8 ────────────────────────────────────────────────────────────────────── +def _registry(spec): + reg = ToolRegistry() + for name, fields in spec.items(): + reg.add_tool(name, {k: {"type": v} for k, v in fields.items()}) + return reg + + +def _feed(spec, text): + v = StreamingValidator(_registry(spec)) + stop_index = -1 + res = None + for ch in text: + res = v.push_token(ch) + if res.errors and stop_index == -1: + stop_index = v.char_count + return v, res, stop_index + + +def s8(inp): + d = inp["S8_firewall"] + spec = d["registry"] + + _, ar, _ = _feed(spec, d["allowed_py"]) + allowed_unknown = any("UNKNOWN_TOOL" in e for e in ar.errors) + allowed_accepted = ar.complete and ar.valid and not allowed_unknown + + bv, br, stop = _feed(spec, d["blocked_py"]) + blocked_unknown = any("UNKNOWN_TOOL" in e for e in br.errors) + total = len(d["blocked_py"]) + return { + "allowed_accepted": allowed_accepted, + "allowed_tool": ar.tool_name, + "blocked_rejected": bool(br.should_cancel and blocked_unknown), + "blocked_code": "UNKNOWN_TOOL" if blocked_unknown else (br.errors[0] if br.errors else ""), + "blocked_tool_seen": br.tool_name, + "stop_index": stop, + "total_len": total, + "bytes_avoided": (total - stop) if stop >= 0 else 0, + } + + +def main(): + inputs_path = sys.argv[1] if len(sys.argv) > 1 else os.path.join(HERE, "inputs.json") + with open(inputs_path, encoding="utf-8") as fh: + inp = json.load(fh) + + out = { + "lang": "py", + "version": f"Python {platform.python_version()}", + "scenarios": { + "S1": scenario(lambda: s1(inp)), + "S2": scenario(lambda: s2(inp)), + "S3": scenario(lambda: s3(inp)), + "S4": scenario(lambda: s4(inp)), + "S5": scenario(lambda: s5(inp)), + "S6": scenario(lambda: s6(inp)), + "S8": scenario(lambda: s8(inp)), + }, + } + json.dump(out, sys.stdout, ensure_ascii=False) + + +if __name__ == "__main__": + main() diff --git a/gauntlet/web/glyph.bundle.js b/gauntlet/web/glyph.bundle.js new file mode 100644 index 0000000..cef0cab --- /dev/null +++ b/gauntlet/web/glyph.bundle.js @@ -0,0 +1,6433 @@ +"use strict"; +var Glyph = (() => { + var __create = Object.create; + var __defProp = Object.defineProperty; + var __getOwnPropDesc = Object.getOwnPropertyDescriptor; + var __getOwnPropNames = Object.getOwnPropertyNames; + var __getProtoOf = Object.getPrototypeOf; + var __hasOwnProp = Object.prototype.hasOwnProperty; + var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, { + get: (a, b) => (typeof require !== "undefined" ? require : a)[b] + }) : x)(function(x) { + if (typeof require !== "undefined") return require.apply(this, arguments); + throw Error('Dynamic require of "' + x + '" is not supported'); + }); + var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); + }; + var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; + }; + var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( + // If the importer is in node compatibility mode or this is not an ESM + // file that has been converted to a CommonJS file using a Babel- + // compatible transform (i.e. "__esModule" has not been set), then set + // "default" to the CommonJS "module.exports" for node compatibility. + isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, + mod + )); + var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); + + // src/index.ts + var index_exports = {}; + __export(index_exports, { + DEFAULT_MAX_BUFFER: () => DEFAULT_MAX_BUFFER, + DEFAULT_MAX_ERRORS: () => DEFAULT_MAX_ERRORS, + DEFAULT_MAX_FIELDS: () => DEFAULT_MAX_FIELDS, + Decimal128: () => Decimal128, + DecimalError: () => DecimalError, + ErrorCode: () => ErrorCode, + EvolutionMode: () => EvolutionMode, + EvolvingField: () => EvolvingField, + GValue: () => GValue, + PatchBuilder: () => PatchBuilder, + Schema: () => Schema, + SchemaBuilder: () => SchemaBuilder, + StreamingValidator: () => StreamingValidator, + ToolRegistry: () => ToolRegistry, + ValidatorState: () => ValidatorState, + VersionSchema: () => VersionSchema, + VersionedSchema: () => VersionedSchema, + applyPatch: () => applyPatch, + buildKeyDictFromValue: () => buildKeyDictFromValue, + canonicalizeLoose: () => canonicalizeLoose, + canonicalizeLooseNoTabular: () => canonicalizeLooseNoTabular, + canonicalizeLooseWithOpts: () => canonicalizeLooseWithOpts, + canonicalizeLooseWithSchema: () => canonicalizeLooseWithSchema, + compareTokens: () => compareTokens, + compareVersions: () => compareVersions, + decimal: () => decimal, + defaultLooseCanonOpts: () => defaultLooseCanonOpts, + defaultToolRegistry: () => defaultToolRegistry, + emit: () => emit, + emitHeader: () => emitHeader, + emitPacked: () => emitPacked, + emitPatch: () => emitPatch, + emitTabular: () => emitTabular, + emitV2: () => emitV2, + equalLoose: () => equalLoose, + estimateTokens: () => estimateTokens, + field: () => field, + fieldSeg: () => fieldSeg, + fingerprintLoose: () => fingerprintLoose, + formatVersionHeader: () => formatVersionHeader, + fromJson: () => fromJson, + fromJsonLoose: () => fromJsonLoose, + g: () => g, + isDecimalLiteral: () => isDecimalLiteral, + jsonEqual: () => jsonEqual, + jsonToLyph: () => jsonToLyph, + jsonToPacked: () => jsonToPacked, + jsonToTabular: () => jsonToTabular, + listIdxSeg: () => listIdxSeg, + llmLooseCanonOpts: () => llmLooseCanonOpts, + mapKeySeg: () => mapKeySeg, + noTabularLooseCanonOpts: () => noTabularLooseCanonOpts, + normalizeJson: () => normalizeJson, + parseDecimalLiteral: () => parseDecimalLiteral, + parseHeader: () => parseHeader, + parseJson: () => parseJson, + parseJsonLoose: () => parseJsonLoose, + parseLoose: () => parseLoose, + parsePacked: () => parsePacked, + parsePatch: () => parsePatch, + parsePathToSegs: () => parsePathToSegs, + parseSchemaHeader: () => parseSchemaHeader, + parseTabular: () => parseTabular, + parseTabularLoose: () => parseTabularLoose, + parseTabularLooseHeaderWithMeta: () => parseTabularLooseHeaderWithMeta, + parseVersionHeader: () => parseVersionHeader, + stream: () => stream_exports, + stringifyJson: () => stringifyJson, + stringifyJsonLoose: () => stringifyJsonLoose, + t: () => t, + toJson: () => toJson, + toJsonLoose: () => toJsonLoose, + unescapeTabularCell: () => unescapeTabularCell, + versionedSchema: () => versionedSchema + }); + + // src/types.ts + var GValue = class _GValue { + constructor(type) { + this.type = type; + } + // ============================================================ + // Constructors + // ============================================================ + static null() { + return new _GValue("null"); + } + static bool(v) { + const gv = new _GValue("bool"); + gv._bool = v; + return gv; + } + static int(v) { + const gv = new _GValue("int"); + gv._int = Math.floor(v); + return gv; + } + static float(v) { + const gv = new _GValue("float"); + gv._float = v; + return gv; + } + static str(v) { + const gv = new _GValue("str"); + gv._str = v; + return gv; + } + static bytes(v) { + const gv = new _GValue("bytes"); + gv._bytes = v; + return gv; + } + static time(v) { + const gv = new _GValue("time"); + gv._time = v; + return gv; + } + static id(prefix, value) { + const gv = new _GValue("id"); + gv._id = { prefix, value }; + return gv; + } + static idFromRef(ref) { + const gv = new _GValue("id"); + gv._id = ref; + return gv; + } + static list(...values) { + const gv = new _GValue("list"); + gv._list = values; + return gv; + } + static map(...entries) { + const gv = new _GValue("map"); + gv._map = entries; + return gv; + } + static struct(typeName, ...fields) { + const gv = new _GValue("struct"); + gv._struct = { typeName, fields }; + return gv; + } + static sum(tag, value) { + const gv = new _GValue("sum"); + gv._sum = { tag, value }; + return gv; + } + // ============================================================ + // Accessors + // ============================================================ + isNull() { + return this.type === "null"; + } + asBool() { + if (this.type !== "bool") throw new Error("not a bool"); + return this._bool; + } + asInt() { + if (this.type !== "int") throw new Error("not an int"); + return this._int; + } + asFloat() { + if (this.type !== "float") throw new Error("not a float"); + return this._float; + } + asStr() { + if (this.type !== "str") throw new Error("not a str"); + return this._str; + } + asBytes() { + if (this.type !== "bytes") throw new Error("not bytes"); + return this._bytes; + } + asTime() { + if (this.type !== "time") throw new Error("not a time"); + return this._time; + } + asId() { + if (this.type !== "id") throw new Error("not an id"); + return this._id; + } + asList() { + if (this.type !== "list") throw new Error("not a list"); + return this._list; + } + asMap() { + if (this.type !== "map") throw new Error("not a map"); + return this._map; + } + asStruct() { + if (this.type !== "struct") throw new Error("not a struct"); + return this._struct; + } + asSum() { + if (this.type !== "sum") throw new Error("not a sum"); + return this._sum; + } + /** + * Get numeric value as number (works for int or float) + */ + asNumber() { + if (this.type === "int") return this._int; + if (this.type === "float") return this._float; + throw new Error("not a number"); + } + /** + * Get field from struct or map by key + */ + get(key) { + if (this.type === "struct") { + for (const f of this._struct.fields) { + if (f.key === key) return f.value; + } + return null; + } + if (this.type === "map") { + for (const e of this._map) { + if (e.key === key) return e.value; + } + return null; + } + return null; + } + /** + * Get element from list by index + */ + index(i) { + if (this.type !== "list") throw new Error("not a list"); + if (i < 0 || i >= this._list.length) throw new Error("index out of bounds"); + return this._list[i]; + } + /** + * Get length of list, map, or struct fields + */ + len() { + if (this.type === "list") return this._list.length; + if (this.type === "map") return this._map.length; + if (this.type === "struct") return this._struct.fields.length; + return 0; + } + // ============================================================ + // Mutators + // ============================================================ + /** + * Set field on struct or map + */ + set(key, value) { + if (this.type === "struct") { + for (let i = 0; i < this._struct.fields.length; i++) { + if (this._struct.fields[i].key === key) { + this._struct.fields[i].value = value; + return; + } + } + this._struct.fields.push({ key, value }); + } else if (this.type === "map") { + for (let i = 0; i < this._map.length; i++) { + if (this._map[i].key === key) { + this._map[i].value = value; + return; + } + } + this._map.push({ key, value }); + } else { + throw new Error("cannot set on non-struct/map"); + } + } + /** + * Append to list + */ + append(value) { + if (this.type !== "list") throw new Error("cannot append to non-list"); + this._list.push(value); + } + // ============================================================ + // Deep Copy + // ============================================================ + clone() { + switch (this.type) { + case "null": + return _GValue.null(); + case "bool": + return _GValue.bool(this._bool); + case "int": + return _GValue.int(this._int); + case "float": + return _GValue.float(this._float); + case "str": + return _GValue.str(this._str); + case "bytes": + return _GValue.bytes(new Uint8Array(this._bytes)); + case "time": + return _GValue.time(new Date(this._time)); + case "id": + return _GValue.id(this._id.prefix, this._id.value); + case "list": + return _GValue.list(...this._list.map((v) => v.clone())); + case "map": + return _GValue.map(...this._map.map((e) => ({ key: e.key, value: e.value.clone() }))); + case "struct": + return _GValue.struct( + this._struct.typeName, + ...this._struct.fields.map((f) => ({ key: f.key, value: f.value.clone() })) + ); + case "sum": + return _GValue.sum(this._sum.tag, this._sum.value?.clone() ?? null); + } + } + }; + function field(key, value) { + return { key, value }; + } + var g = { + null: GValue.null, + bool: GValue.bool, + int: GValue.int, + float: GValue.float, + str: GValue.str, + bytes: GValue.bytes, + time: GValue.time, + id: GValue.id, + list: GValue.list, + map: GValue.map, + struct: GValue.struct, + sum: GValue.sum, + field + }; + + // src/schema.ts + var import_crypto = __require("crypto"); + var Schema = class { + constructor() { + this.types = /* @__PURE__ */ new Map(); + this.hash = ""; + } + getType(name) { + return this.types.get(name); + } + getField(typeName, fieldName) { + const td = this.types.get(typeName); + if (!td || td.kind !== "struct" || !td.fields) return void 0; + return td.fields.find((f) => f.name === fieldName || f.wireKey === fieldName); + } + /** + * Get fields sorted by FID + */ + fieldsByFid(typeName) { + const td = this.types.get(typeName); + if (!td || !td.fields) return []; + return [...td.fields].sort((a, b) => a.fid - b.fid); + } + /** + * Get required fields sorted by FID + */ + requiredFieldsByFid(typeName) { + return this.fieldsByFid(typeName).filter((f) => !f.optional); + } + /** + * Get optional fields sorted by FID + */ + optionalFieldsByFid(typeName) { + return this.fieldsByFid(typeName).filter((f) => f.optional); + } + /** + * Compute schema hash (SHA-256, first 16 bytes = 32 hex chars). + * Matches Go schema.go:238 (sha256.Sum256[:16] → hex.EncodeToString). + */ + computeHash() { + const canonical = this.canonical(); + const digest = (0, import_crypto.createHash)("sha256").update(canonical).digest(); + this.hash = digest.slice(0, 16).toString("hex"); + return this.hash; + } + /** + * Get canonical representation + */ + canonical() { + const lines = ["@schema{"]; + const names = [...this.types.keys()].sort(); + for (const name of names) { + const td = this.types.get(name); + const openPrefix = td.open ? "@open " : ""; + lines.push(` ${name}${td.version ? ":" + td.version : ""} ${openPrefix}${td.kind}{`); + if (td.kind === "struct" && td.fields) { + for (const f of td.fields) { + let line = ` ${f.name}: ${typeSpecToString(f.type)}`; + if (f.wireKey) line += ` @k(${f.wireKey})`; + if (f.optional) line += " [optional]"; + lines.push(line); + } + } + lines.push(" }"); + } + lines.push("}"); + return lines.join("\n"); + } + }; + function typeSpecToString(ts) { + switch (ts.kind) { + case "list": + return `list<${typeSpecToString(ts.elem)}>`; + case "map": + return `map<${typeSpecToString(ts.keyType)},${typeSpecToString(ts.valType)}>`; + case "ref": + return ts.name; + default: + return ts.kind; + } + } + var SchemaBuilder = class { + constructor() { + this.schema = new Schema(); + } + /** + * Add a struct type + */ + addStruct(name, version) { + this.currentType = { + name, + version, + kind: "struct", + fields: [], + tabEnabled: true + }; + this.schema.types.set(name, this.currentType); + return this; + } + /** + * Add a packed struct type (packed encoding enabled by default) + */ + addPackedStruct(name, version) { + this.currentType = { + name, + version, + kind: "struct", + fields: [], + packEnabled: true, + tabEnabled: true + }; + this.schema.types.set(name, this.currentType); + return this; + } + /** + * Add an open struct type (accepts unknown fields) + */ + addOpenStruct(name, version) { + this.currentType = { + name, + version, + kind: "struct", + fields: [], + open: true, + tabEnabled: true + }; + this.schema.types.set(name, this.currentType); + return this; + } + /** + * Add an open packed struct type (accepts unknown fields + packed encoding) + */ + addOpenPackedStruct(name, version) { + this.currentType = { + name, + version, + kind: "struct", + fields: [], + open: true, + packEnabled: true, + tabEnabled: true + }; + this.schema.types.set(name, this.currentType); + return this; + } + /** + * Add a field to the current struct + */ + field(name, type, options) { + if (!this.currentType || this.currentType.kind !== "struct") { + throw new Error("No struct type in progress"); + } + const fid = options?.fid ?? this.currentType.fields.length + 1; + this.currentType.fields.push({ + name, + type, + fid, + ...options + }); + return this; + } + /** + * Add a sum type + */ + addSum(name, version) { + this.currentType = { + name, + version, + kind: "sum", + variants: [] + }; + this.schema.types.set(name, this.currentType); + return this; + } + /** + * Add a variant to the current sum type + */ + variant(tag, type) { + if (!this.currentType || this.currentType.kind !== "sum") { + throw new Error("No sum type in progress"); + } + this.currentType.variants.push({ tag, type }); + return this; + } + /** + * Enable packed encoding for a type + */ + withPack(typeName) { + const td = this.schema.types.get(typeName); + if (td) td.packEnabled = true; + return this; + } + /** + * Enable tabular encoding for a type + */ + withTab(typeName) { + const td = this.schema.types.get(typeName); + if (td) td.tabEnabled = true; + return this; + } + /** + * Mark a type as open (accepts unknown fields) + */ + withOpen(typeName) { + const td = this.schema.types.get(typeName); + if (td) td.open = true; + return this; + } + /** + * Build and return the schema + */ + build() { + this.schema.computeHash(); + return this.schema; + } + }; + var t = { + null: () => ({ kind: "null" }), + bool: () => ({ kind: "bool" }), + int: () => ({ kind: "int" }), + float: () => ({ kind: "float" }), + str: () => ({ kind: "str" }), + bytes: () => ({ kind: "bytes" }), + time: () => ({ kind: "time" }), + id: () => ({ kind: "id" }), + list: (elem) => ({ kind: "list", elem }), + map: (keyType, valType) => ({ kind: "map", keyType, valType }), + ref: (name) => ({ kind: "ref", name }) + }; + + // src/json.ts + var hasOwnProperty = Object.prototype.hasOwnProperty; + function hasOwn(obj, key) { + return hasOwnProperty.call(obj, key); + } + function createJsonObject() { + return /* @__PURE__ */ Object.create(null); + } + function fromJson(json, options = {}) { + const { schema, typeName, parseDates = true, parseRefs = true } = options; + return convertValue(json, schema, typeName, parseDates, parseRefs); + } + function convertValue(v, schema, typeName, parseDates, parseRefs) { + if (v === null || v === void 0) { + return GValue.null(); + } + if (typeof v === "boolean") { + return GValue.bool(v); + } + if (typeof v === "number") { + if (Number.isInteger(v)) { + return GValue.int(v); + } + return GValue.float(v); + } + if (typeof v === "string") { + if (parseRefs && v.startsWith("^")) { + const rest = v.slice(1); + const colonIdx = rest.indexOf(":"); + if (colonIdx > 0) { + return GValue.id(rest.slice(0, colonIdx), rest.slice(colonIdx + 1)); + } + return GValue.id("", rest); + } + if (parseDates && isIsoDateString(v)) { + const date = new Date(v); + if (!isNaN(date.getTime())) { + return GValue.time(date); + } + } + return GValue.str(v); + } + if (Array.isArray(v)) { + const items = v.map((item) => convertValue(item, schema, void 0, parseDates, parseRefs)); + return GValue.list(...items); + } + if (typeof v === "object") { + const obj = v; + const typeMarker = hasOwn(obj, "$type") ? obj.$type : void 0; + const refMarker = hasOwn(obj, "$ref") ? obj.$ref : void 0; + const timeMarker = hasOwn(obj, "$time") ? obj.$time : void 0; + const bytesMarker = hasOwn(obj, "$bytes") ? obj.$bytes : void 0; + const tagMarker = hasOwn(obj, "$tag") ? obj.$tag : void 0; + if (typeof typeMarker === "string") { + const structTypeName = typeMarker; + const td = schema?.getType(structTypeName); + const fields = []; + for (const [key, val] of Object.entries(obj)) { + if (key === "$type") continue; + const fieldDef = td?.fields?.find((f) => f.name === key || f.wireKey === key); + const fieldTypeName = fieldDef?.type.kind === "ref" ? fieldDef.type.name : void 0; + fields.push({ + key, + value: convertValue(val, schema, fieldTypeName, parseDates, parseRefs) + }); + } + return GValue.struct(structTypeName, ...fields); + } + if (typeof refMarker === "string") { + const ref = refMarker; + const colonIdx = ref.indexOf(":"); + if (colonIdx > 0) { + return GValue.id(ref.slice(0, colonIdx), ref.slice(colonIdx + 1)); + } + return GValue.id("", ref); + } + if (typeof timeMarker === "string") { + return GValue.time(new Date(timeMarker)); + } + if (typeof bytesMarker === "string") { + return GValue.bytes(base64ToBytes(bytesMarker)); + } + if (typeof tagMarker === "string") { + const value = hasOwn(obj, "$value") ? convertValue(obj.$value, schema, void 0, parseDates, parseRefs) : null; + return GValue.sum(tagMarker, value); + } + if (typeName) { + const td = schema?.getType(typeName); + const fields = []; + for (const [key, val] of Object.entries(obj)) { + const fieldDef = td?.fields?.find((f) => f.name === key || f.wireKey === key); + const fieldTypeName = fieldDef?.type.kind === "ref" ? fieldDef.type.name : void 0; + fields.push({ + key, + value: convertValue(val, schema, fieldTypeName, parseDates, parseRefs) + }); + } + return GValue.struct(typeName, ...fields); + } + const entries = []; + for (const [key, val] of Object.entries(obj)) { + entries.push({ + key, + value: convertValue(val, schema, void 0, parseDates, parseRefs) + }); + } + return GValue.map(...entries); + } + throw new Error(`Unsupported JSON value type: ${typeof v}`); + } + function isIsoDateString(s) { + return /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2})?/.test(s); + } + function base64ToBytes(b64) { + if (typeof atob === "function") { + const binary = atob(b64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; + } + return new Uint8Array(Buffer.from(b64, "base64")); + } + function toJson(gv, options = {}) { + const { + includeTypeMarkers = false, + compactRefs = true, + formatDates = true, + useWireKeys = false, + schema + } = options; + return convertToJson(gv, includeTypeMarkers, compactRefs, formatDates, useWireKeys, schema); + } + function convertToJson(gv, includeTypeMarkers, compactRefs, formatDates, useWireKeys, schema) { + switch (gv.type) { + case "null": + return null; + case "bool": + return gv.asBool(); + case "int": + return gv.asInt(); + case "float": + return gv.asFloat(); + case "str": + return gv.asStr(); + case "bytes": { + const bytes = gv.asBytes(); + const b64 = bytesToBase64(bytes); + const result = createJsonObject(); + result.$bytes = b64; + return result; + } + case "time": { + const date = gv.asTime(); + if (formatDates) { + return date.toISOString(); + } + const result = createJsonObject(); + result.$time = date.toISOString(); + return result; + } + case "id": { + const ref = gv.asId(); + const refStr = ref.prefix ? `${ref.prefix}:${ref.value}` : ref.value; + if (compactRefs) { + return `^${refStr}`; + } + const result = createJsonObject(); + result.$ref = refStr; + return result; + } + case "list": { + return gv.asList().map( + (item) => convertToJson(item, includeTypeMarkers, compactRefs, formatDates, useWireKeys, schema) + ); + } + case "map": { + const result = createJsonObject(); + for (const entry of gv.asMap()) { + result[entry.key] = convertToJson( + entry.value, + includeTypeMarkers, + compactRefs, + formatDates, + useWireKeys, + schema + ); + } + return result; + } + case "struct": { + const sv = gv.asStruct(); + const result = createJsonObject(); + if (includeTypeMarkers) { + result.$type = sv.typeName; + } + const td = schema?.getType(sv.typeName); + for (const field2 of sv.fields) { + let key = field2.key; + if (useWireKeys && td) { + const fd = td.fields?.find((f) => f.name === field2.key); + if (fd?.wireKey) { + key = fd.wireKey; + } + } + result[key] = convertToJson( + field2.value, + includeTypeMarkers, + compactRefs, + formatDates, + useWireKeys, + schema + ); + } + return result; + } + case "sum": { + const sum = gv.asSum(); + const result = createJsonObject(); + result.$tag = sum.tag; + if (sum.value === null) { + return result; + } + result.$value = convertToJson( + sum.value, + includeTypeMarkers, + compactRefs, + formatDates, + useWireKeys, + schema + ); + return result; + } + } + } + function bytesToBase64(bytes) { + if (typeof btoa === "function") { + let binary = ""; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); + } + return Buffer.from(bytes).toString("base64"); + } + function parseJson(jsonStr, options = {}) { + const json = JSON.parse(jsonStr); + return fromJson(json, options); + } + function stringifyJson(gv, options = {}, indent) { + const json = toJson(gv, options); + return JSON.stringify(json, null, indent); + } + function normalizeJson(json, fromOptions = {}, toOptions = {}) { + const gv = fromJson(json, fromOptions); + return toJson(gv, toOptions); + } + + // src/codec_primitives.ts + var NULL_SYMBOL = "\u2205"; + function canonNull() { + return NULL_SYMBOL; + } + function canonBool(v) { + return v ? "t" : "f"; + } + function canonInt(n) { + if (n === 0) return "0"; + return String(Math.floor(n)); + } + function normalizeExpStr(jsExp) { + return jsExp.replace(/[eE]([+-]?)(\d+)$/, (_match, sign, digits) => { + const signChar = sign === "-" ? "-" : "+"; + const paddedDigits = digits.length === 1 ? "0" + digits : digits; + return "e" + signChar + paddedDigits; + }); + } + function decimalToGoExp(absF) { + let expStr = absF.toExponential(); + expStr = expStr.replace(/\.?0+(e)/, "$1"); + return normalizeExpStr(expStr); + } + function canonFloat(f) { + if (Number.isNaN(f)) return "NaN"; + if (f === Infinity) return "Inf"; + if (f === -Infinity) return "-Inf"; + if (f === 0 || Object.is(f, -0)) return "0.0"; + const absF = Math.abs(f); + const neg = f < 0; + const jsStr = String(absF); + let s; + if (jsStr.includes("e") || jsStr.includes("E")) { + s = normalizeExpStr(jsStr); + } else { + const E = Math.floor(Math.log10(absF)); + if (E >= 6 || E <= -5) { + s = decimalToGoExp(absF); + } else { + s = jsStr; + if (!s.includes(".") && !s.includes("e")) { + s = s + ".0"; + } + } + } + return neg ? "-" + s : s; + } + function canonString(s) { + if (isBareSafe(s)) { + return s; + } + return quoteString(s); + } + function isLetter(c) { + return c >= 65 && c <= 90 || c >= 97 && c <= 122; + } + function isDigit(c) { + return c >= 48 && c <= 57; + } + function isBareSafe(s) { + if (s.length === 0) return false; + if (["t", "f", "_", "true", "false", "null", "none", "nil", "struct", "sum", "list", "map", "NaN", "Inf"].includes(s)) { + return false; + } + const first = s.charCodeAt(0); + if (!isLetter(first) && first !== 95) return false; + for (let i = 1; i < s.length; i++) { + const c = s.charCodeAt(i); + if (!isLetter(c) && !isDigit(c) && c !== 95) { + return false; + } + } + return true; + } + function isRefPartChar(c) { + return isLetter(c) || isDigit(c) || c === 95 || c === 45 || c === 46; + } + function isRefSafe(s) { + if (s.length === 0) return false; + const colonIdx = s.indexOf(":"); + if (colonIdx < 0) { + for (let i = 0; i < s.length; i++) { + if (!isRefPartChar(s.charCodeAt(i))) return false; + } + return true; + } + const prefix = s.slice(0, colonIdx); + const value = s.slice(colonIdx + 1); + for (let i = 0; i < prefix.length; i++) { + if (!isRefPartChar(prefix.charCodeAt(i))) return false; + } + for (let i = 0; i < value.length; i++) { + const c = value.charCodeAt(i); + if (c === 58 || !isRefPartChar(c)) return false; + } + return true; + } + function quoteString(s) { + let result = '"'; + for (const ch of s) { + switch (ch) { + case "\\": + result += "\\\\"; + break; + case '"': + result += '\\"'; + break; + case "\n": + result += "\\n"; + break; + case "\r": + result += "\\r"; + break; + case " ": + result += "\\t"; + break; + default: + if (ch.charCodeAt(0) < 32) { + result += "\\u" + ch.charCodeAt(0).toString(16).padStart(4, "0"); + } else { + result += ch; + } + } + } + return result + '"'; + } + + // src/emit.ts + function canonRef(ref) { + const full = ref.prefix ? `${ref.prefix}:${ref.value}` : ref.value; + if (isRefSafe(full)) { + return `^${full}`; + } + return `^${quoteString(full)}`; + } + function canonTime(d) { + const ms = d.getUTCMilliseconds(); + if (ms === 0) { + return d.toISOString().replace(/\.\d{3}Z$/, "Z"); + } + const msStr = ms.toString().padStart(3, "0").replace(/0+$/, ""); + return d.toISOString().replace(/\.\d{3}Z$/, "." + msStr + "Z"); + } + function maskToBinary(mask) { + let hi = -1; + for (let i = mask.length - 1; i >= 0; i--) { + if (mask[i]) { + hi = i; + break; + } + } + if (hi === -1) return "0b0"; + let result = "0b"; + for (let i = hi; i >= 0; i--) { + result += mask[i] ? "1" : "0"; + } + return result; + } + function emit(gv, options = {}) { + return emitValue(gv, options); + } + function emitValue(gv, opts) { + switch (gv.type) { + case "null": + return canonNull(); + case "bool": + return canonBool(gv.asBool()); + case "int": + return canonInt(gv.asInt()); + case "float": + return canonFloat(gv.asFloat()); + case "str": + return canonString(gv.asStr()); + case "bytes": + return "b64" + quoteString(bytesToBase642(gv.asBytes())); + case "time": + return canonTime(gv.asTime()); + case "id": + return canonRef(gv.asId()); + case "list": + return emitList(gv, opts); + case "map": + return emitMap(gv, opts); + case "struct": + return emitStruct(gv, opts); + case "sum": + return emitSum(gv, opts); + } + } + function emitList(gv, opts) { + const items = gv.asList().map((v) => emitValue(v, opts)); + return "[" + items.join(" ") + "]"; + } + function emitMap(gv, opts) { + const parts = []; + for (const entry of gv.asMap()) { + parts.push(`${canonString(entry.key)}:${emitValue(entry.value, opts)}`); + } + return "{" + parts.join(" ") + "}"; + } + function emitStruct(gv, opts) { + const sv = gv.asStruct(); + const parts = []; + const td = opts.schema?.getType(sv.typeName); + for (const field2 of sv.fields) { + let key = field2.key; + if (opts.keyMode === "wire" && td) { + const fd = td.fields?.find((f) => f.name === field2.key); + if (fd?.wireKey) key = fd.wireKey; + } else if (opts.keyMode === "fid" && td) { + const fd = td.fields?.find((f) => f.name === field2.key); + if (fd) key = `#${fd.fid}`; + } + parts.push(`${canonString(key)}=${emitValue(field2.value, opts)}`); + } + return `${sv.typeName}{${parts.join(" ")}}`; + } + function emitSum(gv, opts) { + const sum = gv.asSum(); + if (sum.value === null) { + return `${sum.tag}()`; + } + if (sum.value.type === "struct") { + return `${sum.tag}${emitStruct(sum.value, opts).slice(sum.value.asStruct().typeName.length)}`; + } + return `${sum.tag}(${emitValue(sum.value, opts)})`; + } + function emitPacked(gv, schema, options = {}) { + if (gv.type !== "struct") { + throw new Error("packed encoding requires struct value"); + } + const sv = gv.asStruct(); + const td = schema.getType(sv.typeName); + if (!td || td.kind !== "struct") { + throw new Error(`unknown struct type: ${sv.typeName}`); + } + const useBitmap = options.useBitmap !== false && shouldUseBitmap(gv, td, schema); + if (useBitmap) { + return emitPackedBitmap(gv, td, schema, options); + } + return emitPackedDense(gv, td, schema, options); + } + function shouldUseBitmap(gv, td, schema) { + const optFields = schema.optionalFieldsByFid(td.name); + if (optFields.length === 0) return false; + for (const fd of optFields) { + const val = getFieldValue(gv, fd); + if (!isFieldPresent(val, fd)) { + return true; + } + } + return false; + } + function emitPackedDense(gv, td, schema, opts) { + const fields = schema.fieldsByFid(td.name); + const parts = []; + for (const fd of fields) { + const val = getFieldValue(gv, fd); + if (fd.optional && !isFieldPresent(val, fd)) { + parts.push(canonNull()); + continue; + } + if (!fd.optional && val === null) { + throw new Error(`missing required field: ${td.name}.${fd.name}`); + } + parts.push(emitPackedValue(val, schema, opts)); + } + return `${td.name}@(${parts.join(" ")})`; + } + function emitPackedBitmap(gv, td, schema, opts) { + const reqFields = schema.requiredFieldsByFid(td.name); + const optFields = schema.optionalFieldsByFid(td.name); + const mask = []; + for (const fd of optFields) { + const val = getFieldValue(gv, fd); + mask.push(isFieldPresent(val, fd)); + } + const parts = []; + for (const fd of reqFields) { + const val = getFieldValue(gv, fd); + if (val === null) { + throw new Error(`missing required field: ${td.name}.${fd.name}`); + } + parts.push(emitPackedValue(val, schema, opts)); + } + for (let i = 0; i < optFields.length; i++) { + if (!mask[i]) continue; + const val = getFieldValue(gv, optFields[i]); + parts.push(emitPackedValue(val, schema, opts)); + } + return `${td.name}@{bm=${maskToBinary(mask)}}(${parts.join(" ")})`; + } + function getFieldValue(gv, fd) { + const sv = gv.asStruct(); + for (const f of sv.fields) { + if (f.key === fd.name || f.key === fd.wireKey) { + return f.value; + } + } + return null; + } + function isFieldPresent(val, fd) { + if (val === null) return false; + if (val.type === "null" && fd.optional && !fd.keepNull) return false; + return true; + } + function emitPackedValue(gv, schema, opts) { + switch (gv.type) { + case "null": + return canonNull(); + case "bool": + return canonBool(gv.asBool()); + case "int": + return canonInt(gv.asInt()); + case "float": + return canonFloat(gv.asFloat()); + case "str": + return canonString(gv.asStr()); + case "bytes": + return "b64" + quoteString(bytesToBase642(gv.asBytes())); + case "time": + return canonTime(gv.asTime()); + case "id": + return canonRef(gv.asId()); + case "list": { + const items = gv.asList().map((v) => emitPackedValue(v, schema, opts)); + return "[" + items.join(" ") + "]"; + } + case "map": { + const parts = []; + for (const entry of gv.asMap()) { + parts.push(`${canonString(entry.key)}:${emitPackedValue(entry.value, schema, opts)}`); + } + return "{" + parts.join(" ") + "}"; + } + case "struct": { + const sv = gv.asStruct(); + const td = schema.getType(sv.typeName); + if (td?.packEnabled) { + return emitPacked(gv, schema, opts); + } + return emitStruct(gv, { ...opts, schema }); + } + case "sum": { + const sum = gv.asSum(); + if (sum.value === null) { + return `${sum.tag}()`; + } + return `${sum.tag}(${emitPackedValue(sum.value, schema, opts)})`; + } + } + } + function emitTabular(gv, schema, options = {}) { + if (gv.type !== "list") { + throw new Error("tabular encoding requires list value"); + } + const list = gv.asList(); + if (list.length === 0) { + return "[]"; + } + const first = list[0]; + if (first.type !== "struct") { + throw new Error("tabular encoding requires list of structs"); + } + const typeName = first.asStruct().typeName; + for (let i = 1; i < list.length; i++) { + if (list[i].type !== "struct" || list[i].asStruct().typeName !== typeName) { + throw new Error("all elements must be same type struct"); + } + } + const td = schema.getType(typeName); + if (!td) { + throw new Error(`unknown type: ${typeName}`); + } + const fields = schema.fieldsByFid(typeName); + const keyMode = options.keyMode || "wire"; + const indent = options.indentPrefix || ""; + const cols = fields.map((fd) => { + if (keyMode === "wire" && fd.wireKey) return fd.wireKey; + if (keyMode === "fid") return `#${fd.fid}`; + return fd.name; + }); + let result = `@tab ${typeName} [${cols.join(" ")}] +`; + for (const row of list) { + result += indent; + const cells = []; + for (const fd of fields) { + const val = getFieldValue(row, fd); + if (!isFieldPresent(val, fd)) { + cells.push(canonNull()); + } else { + cells.push(emitPackedValue(val, schema, options)); + } + } + result += cells.join(" ") + "\n"; + } + result += "@end"; + return result; + } + function emitHeader(options = {}) { + const parts = ["@lyph", options.version || "v2"]; + if (options.schemaId) { + parts.push(`@schema#${options.schemaId}`); + } + if (options.mode && options.mode !== "auto") { + parts.push(`@mode=${options.mode}`); + } + if (options.keyMode && options.keyMode !== "wire") { + parts.push(`@keys=${options.keyMode}`); + } + if (options.target) { + const ref = options.target.prefix ? `${options.target.prefix}:${options.target.value}` : options.target.value; + parts.push(`@target=${ref}`); + } + return parts.join(" "); + } + function emitV2(gv, schema, options = {}) { + const mode = options.mode || "auto"; + const tabThreshold = options.tabThreshold || 3; + let selectedMode = mode; + if (mode === "auto") { + selectedMode = selectMode(gv, schema, tabThreshold); + } + let body; + switch (selectedMode) { + case "tabular": + body = emitTabular(gv, schema, options); + break; + case "packed": + body = emitPacked(gv, schema, options); + break; + default: + body = emit(gv, { ...options, schema }); + } + if (options.includeHeader) { + const header = emitHeader({ + schemaId: schema.hash, + mode: selectedMode, + keyMode: options.keyMode + }); + return header + "\n" + body; + } + return body; + } + function selectMode(gv, schema, tabThreshold) { + if (gv.type === "list") { + const list = gv.asList(); + if (list.length >= tabThreshold && list[0]?.type === "struct") { + const typeName = list[0].asStruct().typeName; + const td = schema.getType(typeName); + if (td?.tabEnabled) { + return "tabular"; + } + } + } + if (gv.type === "struct") { + const td = schema.getType(gv.asStruct().typeName); + if (td?.packEnabled) { + return "packed"; + } + } + return "struct"; + } + function bytesToBase642(bytes) { + if (typeof btoa === "function") { + let binary = ""; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); + } + return Buffer.from(bytes).toString("base64"); + } + + // src/parse.ts + function parsePacked(input, schema) { + const parser = new PackedParser(input, schema); + return parser.parse(); + } + var MAX_PARSE_DEPTH = 128; + var MAX_COLLECTION_LEN = 1e6; + var MAX_STRING_LEN = 10 * 1024 * 1024; + var PackedParser = class { + constructor(input, schema) { + this.pos = 0; + this.depth = 0; + this.input = input; + this.schema = schema; + } + parse() { + this.skipWhitespace(); + const typeName = this.parseTypeName(); + this.expect("@"); + const td = this.schema.getType(typeName); + if (!td) { + throw new Error(`unknown type: ${typeName}`); + } + let mask = null; + if (this.peek() === "{") { + mask = this.parseBitmapHeader(); + } + this.expect("("); + let value; + if (mask) { + value = this.parseBitmapValues(typeName, mask); + } else { + value = this.parseDenseValues(typeName); + } + this.expect(")"); + this.skipWhitespace(); + if (this.pos !== this.input.length) { + throw new Error(`trailing garbage at pos ${this.pos}`); + } + return value; + } + parseTypeName() { + this.skipWhitespace(); + const start = this.pos; + if (this.pos >= this.input.length) { + throw new Error("unexpected end of input"); + } + if (!this.isTypeNameStart(this.input.charCodeAt(this.pos))) { + throw new Error(`expected type name at pos ${this.pos}`); + } + while (this.pos < this.input.length && this.isTypeNameCont(this.input.charCodeAt(this.pos))) { + this.pos++; + } + return this.input.slice(start, this.pos); + } + isTypeNameStart(c) { + return c >= 65 && c <= 90 || c >= 97 && c <= 122 || c === 95; + } + isTypeNameCont(c) { + return this.isTypeNameStart(c) || c >= 48 && c <= 57; + } + parseBitmapHeader() { + this.expect("{"); + this.skipWhitespace(); + this.expectLiteral("bm="); + this.expectLiteral("0b"); + const start = this.pos; + while (this.pos < this.input.length && (this.input[this.pos] === "0" || this.input[this.pos] === "1")) { + this.pos++; + } + const bits = this.input.slice(start, this.pos); + if (bits.length === 0) { + throw new Error("empty bitmap"); + } + const mask = []; + for (let i = bits.length - 1; i >= 0; i--) { + mask.push(bits[i] === "1"); + } + this.skipWhitespace(); + this.expect("}"); + return mask; + } + parseDenseValues(typeName) { + const fields = this.schema.fieldsByFid(typeName); + const entries = []; + for (let i = 0; i < fields.length; i++) { + const fd = fields[i]; + this.skipWhitespace(); + if (this.peek() === ")") { + for (let j = i; j < fields.length; j++) { + entries.push({ key: fields[j].name, value: GValue.null() }); + } + break; + } + const val = this.parseValue(fd.type.kind === "ref" ? fd.type.name : void 0); + entries.push({ key: fd.name, value: val }); + } + return GValue.struct(typeName, ...entries); + } + parseBitmapValues(typeName, mask) { + const reqFields = this.schema.requiredFieldsByFid(typeName); + const optFields = this.schema.optionalFieldsByFid(typeName); + const entries = []; + for (const fd of reqFields) { + this.skipWhitespace(); + const val = this.parseValue(fd.type.kind === "ref" ? fd.type.name : void 0); + entries.push({ key: fd.name, value: val }); + } + for (let i = 0; i < optFields.length; i++) { + const fd = optFields[i]; + if (i < mask.length && mask[i]) { + this.skipWhitespace(); + const val = this.parseValue(fd.type.kind === "ref" ? fd.type.name : void 0); + entries.push({ key: fd.name, value: val }); + } else { + entries.push({ key: fd.name, value: GValue.null() }); + } + } + return GValue.struct(typeName, ...entries); + } + parseValue(typeHint) { + this.depth++; + if (this.depth > MAX_PARSE_DEPTH) { + throw new Error(`maximum nesting depth exceeded (${MAX_PARSE_DEPTH})`); + } + try { + return this.parseValueInner(typeHint); + } finally { + this.depth--; + } + } + parseValueInner(typeHint) { + this.skipWhitespace(); + const c = this.peek(); + if (c === "\u2205") { + this.pos++; + return GValue.null(); + } + if (c === "t") { + if (this.tryLiteral("true") || this.tryLiteral("t")) { + return GValue.bool(true); + } + return this.parseBareString(); + } + if (c === "f") { + if (this.tryLiteral("false") || this.tryLiteral("f")) { + return GValue.bool(false); + } + return this.parseBareString(); + } + if (c === '"') { + return this.parseQuotedString(); + } + if (c === "^") { + return this.parseRef(); + } + if (c === "[") { + return this.parseList(); + } + if (c === "{") { + return this.parseMap(); + } + if (c === "b" && this.input.startsWith('b64"', this.pos)) { + return this.parseBytes(); + } + if (c === "-" || c >= "0" && c <= "9") { + return this.parseNumberOrTime(); + } + if (this.isTypeNameStart(c.charCodeAt(0))) { + const saved = this.pos; + const name = this.parseTypeName(); + if (this.peek() === "@") { + this.pos = saved; + return this.parseNestedPacked(); + } + return GValue.str(name); + } + throw new Error(`unexpected character at pos ${this.pos}: ${c}`); + } + parseNestedPacked() { + const typeName = this.parseTypeName(); + this.expect("@"); + const td = this.schema.getType(typeName); + if (!td) { + throw new Error(`unknown nested type: ${typeName}`); + } + let mask = null; + if (this.peek() === "{") { + mask = this.parseBitmapHeader(); + } + this.expect("("); + let value; + if (mask) { + value = this.parseBitmapValues(typeName, mask); + } else { + value = this.parseDenseValues(typeName); + } + this.expect(")"); + return value; + } + parseNumberOrTime() { + if (this.pos + 10 < this.input.length) { + const ahead = this.input.slice(this.pos, this.pos + 11); + if (/^\d{4}-\d{2}-\d{2}T/.test(ahead)) { + return this.parseTime(); + } + } + return this.parseNumber(); + } + parseTime() { + const start = this.pos; + while (this.pos < this.input.length) { + const c = this.input[this.pos]; + if (this.isTokenBoundary(c)) { + break; + } + this.pos++; + } + const timeStr = this.input.slice(start, this.pos); + const date = new Date(timeStr); + if (Number.isNaN(date.getTime())) { + throw new Error(`invalid time at pos ${start}`); + } + return GValue.time(date); + } + parseNumber() { + const start = this.pos; + const match = /^-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(this.input.slice(this.pos)); + if (!match) { + throw new Error(`invalid number at pos ${start}`); + } + const numStr = match[0]; + const next = this.input[this.pos + numStr.length] ?? ""; + if (next !== "" && !this.isTokenBoundary(next)) { + throw new Error(`invalid numeric token at pos ${start}`); + } + this.pos += numStr.length; + const num = Number(numStr); + if (!Number.isFinite(num)) { + throw new Error(`invalid number at pos ${start}`); + } + if (numStr.includes(".") || numStr.includes("e") || numStr.includes("E")) { + return GValue.float(num); + } + const intVal = parseInt(numStr, 10); + if (!Number.isSafeInteger(intVal)) { + throw new Error(`integer exceeds safe range at pos ${start}: ${numStr}`); + } + return GValue.int(intVal); + } + parseQuotedString() { + this.expect('"'); + let result = ""; + while (this.pos < this.input.length) { + const c = this.input[this.pos]; + if (c === '"') { + this.pos++; + return GValue.str(result); + } + if (result.length >= MAX_STRING_LEN) { + throw new Error(`string exceeds maximum length (${MAX_STRING_LEN})`); + } + if (c === "\\" && this.pos + 1 < this.input.length) { + this.pos++; + switch (this.input[this.pos]) { + case "n": + result += "\n"; + break; + case "r": + result += "\r"; + break; + case "t": + result += " "; + break; + case "\\": + result += "\\"; + break; + case '"': + result += '"'; + break; + case "u": { + const hex = this.input.slice(this.pos + 1, this.pos + 5); + if (hex.length < 4 || !/^[0-9a-fA-F]{4}$/.test(hex)) { + throw new Error(`invalid \\u escape at pos ${this.pos}`); + } + result += String.fromCharCode(parseInt(hex, 16)); + this.pos += 4; + break; + } + default: + result += this.input[this.pos]; + } + } else { + result += c; + } + this.pos++; + } + throw new Error("unterminated string"); + } + // parseBytes decodes a b64"..." literal into a bytes value. The cursor is on + // the leading 'b'. Invalid base64 is a hard error (never coerced to a string). + parseBytes() { + this.pos += 3; + this.expect('"'); + const start = this.pos; + while (this.pos < this.input.length && this.input[this.pos] !== '"') { + this.pos++; + } + if (this.pos >= this.input.length) { + throw new Error("unterminated bytes literal"); + } + const b64 = this.input.slice(start, this.pos); + this.pos++; + return GValue.bytes(base64ToBytes2(b64)); + } + parseBareString() { + const start = this.pos; + while (this.pos < this.input.length) { + const c = this.input[this.pos]; + if (c === " " || c === ")" || c === "]" || c === "}" || c === "\n") { + break; + } + this.pos++; + } + return GValue.str(this.input.slice(start, this.pos)); + } + parseRef() { + this.expect("^"); + if (this.peek() === '"') { + const s = this.parseQuotedString().asStr(); + const colonIdx2 = s.indexOf(":"); + if (colonIdx2 > 0) { + return GValue.id(s.slice(0, colonIdx2), s.slice(colonIdx2 + 1)); + } + return GValue.id("", s); + } + const start = this.pos; + while (this.pos < this.input.length) { + const c = this.input[this.pos]; + if (c === " " || c === ")" || c === "]" || c === "}" || c === "\n") { + break; + } + this.pos++; + } + const refStr = this.input.slice(start, this.pos); + const colonIdx = refStr.indexOf(":"); + if (colonIdx > 0) { + return GValue.id(refStr.slice(0, colonIdx), refStr.slice(colonIdx + 1)); + } + return GValue.id("", refStr); + } + parseList() { + this.expect("["); + const items = []; + while (true) { + this.skipWhitespace(); + if (this.peek() === "]") { + this.pos++; + return GValue.list(...items); + } + if (items.length >= MAX_COLLECTION_LEN) { + throw new Error(`list exceeds maximum length (${MAX_COLLECTION_LEN})`); + } + items.push(this.parseValue()); + } + } + parseMap() { + this.expect("{"); + const entries = []; + while (true) { + this.skipWhitespace(); + if (this.peek() === "}") { + this.pos++; + return GValue.map(...entries); + } + if (entries.length >= MAX_COLLECTION_LEN) { + throw new Error(`map exceeds maximum length (${MAX_COLLECTION_LEN})`); + } + const key = this.parseValue().asStr(); + this.skipWhitespace(); + if (this.peek() !== ":" && this.peek() !== "=") { + throw new Error(`expected ':' or '=' after map key`); + } + this.pos++; + const value = this.parseValue(); + const existing = entries.findIndex((e) => e.key === key); + if (existing >= 0) { + entries[existing].value = value; + } else { + entries.push({ key, value }); + } + } + } + skipWhitespace() { + while (this.pos < this.input.length) { + const c = this.input[this.pos]; + if (c !== " " && c !== " " && c !== "\n" && c !== "\r") break; + this.pos++; + } + } + peek() { + return this.pos < this.input.length ? this.input[this.pos] : ""; + } + isTokenBoundary(c) { + return c === "" || c === " " || c === " " || c === "\n" || c === "\r" || c === ")" || c === "]" || c === "}"; + } + expect(c) { + this.skipWhitespace(); + if (this.pos >= this.input.length || this.input[this.pos] !== c) { + throw new Error(`expected '${c}' at pos ${this.pos}`); + } + this.pos++; + } + expectLiteral(s) { + if (this.input.slice(this.pos, this.pos + s.length) !== s) { + throw new Error(`expected '${s}' at pos ${this.pos}`); + } + this.pos += s.length; + } + tryLiteral(s) { + if (this.input.slice(this.pos, this.pos + s.length) === s) { + const next = this.input.charCodeAt(this.pos + s.length); + if (this.isTypeNameCont(next)) { + return false; + } + this.pos += s.length; + return true; + } + return false; + } + }; + function parseHeader(input) { + const trimmed = input.trim(); + if (!trimmed.startsWith("@lyph") && !trimmed.startsWith("@glyph")) { + return null; + } + const header = { version: "v2" }; + const tokens = tokenizeHeader(trimmed); + for (let i = 0; i < tokens.length; i++) { + const tok = tokens[i]; + if (tok === "@lyph" || tok === "@glyph") { + if (i + 1 < tokens.length && !tokens[i + 1].startsWith("@")) { + header.version = tokens[++i]; + } + continue; + } + if (tok.startsWith("@schema#")) { + header.schemaId = tok.slice(8); + continue; + } + if (tok.startsWith("@mode=")) { + header.mode = tok.slice(6); + continue; + } + if (tok.startsWith("@keys=")) { + header.keyMode = tok.slice(6); + continue; + } + if (tok.startsWith("@target=")) { + const ref = tok.slice(8); + const colonIdx = ref.indexOf(":"); + if (colonIdx > 0) { + header.target = { prefix: ref.slice(0, colonIdx), value: ref.slice(colonIdx + 1) }; + } else { + header.target = { prefix: "", value: ref }; + } + continue; + } + } + return header; + } + function tokenizeHeader(input) { + const tokens = []; + let current = ""; + let inQuote = false; + for (const c of input) { + if (c === '"') { + inQuote = !inQuote; + current += c; + } else if (c === " " && !inQuote) { + if (current) { + tokens.push(current); + current = ""; + } + } else { + current += c; + } + } + if (current) tokens.push(current); + return tokens; + } + function parseTabular(input, schema) { + const lines = input.split("\n"); + if (lines.length === 0) { + throw new Error("empty tabular input"); + } + const headerLine = lines[0].trim(); + const { typeName, columns } = parseTabularHeader(headerLine); + const td = schema.getType(typeName); + if (!td) { + throw new Error(`unknown type: ${typeName}`); + } + if (!td.fields || td.fields.length === 0) { + throw new Error(`type ${typeName} has no fields`); + } + const fieldMap = /* @__PURE__ */ new Map(); + for (const fd of td.fields) { + fieldMap.set(fd.name, fd); + if (fd.wireKey) fieldMap.set(fd.wireKey, fd); + fieldMap.set(`#${fd.fid}`, fd); + } + const columnFields = columns.map((col) => { + const fd = fieldMap.get(col); + if (!fd) { + throw new Error(`unknown column: ${col}`); + } + return fd; + }); + const rows = []; + for (let i = 1; i < lines.length; i++) { + const line = lines[i].trim(); + if (line === "" || line.startsWith("#")) continue; + if (line === "@end") break; + const row = parseTabularRow(line, typeName, columnFields, schema); + rows.push(row); + } + return { typeName, columns, rows }; + } + function parseTabularHeader(line) { + if (!line.startsWith("@tab")) { + throw new Error("tabular must start with @tab"); + } + const rest = line.slice(4).trim(); + let pos = 0; + while (pos < rest.length && rest[pos] !== " " && rest[pos] !== "[") { + pos++; + } + const typeName = rest.slice(0, pos); + if (!typeName) { + throw new Error("missing type name after @tab"); + } + while (pos < rest.length && rest[pos] !== "[") pos++; + if (pos >= rest.length) { + throw new Error("missing column list in tabular header"); + } + pos++; + const colStart = pos; + while (pos < rest.length && rest[pos] !== "]") pos++; + const colStr = rest.slice(colStart, pos); + const columns = colStr.trim().split(/\s+/).filter((c) => c.length > 0); + return { typeName, columns }; + } + function parseTabularRow(line, typeName, columnFields, schema) { + const tokens = tokenizeRow(line); + if (tokens.length !== columnFields.length) { + throw new Error(`row has ${tokens.length} values, expected ${columnFields.length}`); + } + const entries = []; + for (let i = 0; i < tokens.length; i++) { + const fd = columnFields[i]; + const token = tokens[i]; + let value; + if (isPackedFormat(token)) { + value = parsePacked(token, schema); + } else { + value = parseScalarValue(token); + } + entries.push({ key: fd.name, value }); + } + return GValue.struct(typeName, ...entries); + } + function tokenizeRow(line) { + const tokens = []; + let pos = 0; + while (pos < line.length) { + while (pos < line.length && (line[pos] === " " || line[pos] === " ")) pos++; + if (pos >= line.length) break; + const start = pos; + const c = line[pos]; + if (c === '"') { + pos++; + while (pos < line.length && line[pos] !== '"') { + if (line[pos] === "\\") pos++; + pos++; + } + pos++; + } else if (c === "[") { + let depth = 1; + pos++; + while (pos < line.length && depth > 0) { + if (line[pos] === "[") depth++; + else if (line[pos] === "]") depth--; + pos++; + } + } else if (c === "{") { + let depth = 1; + pos++; + while (pos < line.length && depth > 0) { + if (line[pos] === "{") depth++; + else if (line[pos] === "}") depth--; + pos++; + } + } else { + while (pos < line.length) { + const ch = line[pos]; + if (ch === " " || ch === " ") break; + if (ch === "(") { + let depth = 1; + pos++; + while (pos < line.length && depth > 0) { + if (line[pos] === "(") depth++; + else if (line[pos] === ")") depth--; + pos++; + } + break; + } + pos++; + } + } + tokens.push(line.slice(start, pos)); + } + return tokens; + } + function isPackedFormat(s) { + const atIdx = s.indexOf("@"); + if (atIdx <= 0) return false; + if (atIdx + 1 >= s.length) return false; + const next = s[atIdx + 1]; + return next === "(" || next === "{"; + } + function parseScalarValue(s) { + s = s.trim(); + if (s === "\u2205" || s === "null" || s === "nil" || s === "none") { + return GValue.null(); + } + if (s === "t" || s === "true") return GValue.bool(true); + if (s === "f" || s === "false") return GValue.bool(false); + if (s.startsWith("^")) { + const ref = s.slice(1); + if (ref.startsWith('"')) { + const inner = ref.slice(1, -1); + const colonIdx2 = inner.indexOf(":"); + if (colonIdx2 > 0) { + return GValue.id(inner.slice(0, colonIdx2), inner.slice(colonIdx2 + 1)); + } + return GValue.id("", inner); + } + const colonIdx = ref.indexOf(":"); + if (colonIdx > 0) { + const first = ref.slice(0, colonIdx); + const second = ref.slice(colonIdx + 1); + return GValue.id(first, second); + } + return GValue.id("", ref); + } + if (s.startsWith('b64"') && s.endsWith('"')) { + return GValue.bytes(base64ToBytes2(s.slice(4, -1))); + } + if (s.startsWith('"')) { + return parseQuotedScalar(s); + } + if (/^\d{4}-\d{2}-\d{2}T/.test(s)) { + return GValue.time(new Date(s)); + } + if (/^-?\d/.test(s)) { + if (s.includes(".") || s.includes("e") || s.includes("E")) { + return GValue.float(parseFloat(s)); + } + const intVal = parseInt(s, 10); + if (!Number.isSafeInteger(intVal)) { + throw new Error(`integer exceeds safe range: ${s}`); + } + return GValue.int(intVal); + } + if (s.startsWith("[")) { + return parseListScalar(s); + } + if (s.startsWith("{")) { + return parseMapScalar(s); + } + return GValue.str(s); + } + function parseQuotedScalar(s) { + let result = ""; + for (let i = 1; i < s.length - 1; i++) { + if (s[i] === "\\" && i + 1 < s.length - 1) { + i++; + switch (s[i]) { + case "n": + result += "\n"; + break; + case "r": + result += "\r"; + break; + case "t": + result += " "; + break; + case "\\": + result += "\\"; + break; + case '"': + result += '"'; + break; + case "u": { + const hex = s.slice(i + 1, i + 5); + if (hex.length < 4 || !/^[0-9a-fA-F]{4}$/.test(hex)) { + throw new Error("invalid \\u escape"); + } + result += String.fromCharCode(parseInt(hex, 16)); + i += 4; + break; + } + default: + result += s[i]; + } + } else { + result += s[i]; + } + } + return GValue.str(result); + } + function base64ToBytes2(b64) { + if (typeof atob === "function") { + const binary = atob(b64); + const out = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i); + return out; + } + return new Uint8Array(Buffer.from(b64, "base64")); + } + function parseListScalar(s) { + const inner = s.slice(1, -1).trim(); + if (!inner) return GValue.list(); + const tokens = tokenizeRow(inner); + return GValue.list(...tokens.map((t2) => parseScalarValue(t2))); + } + function parseMapScalar(s) { + const inner = s.slice(1, -1).trim(); + if (!inner) return GValue.map(); + const entries = []; + const tokens = tokenizeRow(inner); + for (const token of tokens) { + const eqIdx = token.indexOf("="); + const colonIdx = token.indexOf(":"); + const sepIdx = eqIdx > 0 ? eqIdx : colonIdx; + if (sepIdx > 0) { + const key = token.slice(0, sepIdx).trim(); + const valStr = token.slice(sepIdx + 1).trim(); + const value = parseScalarValue(valStr); + const existing = entries.findIndex((e) => e.key === key); + if (existing >= 0) { + entries[existing].value = value; + } else { + entries.push({ key, value }); + } + } + } + return GValue.map(...entries); + } + + // src/loose.ts + var MAX_JSON_DEPTH = 128; + var MAX_COLLECTION_LEN2 = 1e6; + var MAX_STRING_LEN2 = 10 * 1024 * 1024; + var hasOwnProperty2 = Object.prototype.hasOwnProperty; + function hasOwn2(obj, key) { + return hasOwnProperty2.call(obj, key); + } + function createJsonObject2() { + return /* @__PURE__ */ Object.create(null); + } + function defaultLooseCanonOpts() { + return { + autoTabular: true, + minRows: 3, + maxCols: 20, + allowMissing: true, + nullStyle: "underscore" + }; + } + function llmLooseCanonOpts() { + return { + autoTabular: true, + minRows: 3, + maxCols: 20, + allowMissing: true, + nullStyle: "underscore" + }; + } + function noTabularLooseCanonOpts() { + return { + autoTabular: false, + minRows: 3, + maxCols: 20, + allowMissing: true, + nullStyle: "symbol" + }; + } + var NULL_SYMBOL2 = "\u2205"; + var NULL_UNDERSCORE = "_"; + function canonNullWithStyle(style) { + if (style === "underscore") { + return NULL_UNDERSCORE; + } + return NULL_SYMBOL2; + } + function canonBool2(v) { + return v ? "t" : "f"; + } + function canonInt2(n) { + if (n === 0) return "0"; + return String(Math.floor(n)); + } + function canonFloat2(f) { + if (Number.isNaN(f)) throw new Error("NaN not allowed in GLYPH-Loose"); + if (f === Infinity) throw new Error("Infinity not allowed in GLYPH-Loose"); + if (f === -Infinity) throw new Error("-Infinity not allowed in GLYPH-Loose"); + if (Object.is(f, -0)) return "0.0"; + if (f === 0) return "0.0"; + return goFormatFloat(f); + } + function goFormatFloat(f) { + const absF = Math.abs(f); + const neg = f < 0; + const jsStr = String(absF); + let s; + if (jsStr.includes("e") || jsStr.includes("E")) { + s = normalizeExpStr2(jsStr); + } else { + const E = Math.floor(Math.log10(absF)); + if (E >= 6 || E <= -5) { + s = decimalToGoExp2(absF); + } else { + s = jsStr; + if (!s.includes(".") && !s.includes("e")) { + s = s + ".0"; + } + } + } + return neg ? "-" + s : s; + } + function normalizeExpStr2(jsExp) { + return jsExp.replace(/[eE]([+-]?)(\d+)$/, (_match, sign, digits) => { + const signChar = sign === "-" ? "-" : "+"; + const paddedDigits = digits.length === 1 ? "0" + digits : digits; + return "e" + signChar + paddedDigits; + }); + } + function decimalToGoExp2(absF) { + let expStr = absF.toExponential(); + expStr = expStr.replace(/\.?0+(e)/, "$1"); + return normalizeExpStr2(expStr); + } + function canonString2(s) { + if (isBareSafe2(s)) { + return s; + } + return quoteString2(s); + } + function canonRef2(prefix, value) { + const full = prefix ? `${prefix}:${value}` : value; + if (isRefSafe2(full)) { + return `^${full}`; + } + return `^${quoteString2(full)}`; + } + function canonTime2(d) { + const ms = d.getUTCMilliseconds(); + if (ms === 0) { + return d.toISOString().replace(/\.\d{3}Z$/, "Z"); + } + const msStr = ms.toString().padStart(3, "0").replace(/0+$/, ""); + return d.toISOString().replace(/\.\d{3}Z$/, "." + msStr + "Z"); + } + function canonBytes(bytes) { + if (bytes.length === 0) { + return 'b64""'; + } + return "b64" + quoteString2(bytesToBase643(bytes)); + } + function isBareSafe2(s) { + if (s.length === 0) return false; + if (["t", "f", "_", "true", "false", "null", "none", "nil", "struct", "sum", "list", "map", "NaN", "Inf"].includes(s)) { + return false; + } + const first = s.charCodeAt(0); + if (!(first >= 65 && first <= 90 || first >= 97 && first <= 122 || first === 95)) return false; + for (let i = 1; i < s.length; i++) { + const c = s.charCodeAt(i); + if (!(c >= 65 && c <= 90 || c >= 97 && c <= 122 || c >= 48 && c <= 57 || c === 95)) { + return false; + } + } + return true; + } + function isRefPartChar2(c) { + return c >= 65 && c <= 90 || c >= 97 && c <= 122 || c >= 48 && c <= 57 || c === 95 || c === 45 || c === 46; + } + function isRefSafe2(s) { + if (s.length === 0) return false; + const colonIdx = s.indexOf(":"); + if (colonIdx < 0) { + for (let i = 0; i < s.length; i++) { + if (!isRefPartChar2(s.charCodeAt(i))) return false; + } + return true; + } + const prefix = s.slice(0, colonIdx); + const value = s.slice(colonIdx + 1); + for (let i = 0; i < prefix.length; i++) { + if (!isRefPartChar2(prefix.charCodeAt(i))) return false; + } + for (let i = 0; i < value.length; i++) { + const c = value.charCodeAt(i); + if (c === 58 || !isRefPartChar2(c)) return false; + } + return true; + } + function quoteString2(s) { + let result = '"'; + for (const ch of s) { + switch (ch) { + case "\\": + result += "\\\\"; + break; + case '"': + result += '\\"'; + break; + case "\n": + result += "\\n"; + break; + case "\r": + result += "\\r"; + break; + case " ": + result += "\\t"; + break; + default: + const code = ch.charCodeAt(0); + if (code < 32) { + result += "\\u" + code.toString(16).padStart(4, "0").toUpperCase(); + } else { + result += ch; + } + } + } + return result + '"'; + } + function bytesToBase643(bytes) { + if (typeof btoa === "function") { + let binary = ""; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); + } + return Buffer.from(bytes).toString("base64"); + } + function canonicalizeLoose(v) { + return canonicalizeLooseImpl(v, defaultLooseCanonOpts()); + } + function canonicalizeLooseNoTabular(v) { + return canonicalizeLooseWithOpts(v, noTabularLooseCanonOpts()); + } + function canonicalizeLooseWithOpts(v, opts) { + return canonicalizeLooseImpl(v, { ...defaultLooseCanonOpts(), ...opts }); + } + function canonicalizeLooseImpl(v, opts) { + switch (v.type) { + case "null": + return canonNullWithStyle(opts.nullStyle); + case "bool": + return canonBool2(v.asBool()); + case "int": + return canonInt2(v.asInt()); + case "float": + return canonFloat2(v.asFloat()); + case "str": + return canonString2(v.asStr()); + case "bytes": + return canonBytes(v.asBytes()); + case "time": + return canonTime2(v.asTime()); + case "id": { + const ref = v.asId(); + return canonRef2(ref.prefix, ref.value); + } + case "list": + return canonListLooseWithOpts(v.asList(), opts); + case "map": + return canonMapLooseWithOpts(v.asMap(), opts); + case "struct": + return canonMapLooseWithOpts(v.asStruct().fields, opts); + case "sum": { + const sum = v.asSum(); + const entry = { key: sum.tag, value: sum.value ?? GValue.null() }; + return canonMapLooseWithOpts([entry], opts); + } + } + } + function canonListLooseWithOpts(items, opts) { + if (items.length === 0) { + return "[]"; + } + if (opts.autoTabular) { + const cols = detectTabular(items, opts); + if (cols !== null) { + return emitTabularLoose(items, cols, opts); + } + } + const parts = items.map((v) => canonicalizeLooseImpl(v, opts)); + return "[" + parts.join(" ") + "]"; + } + function detectTabular(items, opts) { + const minRows = opts.minRows ?? 3; + const maxCols = opts.maxCols ?? 20; + const allowMissing = opts.allowMissing ?? true; + if (items.length < minRows) { + return null; + } + const allKeys = /* @__PURE__ */ new Set(); + const rowKeys = []; + for (const item of items) { + const entries = getMapEntries(item); + if (entries === null) { + return null; + } + const keys = /* @__PURE__ */ new Set(); + for (const e of entries) { + allKeys.add(e.key); + keys.add(e.key); + } + rowKeys.push(keys); + } + if (allKeys.size === 0 || allKeys.size > maxCols) { + return null; + } + if (!allowMissing) { + for (const keys of rowKeys) { + if (keys.size !== allKeys.size) { + return null; + } + for (const k of allKeys) { + if (!keys.has(k)) { + return null; + } + } + } + } else { + let commonKeys = new Set(rowKeys[0]); + for (let i = 1; i < rowKeys.length; i++) { + const itemKeys = rowKeys[i]; + for (const k of commonKeys) { + if (!itemKeys.has(k)) { + commonKeys.delete(k); + } + } + } + if (commonKeys.size < allKeys.size / 2) { + return null; + } + } + const cols = [...allKeys].sort((a, b) => { + const ca = canonString2(a); + const cb = canonString2(b); + return ca < cb ? -1 : ca > cb ? 1 : 0; + }); + return cols; + } + function getMapEntries(v) { + if (v.type === "map") { + return v.asMap(); + } + if (v.type === "struct") { + return v.asStruct().fields; + } + return null; + } + function emitTabularLoose(items, cols, opts) { + const lines = []; + const headerCols = cols.map((c) => { + if (opts.useCompactKeys && opts.keyDict) { + const idx = opts.keyDict.indexOf(c); + if (idx >= 0) { + return `#${idx}`; + } + } + return canonString2(c); + }).join(" "); + lines.push(`@tab _ rows=${items.length} cols=${cols.length} [${headerCols}]`); + for (const item of items) { + const entries = getMapEntries(item); + const rowMap = /* @__PURE__ */ new Map(); + for (const e of entries) { + rowMap.set(e.key, e.value); + } + const cells = []; + for (const col of cols) { + const val = rowMap.get(col); + if (val === void 0) { + cells.push(canonNullWithStyle(opts.nullStyle)); + } else { + cells.push(escapeTabularCell(canonicalizeLooseImpl(val, opts))); + } + } + lines.push("|" + cells.join("|") + "|"); + } + lines.push("@end"); + return lines.join("\n"); + } + function escapeTabularCell(s) { + return s.replace(/\|/g, "\\|"); + } + function unescapeTabularCell(s) { + return s.replace(/\\\|/g, "|"); + } + function parseTabularLoose(input) { + const lines = input.split("\n").map((l) => l.trim()).filter((l) => l.length > 0); + if (lines.length < 2) { + throw new Error("tabular block requires at least header and @end"); + } + const header = lines[0]; + if (!header.startsWith("@tab _")) { + throw new Error("expected @tab _ header"); + } + const cols = parseTabularLooseHeader(header); + if (cols.length === 0) { + throw new Error("no columns found in header"); + } + const rows = []; + for (let i = 1; i < lines.length; i++) { + const line = lines[i]; + if (line === "@end") { + break; + } + const row = parseTabularLooseRow(line, cols); + rows.push(row); + } + return { columns: cols, rows }; + } + function parseTabularLooseHeader(line) { + return parseTabularLooseHeaderWithMeta(line).keys; + } + function parseTabularLooseHeaderWithMeta(line) { + let rest = line.slice(line.indexOf("_") + 1).trim(); + const meta = { rows: -1, cols: -1, keys: [] }; + while (!rest.startsWith("[") && rest.length > 0) { + if (rest.startsWith("rows=")) { + rest = rest.slice(5); + const end2 = rest.search(/[\s\[]/); + if (end2 === -1) { + throw new Error("invalid rows= value"); + } + const rowsVal = parseInt(rest.slice(0, end2), 10); + if (!Number.isFinite(rowsVal) || rowsVal > Number.MAX_SAFE_INTEGER) { + throw new Error("rows= value overflows safe integer range"); + } + meta.rows = rowsVal; + rest = rest.slice(end2).trim(); + } else if (rest.startsWith("cols=")) { + rest = rest.slice(5); + const end2 = rest.search(/[\s\[]/); + if (end2 === -1) { + throw new Error("invalid cols= value"); + } + const colsVal = parseInt(rest.slice(0, end2), 10); + if (!Number.isFinite(colsVal) || colsVal > Number.MAX_SAFE_INTEGER) { + throw new Error("cols= value overflows safe integer range"); + } + meta.cols = colsVal; + rest = rest.slice(end2).trim(); + } else { + const spaceIdx = rest.indexOf(" "); + const bracketIdx = rest.indexOf("["); + if (spaceIdx === -1 && bracketIdx === -1) { + throw new Error(`expected '[' in header, got: ${rest}`); + } + if (spaceIdx >= 0 && (bracketIdx === -1 || spaceIdx < bracketIdx)) { + rest = rest.slice(spaceIdx).trim(); + } else { + break; + } + } + } + const start = rest.indexOf("["); + const end = rest.lastIndexOf("]"); + if (start === -1 || end === -1 || end <= start) { + throw new Error("malformed header: missing brackets"); + } + const content = rest.slice(start + 1, end).trim(); + if (content.length === 0) { + meta.keys = []; + } else { + meta.keys = parseSpaceSeparatedValues(content); + } + return meta; + } + function parseTabularLooseRow(line, cols) { + if (!line.startsWith("|") || !line.endsWith("|")) { + throw new Error("row must start and end with |"); + } + const cells = splitTabularCells(line.slice(1, -1)); + const row = {}; + for (let i = 0; i < cols.length && i < cells.length; i++) { + const cell = unescapeTabularCell(cells[i]); + row[cols[i]] = parseLooseValue(cell); + } + return row; + } + function splitTabularCells(s) { + const cells = []; + let current = ""; + let i = 0; + while (i < s.length) { + if (s[i] === "\\" && i + 1 < s.length && s[i + 1] === "|") { + current += "\\|"; + i += 2; + } else if (s[i] === "|") { + cells.push(current); + current = ""; + i++; + } else { + current += s[i]; + i++; + } + } + cells.push(current); + return cells; + } + function parseSpaceSeparatedValues(s) { + const values = []; + let i = 0; + while (i < s.length) { + while (i < s.length && /\s/.test(s[i])) i++; + if (i >= s.length) break; + if (s[i] === '"') { + const end = findClosingQuote(s, i); + values.push(unquoteString(s.slice(i, end + 1))); + i = end + 1; + } else { + let end = i; + while (end < s.length && !/\s/.test(s[end])) end++; + values.push(s.slice(i, end)); + i = end; + } + } + return values; + } + function findClosingQuote(s, start) { + let i = start + 1; + while (i < s.length) { + if (s[i] === "\\" && i + 1 < s.length) { + i += 2; + } else if (s[i] === '"') { + return i; + } else { + i++; + } + } + throw new Error("unclosed quote"); + } + function parseLooseValue(s) { + s = s.trim(); + if (s === "\u2205" || s === "_" || s === "null") return null; + if (s === "t") return true; + if (s === "f") return false; + if (s === "NaN") return NaN; + if (s === "Inf") return Infinity; + if (s === "-Inf") return -Infinity; + if (s.startsWith('"') && s.endsWith('"')) { + return unquoteString(s); + } + const num = tryParseNumber(s); + if (num !== null) return num; + if (s.startsWith("{") && s.endsWith("}")) { + return parseLooseMap(s); + } + if (s.startsWith("[") && s.endsWith("]")) { + return parseLooseList(s); + } + if (s.startsWith("^")) { + return s; + } + return s; + } + function tryParseNumber(s) { + if (!/^-?\d/.test(s) && s !== "-0") return null; + const n = Number(s); + if (Number.isNaN(n)) return null; + return n; + } + function unquoteString(s) { + if (!s.startsWith('"') || !s.endsWith('"')) { + return s; + } + let result = ""; + let i = 1; + while (i < s.length - 1) { + if (s[i] === "\\" && i + 1 < s.length - 1) { + const next = s[i + 1]; + switch (next) { + case "n": + result += "\n"; + break; + case "r": + result += "\r"; + break; + case "t": + result += " "; + break; + case '"': + result += '"'; + break; + case "\\": + result += "\\"; + break; + case "u": + if (i + 5 < s.length) { + const hex = s.slice(i + 2, i + 6); + result += String.fromCharCode(parseInt(hex, 16)); + i += 4; + } + break; + default: + result += next; + } + i += 2; + } else { + result += s[i]; + i++; + } + } + return result; + } + function parseLooseMap(s) { + const inner = s.slice(1, -1).trim(); + if (inner.length === 0) return {}; + const result = {}; + let i = 0; + let entryCount = 0; + while (i < inner.length) { + while (i < inner.length && /\s/.test(inner[i])) i++; + if (i >= inner.length) break; + if (entryCount >= MAX_COLLECTION_LEN2) { + throw new Error(`map too large (>${MAX_COLLECTION_LEN2} entries)`); + } + let key; + if (inner[i] === '"') { + const end = findClosingQuote(inner, i); + key = unquoteString(inner.slice(i, end + 1)); + i = end + 1; + } else { + let end = i; + while (end < inner.length && inner[end] !== "=" && !/\s/.test(inner[end])) end++; + key = inner.slice(i, end); + i = end; + } + while (i < inner.length && /\s/.test(inner[i])) i++; + if (i >= inner.length || inner[i] !== "=") { + throw new Error("expected = after key"); + } + i++; + while (i < inner.length && /\s/.test(inner[i])) i++; + const valueEnd = findValueEnd(inner, i); + const valueStr = inner.slice(i, valueEnd); + result[key] = parseLooseValue(valueStr); + i = valueEnd; + entryCount++; + } + return result; + } + function parseLooseList(s) { + const inner = s.slice(1, -1).trim(); + if (inner.length === 0) return []; + const result = []; + let i = 0; + while (i < inner.length) { + while (i < inner.length && /\s/.test(inner[i])) i++; + if (i >= inner.length) break; + if (result.length >= MAX_COLLECTION_LEN2) { + throw new Error(`list too large (>${MAX_COLLECTION_LEN2} elements)`); + } + const valueEnd = findValueEnd(inner, i); + const valueStr = inner.slice(i, valueEnd); + result.push(parseLooseValue(valueStr)); + i = valueEnd; + } + return result; + } + function findValueEnd(s, start) { + let i = start; + let depth = 0; + let inQuote = false; + while (i < s.length) { + if (inQuote) { + if (s[i] === "\\" && i + 1 < s.length) { + i += 2; + } else if (s[i] === '"') { + inQuote = false; + i++; + } else { + i++; + } + } else { + if (s[i] === '"') { + inQuote = true; + i++; + } else if (s[i] === "{" || s[i] === "[") { + depth++; + i++; + } else if (s[i] === "}" || s[i] === "]") { + depth--; + i++; + } else if (/\s/.test(s[i]) && depth === 0) { + break; + } else { + i++; + } + } + } + return i; + } + function canonMapLooseWithOpts(entries, opts) { + if (entries.length === 0) { + return "{}"; + } + const sorted = [...entries].sort((a, b) => { + const ka = canonString2(a.key); + const kb = canonString2(b.key); + return ka < kb ? -1 : ka > kb ? 1 : 0; + }); + const parts = sorted.map((e) => { + let keyStr; + if (opts.useCompactKeys && opts.keyDict) { + const idx = opts.keyDict.indexOf(e.key); + if (idx >= 0) { + keyStr = `#${idx}`; + } else { + keyStr = canonString2(e.key); + } + } else { + keyStr = canonString2(e.key); + } + return `${keyStr}=${canonicalizeLooseImpl(e.value, opts)}`; + }); + return "{" + parts.join(" ") + "}"; + } + function fingerprintLoose(v) { + const canonical = canonicalizeLooseNoTabular(v); + const { createHash: createHash2 } = __require("crypto"); + return createHash2("sha256").update(canonical, "utf8").digest("hex"); + } + function equalLoose(a, b) { + return canonicalizeLooseNoTabular(a) === canonicalizeLooseNoTabular(b); + } + function canonicalizeLooseWithSchema(v, opts) { + const fullOpts = { ...defaultLooseCanonOpts(), ...opts }; + const parts = []; + if (fullOpts.schemaRef || fullOpts.keyDict && fullOpts.keyDict.length > 0) { + parts.push(emitSchemaHeader(fullOpts)); + } + parts.push(canonicalizeLooseImpl(v, fullOpts)); + return parts.join("\n"); + } + function emitSchemaHeader(opts) { + const parts = ["@schema"]; + if (opts.schemaRef) { + parts[0] += `#${opts.schemaRef}`; + } + if (opts.keyDict && opts.keyDict.length > 0) { + const keys = opts.keyDict.map((k) => canonString2(k)).join(" "); + parts.push(`keys=[${keys}]`); + } + return parts.join(" "); + } + function buildKeyDictFromValue(v) { + const keySet = /* @__PURE__ */ new Set(); + collectKeys(v, keySet); + return [...keySet].sort(); + } + function collectKeys(v, keySet) { + if (v.type === "map") { + for (const e of v.asMap()) { + keySet.add(e.key); + collectKeys(e.value, keySet); + } + } else if (v.type === "struct") { + for (const f of v.asStruct().fields) { + keySet.add(f.key); + collectKeys(f.value, keySet); + } + } else if (v.type === "list") { + for (const item of v.asList()) { + collectKeys(item, keySet); + } + } + } + function parseSchemaHeader(line) { + line = line.trim(); + if (!line.startsWith("@schema")) { + throw new Error(`not a schema header: ${line}`); + } + let rest = line.slice("@schema".length); + let schemaRef = ""; + let keyDict = []; + if (rest.startsWith("#")) { + rest = rest.slice(1); + const end = rest.indexOf(" "); + if (end === -1) { + schemaRef = rest; + return { schemaRef, keyDict }; + } + schemaRef = rest.slice(0, end); + rest = rest.slice(end).trim(); + } + if (rest.startsWith("keys=")) { + rest = rest.slice("keys=".length); + if (!rest.startsWith("[")) { + throw new Error(`keys= must be followed by []: ${rest}`); + } + const closeIdx = rest.indexOf("]"); + if (closeIdx === -1) { + throw new Error(`missing ] in keys: ${rest}`); + } + const keysStr = rest.slice(1, closeIdx).trim(); + if (keysStr) { + keyDict = keysStr.split(/\s+/); + } + } + return { schemaRef, keyDict }; + } + function fromJsonLoose(json, opts = {}, _depth = 0) { + if (_depth > MAX_JSON_DEPTH) { + throw new Error(`maximum nesting depth exceeded (${MAX_JSON_DEPTH})`); + } + if (json === null || json === void 0) { + return GValue.null(); + } + if (typeof json === "boolean") { + return GValue.bool(json); + } + if (typeof json === "number") { + if (!Number.isFinite(json)) { + throw new Error("NaN/Infinity not allowed in GLYPH-Loose"); + } + if (Number.isInteger(json) && Math.abs(json) <= Number.MAX_SAFE_INTEGER) { + return GValue.int(json); + } + return GValue.float(json); + } + if (typeof json === "string") { + if (json.length > MAX_STRING_LEN2) { + throw new Error(`string too large (${json.length} > ${MAX_STRING_LEN2})`); + } + return GValue.str(json); + } + if (Array.isArray(json)) { + if (json.length > MAX_COLLECTION_LEN2) { + throw new Error(`list too large (${json.length} > ${MAX_COLLECTION_LEN2})`); + } + const items = json.map((item) => fromJsonLoose(item, opts, _depth + 1)); + return GValue.list(...items); + } + if (typeof json === "object") { + const obj = json; + const glyphMarker = hasOwn2(obj, "$glyph") ? obj.$glyph : void 0; + if (opts.extended && typeof glyphMarker === "string") { + return fromGlyphMarker(glyphMarker, obj); + } + const keys = Object.keys(obj); + if (keys.length > MAX_COLLECTION_LEN2) { + throw new Error(`map too large (${keys.length} > ${MAX_COLLECTION_LEN2})`); + } + const entries = []; + for (const [key, val] of Object.entries(obj)) { + entries.push({ key, value: fromJsonLoose(val, opts, _depth + 1) }); + } + return GValue.map(...entries); + } + throw new Error(`Unsupported JSON value type: ${typeof json}`); + } + function fromGlyphMarker(markerType, obj) { + switch (markerType) { + case "time": { + const value = obj.value; + if (typeof value !== "string") { + throw new Error("$glyph time marker missing value"); + } + return GValue.time(new Date(value)); + } + case "id": { + const rawValue = obj.value; + if (typeof rawValue !== "string") { + throw new Error("$glyph id marker missing value"); + } + let value = rawValue; + if (value.startsWith("^")) { + value = value.slice(1); + } + const colonIdx = value.indexOf(":"); + if (colonIdx > 0) { + return GValue.id(value.slice(0, colonIdx), value.slice(colonIdx + 1)); + } + return GValue.id("", value); + } + case "bytes": { + const b64 = obj.base64; + if (typeof b64 !== "string") { + throw new Error("$glyph bytes marker missing base64"); + } + return GValue.bytes(base64ToBytes3(b64)); + } + default: + throw new Error(`Unknown $glyph marker type: ${markerType}`); + } + } + function base64ToBytes3(b64) { + if (typeof atob === "function") { + const binary = atob(b64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; + } + return new Uint8Array(Buffer.from(b64, "base64")); + } + function toJsonLoose(gv, opts = {}) { + switch (gv.type) { + case "null": + return null; + case "bool": + return gv.asBool(); + case "int": + return gv.asInt(); + case "float": { + const f = gv.asFloat(); + if (!Number.isFinite(f)) { + throw new Error("NaN/Infinity not allowed in JSON"); + } + return f; + } + case "str": + return gv.asStr(); + case "bytes": { + const b64 = bytesToBase643(gv.asBytes()); + if (opts.extended) { + const result = createJsonObject2(); + result.$glyph = "bytes"; + result.base64 = b64; + return result; + } + return b64; + } + case "time": { + const d = gv.asTime(); + const iso = canonTime2(d); + if (opts.extended) { + const result = createJsonObject2(); + result.$glyph = "time"; + result.value = iso; + return result; + } + return iso; + } + case "id": { + const ref = gv.asId(); + const refStr = `^${ref.prefix ? ref.prefix + ":" : ""}${ref.value}`; + if (opts.extended) { + const result = createJsonObject2(); + result.$glyph = "id"; + result.value = refStr; + return result; + } + return refStr; + } + case "list": + return gv.asList().map((v) => toJsonLoose(v, opts)); + case "map": { + const result = createJsonObject2(); + for (const entry of gv.asMap()) { + result[entry.key] = toJsonLoose(entry.value, opts); + } + return result; + } + case "struct": { + const sv = gv.asStruct(); + const result = createJsonObject2(); + for (const field2 of sv.fields) { + result[field2.key] = toJsonLoose(field2.value, opts); + } + return result; + } + case "sum": { + const sum = gv.asSum(); + const result = createJsonObject2(); + result[sum.tag] = sum.value ? toJsonLoose(sum.value, opts) : null; + return result; + } + } + } + function parseJsonLoose(jsonStr, opts = {}) { + const json = JSON.parse(jsonStr); + return fromJsonLoose(json, opts); + } + function stringifyJsonLoose(gv, opts = {}, indent) { + const json = toJsonLoose(gv, opts); + return JSON.stringify(json, null, indent); + } + function jsonEqual(a, b) { + const va = JSON.parse(a); + const vb = JSON.parse(b); + return jsonValueEqual(va, vb); + } + function jsonValueEqual(a, b) { + if (a === b) return true; + if (a === null || b === null) return a === b; + if (typeof a !== typeof b) return false; + if (Array.isArray(a)) { + if (!Array.isArray(b) || a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (!jsonValueEqual(a[i], b[i])) return false; + } + return true; + } + if (typeof a === "object") { + const objA = a; + const objB = b; + const keysA = Object.keys(objA); + const keysB = Object.keys(objB); + if (keysA.length !== keysB.length) return false; + for (const key of keysA) { + if (!hasOwn2(objB, key)) return false; + if (!jsonValueEqual(objA[key], objB[key])) return false; + } + return true; + } + return false; + } + + // src/stream/hash.ts + async function sha256(data) { + if (typeof crypto !== "undefined" && crypto.subtle) { + const hash2 = await crypto.subtle.digest("SHA-256", data); + return new Uint8Array(hash2); + } + const { createHash: createHash2 } = await import("crypto"); + const hash = createHash2("sha256").update(data).digest(); + return new Uint8Array(hash); + } + function sha256Sync(data) { + const { createHash: createHash2 } = __require("crypto"); + const hash = createHash2("sha256").update(data).digest(); + return new Uint8Array(hash); + } + async function stateHashLoose(value) { + const canonical = canonicalizeLoose(value); + const encoder3 = new TextEncoder(); + return sha256(encoder3.encode(canonical)); + } + function stateHashLooseSync(value) { + const canonical = canonicalizeLoose(value); + const encoder3 = new TextEncoder(); + return sha256Sync(encoder3.encode(canonical)); + } + async function stateHashBytes(data) { + return sha256(data); + } + function verifyBase(current, expected) { + if (current.length !== expected.length) return false; + for (let i = 0; i < current.length; i++) { + if (current[i] !== expected[i]) return false; + } + return true; + } + function hashToHex(h) { + const hex = "0123456789abcdef"; + let result = ""; + for (let i = 0; i < h.length; i++) { + result += hex[h[i] >> 4]; + result += hex[h[i] & 15]; + } + return result; + } + function hexToHash(s) { + if (s.startsWith("sha256:")) { + s = s.slice(7); + } + if (s.length !== 64) { + return null; + } + const hash = new Uint8Array(32); + for (let i = 0; i < 32; i++) { + const hi = hexDigit(s.charCodeAt(i * 2)); + const lo = hexDigit(s.charCodeAt(i * 2 + 1)); + if (hi < 0 || lo < 0) { + return null; + } + hash[i] = hi << 4 | lo; + } + return hash; + } + function hexDigit(c) { + if (c >= 48 && c <= 57) return c - 48; + if (c >= 97 && c <= 102) return c - 97 + 10; + if (c >= 65 && c <= 70) return c - 65 + 10; + return -1; + } + + // src/patch.ts + function fieldSeg(name, fid) { + return { kind: "field", field: name, fid }; + } + function listIdxSeg(idx) { + return { kind: "listIdx", listIdx: parseNonNegativeSafeInt(String(idx), "list index") }; + } + function mapKeySeg(key) { + return { kind: "mapKey", mapKey: key }; + } + function parseNonNegativeSafeInt(raw, field2) { + if (!/^\d+$/.test(raw)) { + throw new Error(`invalid ${field2}: ${raw}`); + } + const value = Number(raw); + if (!Number.isSafeInteger(value)) { + throw new Error(`${field2} out of range: ${raw}`); + } + return value; + } + function parseFiniteNumber(raw, field2) { + if (!/^[+-]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?$/.test(raw)) { + throw new Error(`invalid ${field2}: ${raw}`); + } + const value = Number(raw); + if (!Number.isFinite(value)) { + throw new Error(`invalid ${field2}: ${raw}`); + } + return value; + } + function parseQuotedPathString(path, start) { + if (path[start] !== '"') { + throw new Error(`expected quoted string at pos ${start}`); + } + let value = ""; + let escaped = false; + let i = start + 1; + while (i < path.length) { + const c = path[i]; + if (escaped) { + value += c; + escaped = false; + i++; + continue; + } + if (c === "\\") { + escaped = true; + i++; + continue; + } + if (c === '"') { + return { value, next: i + 1 }; + } + value += c; + i++; + } + throw new Error(`unterminated quoted path segment at pos ${start}`); + } + function parsePathToSegs(path) { + path = path.trim(); + if (!path) return []; + const segs = []; + let i = path.startsWith(".") ? 1 : 0; + if (i >= path.length) { + throw new Error("path cannot end with dot"); + } + while (i < path.length) { + const c = path[i]; + if (c === "[") { + i++; + if (i >= path.length) { + throw new Error(`unterminated path segment at pos ${i - 1}`); + } + if (path[i] === '"') { + const parsed = parseQuotedPathString(path, i); + i = parsed.next; + if (path[i] !== "]") { + throw new Error(`unterminated map key segment at pos ${i}`); + } + segs.push(mapKeySeg(parsed.value)); + i++; + } else { + const end = path.indexOf("]", i); + if (end < 0) { + throw new Error(`unterminated list index at pos ${i - 1}`); + } + const inner = path.slice(i, end); + segs.push(listIdxSeg(parseNonNegativeSafeInt(inner, "list index"))); + i = end + 1; + } + } else if (c === "#") { + const start = i + 1; + let j = start; + while (j < path.length && path[j] >= "0" && path[j] <= "9") { + j++; + } + if (j === start) { + throw new Error(`missing field id at pos ${i}`); + } + segs.push({ kind: "field", fid: parseNonNegativeSafeInt(path.slice(start, j), "field id") }); + i = j; + } else { + let field2; + if (c === '"') { + const parsed = parseQuotedPathString(path, i); + field2 = parsed.value; + i = parsed.next; + } else { + let j = i; + while (j < path.length && path[j] !== "." && path[j] !== "[" && path[j] !== "]") { + j++; + } + if (j === i) { + throw new Error(`empty path segment at pos ${i}`); + } + field2 = path.slice(i, j); + i = j; + } + if (!field2) { + throw new Error(`empty field name at pos ${i}`); + } + segs.push(fieldSeg(field2)); + } + if (i >= path.length) { + break; + } + if (path[i] === ".") { + i++; + if (i >= path.length) { + throw new Error("path cannot end with dot"); + } + continue; + } + if (path[i] === "[") { + continue; + } + throw new Error(`unexpected character '${path[i]}' in path`); + } + return segs; + } + var PatchBuilder = class { + constructor(target) { + this.patch = { + target, + ops: [] + }; + } + withSchema(schema) { + this.schema = schema; + this.patch.schemaId = schema.hash; + return this; + } + withSchemaId(id) { + this.patch.schemaId = id; + return this; + } + withTargetType(typeName) { + this.patch.targetType = typeName; + return this; + } + /** + * Set the base state fingerprint for validation. + * The fingerprint should be the first 16 chars of the SHA-256 hash + * of the canonical form of the base state. + */ + withBaseFingerprint(fingerprint) { + this.patch.baseFingerprint = fingerprint; + return this; + } + /** + * Compute and set the base fingerprint from a GValue. + * Uses the SHA-256 hash of the loose canonical form (first 16 hex chars). + */ + withBaseValue(base) { + const hash = stateHashLooseSync(base); + const hex = hashToHex(hash); + this.patch.baseFingerprint = hex.slice(0, 16); + return this; + } + set(path, value) { + this.patch.ops.push({ + op: "=", + path: parsePathToSegs(path), + value + }); + return this; + } + setWithSegs(path, value) { + this.patch.ops.push({ + op: "=", + path, + value + }); + return this; + } + append(path, value) { + this.patch.ops.push({ + op: "+", + path: parsePathToSegs(path), + value, + index: -1 + }); + return this; + } + delete(path) { + this.patch.ops.push({ + op: "-", + path: parsePathToSegs(path) + }); + return this; + } + delta(path, amount) { + this.patch.ops.push({ + op: "~", + path: parsePathToSegs(path), + value: GValue.float(amount) + }); + return this; + } + insertAt(path, index, value) { + this.patch.ops.push({ + op: "+", + path: parsePathToSegs(path), + value, + index: parseNonNegativeSafeInt(String(index), "patch index") + }); + return this; + } + build() { + return this.patch; + } + }; + function emitPatch(patch, options = {}) { + const keyMode = options.keyMode || "wire"; + const sortOps = options.sortOps !== false; + const lines = []; + let header = "@patch"; + if (patch.schemaId) { + header += ` @schema#${patch.schemaId}`; + } + header += ` @keys=${keyMode}`; + header += ` @target=${patch.target.prefix}:${patch.target.value}`; + if (patch.baseFingerprint) { + header += ` @base=${patch.baseFingerprint}`; + } + lines.push(header); + let ops = patch.ops; + if (sortOps) { + ops = [...ops].sort((a, b) => { + const pa = pathSegsToString(a.path, keyMode); + const pb = pathSegsToString(b.path, keyMode); + if (pa !== pb) return pa < pb ? -1 : 1; + return a.op < b.op ? -1 : a.op > b.op ? 1 : 0; + }); + } + const prefix = options.indentPrefix || ""; + for (const op of ops) { + let line = prefix + op.op + " "; + line += emitPathSegs(op.path, keyMode); + if (op.op === "=" || op.op === "+") { + if (op.value) { + line += " " + emitValue2(op.value, options.schema); + } + if (op.op === "+" && op.index !== void 0 && op.index >= 0) { + line += ` @idx=${op.index}`; + } + } else if (op.op === "~") { + if (op.value) { + const num = op.value.type === "float" ? op.value.asFloat() : op.value.asInt(); + line += " " + (num >= 0 ? "+" : "") + canonFloat(num); + } + } + lines.push(line); + } + lines.push("@end"); + return lines.join("\n"); + } + function pathSegsToString(path, keyMode) { + let result = ""; + for (let i = 0; i < path.length; i++) { + const seg = path[i]; + if (seg.kind === "field") { + if (i > 0) result += "."; + if (keyMode === "fid" && seg.fid) { + result += "#" + seg.fid; + } else { + result += seg.field || ""; + } + } else if (seg.kind === "listIdx") { + result += `[${seg.listIdx}]`; + } else if (seg.kind === "mapKey") { + result += `["${seg.mapKey}"]`; + } + } + return result; + } + function emitPathSegs(path, keyMode) { + return pathSegsToString(path, keyMode); + } + function emitValue2(gv, schema) { + switch (gv.type) { + case "null": + return "\u2205"; + case "bool": + return gv.asBool() ? "t" : "f"; + case "int": + return canonInt(gv.asInt()); + case "float": + return canonFloat(gv.asFloat()); + case "str": + return canonString(gv.asStr()); + case "id": + return canonRef3(gv.asId()); + case "time": + return gv.asTime().toISOString().replace(".000Z", "Z"); + case "list": { + const items = gv.asList().map((v) => emitValue2(v, schema)); + return "[" + items.join(" ") + "]"; + } + case "map": { + const parts = []; + for (const e of gv.asMap()) { + parts.push(`${canonString(e.key)}:${emitValue2(e.value, schema)}`); + } + return "{" + parts.join(" ") + "}"; + } + case "struct": { + const sv = gv.asStruct(); + const parts = []; + for (const f of sv.fields) { + parts.push(`${canonString(f.key)}=${emitValue2(f.value, schema)}`); + } + return `${sv.typeName}{${parts.join(" ")}}`; + } + case "sum": { + const sum = gv.asSum(); + if (!sum.value) return `${sum.tag}()`; + return `${sum.tag}(${emitValue2(sum.value, schema)})`; + } + default: + return "\u2205"; + } + } + function parsePatch(input, schema) { + const lines = input.split("\n"); + if (lines.length === 0) { + throw new Error("empty patch input"); + } + const headerLine = lines[0].trim(); + const header = parsePatchHeader(headerLine); + const patch = { + target: header.target, + schemaId: header.schemaId, + baseFingerprint: header.baseFingerprint, + ops: [] + }; + for (let i = 1; i < lines.length; i++) { + const line = lines[i].trim(); + if (!line || line.startsWith("#")) continue; + if (line === "@end") break; + const op = parsePatchOp(line, schema); + patch.ops.push(op); + } + return patch; + } + function parsePatchHeader(line) { + if (!line.startsWith("@patch")) { + throw new Error("patch must start with @patch"); + } + const result = { + target: { prefix: "", value: "" }, + keyMode: "wire" + }; + const tokens = tokenizeHeader2(line); + for (const tok of tokens) { + if (tok.startsWith("@schema#")) { + result.schemaId = tok.slice(8); + } else if (tok.startsWith("@keys=")) { + result.keyMode = tok.slice(6); + } else if (tok.startsWith("@target=")) { + const ref = tok.slice(8); + const colonIdx = ref.indexOf(":"); + if (colonIdx > 0) { + result.target = { prefix: ref.slice(0, colonIdx), value: ref.slice(colonIdx + 1) }; + } else { + result.target = { prefix: "", value: ref }; + } + } else if (tok.startsWith("@base=")) { + result.baseFingerprint = tok.slice(6); + } + } + return result; + } + function tokenizeHeader2(input) { + const tokens = []; + let current = ""; + let inQuote = false; + for (const c of input) { + if (c === '"') { + inQuote = !inQuote; + current += c; + } else if (c === " " && !inQuote) { + if (current) { + tokens.push(current); + current = ""; + } + } else { + current += c; + } + } + if (current) tokens.push(current); + return tokens; + } + function parsePatchOp(line, schema) { + if (!line) { + throw new Error("empty operation line"); + } + const opChar = line[0]; + if (!["=", "+", "-", "~"].includes(opChar)) { + throw new Error(`unknown operation: ${opChar}`); + } + const rest = line.slice(1).trim(); + if (!rest) { + throw new Error("missing path in operation"); + } + const pathEnd = findPathEnd(rest); + const pathStr = rest.slice(0, pathEnd); + let valueStr = rest.slice(pathEnd).trim(); + const path = parsePathToSegs(pathStr); + const op = { + op: opChar, + path, + index: -1 + }; + switch (opChar) { + case "=": + case "+": { + if (valueStr) { + const tokens = tokenizeValues(valueStr); + const lastToken = tokens[tokens.length - 1]; + if (opChar === "+" && tokens.length > 1 && lastToken?.startsWith("@idx=")) { + op.index = parseNonNegativeSafeInt(lastToken.slice(5), "patch index"); + tokens.pop(); + valueStr = tokens.join(" ").trim(); + } + op.value = parseInlineValue(valueStr, schema); + } + break; + } + case "~": { + if (!valueStr) { + throw new Error("delta operation requires a value"); + } + const num = parseFiniteNumber(valueStr, "delta"); + op.value = GValue.float(num); + break; + } + case "-": + break; + } + return op; + } + function findPathEnd(s) { + let inQuote = false; + let bracketDepth = 0; + for (let i = 0; i < s.length; i++) { + const c = s[i]; + if (c === '"') { + inQuote = !inQuote; + } else if (c === "[" && !inQuote) { + bracketDepth++; + } else if (c === "]" && !inQuote && bracketDepth > 0) { + bracketDepth--; + } else if ((c === " " || c === " ") && !inQuote && bracketDepth === 0) { + return i; + } + } + return s.length; + } + function parseInlineValue(s, schema) { + s = s.trim(); + if (!s) return GValue.null(); + if (s === "\u2205" || s === "null") return GValue.null(); + if (s === "t" || s === "true") return GValue.bool(true); + if (s === "f" || s === "false") return GValue.bool(false); + if (s.startsWith("^")) { + const ref = s.slice(1); + const colonIdx = ref.indexOf(":"); + if (colonIdx > 0) { + return GValue.id(ref.slice(0, colonIdx), ref.slice(colonIdx + 1)); + } + return GValue.id("", ref); + } + if (s.startsWith('"')) { + return parseQuotedString(s); + } + if (/^-?\d/.test(s)) { + const num = parseFiniteNumber(s, "number"); + if (s.includes(".") || s.includes("e") || s.includes("E")) { + return GValue.float(num); + } + return GValue.int(parseInt(s, 10)); + } + if (s.startsWith("[")) { + return parseList(s); + } + if (/^[A-Za-z_]\w*\{/.test(s)) { + return parseStruct(s); + } + return GValue.str(s); + } + function parseQuotedString(s) { + if (s.length < 2 || !s.endsWith('"')) { + throw new Error("unterminated string literal"); + } + let result = ""; + for (let i = 1; i < s.length - 1; i++) { + if (s[i] === "\\" && i + 1 < s.length - 1) { + i++; + switch (s[i]) { + case "n": + result += "\n"; + break; + case "r": + result += "\r"; + break; + case "t": + result += " "; + break; + case "\\": + result += "\\"; + break; + case '"': + result += '"'; + break; + default: + result += s[i]; + } + } else { + result += s[i]; + } + } + return GValue.str(result); + } + function parseList(s) { + const inner = s.slice(1, -1).trim(); + if (!inner) return GValue.list(); + const items = []; + const tokens = tokenizeValues(inner); + for (const tok of tokens) { + items.push(parseInlineValue(tok)); + } + return GValue.list(...items); + } + function parseStruct(s) { + const braceIdx = s.indexOf("{"); + const typeName = s.slice(0, braceIdx); + const inner = s.slice(braceIdx + 1, -1).trim(); + if (!inner) return GValue.struct(typeName); + const entries = []; + const tokens = tokenizeValues(inner); + for (const tok of tokens) { + const eqIdx = tok.indexOf("="); + if (eqIdx > 0) { + const key = tok.slice(0, eqIdx).trim(); + const valStr = tok.slice(eqIdx + 1).trim(); + entries.push({ key, value: parseInlineValue(valStr) }); + } + } + return GValue.struct(typeName, ...entries); + } + function tokenizeValues(s) { + const tokens = []; + let current = ""; + let inQuote = false; + let depth = 0; + for (const c of s) { + if (c === '"') { + inQuote = !inQuote; + current += c; + } else if (!inQuote) { + if (c === "[" || c === "{" || c === "(") { + depth++; + current += c; + } else if (c === "]" || c === "}" || c === ")") { + depth--; + current += c; + } else if (c === " " && depth === 0) { + if (current) { + tokens.push(current); + current = ""; + } + } else { + current += c; + } + } else { + current += c; + } + } + if (current) tokens.push(current); + return tokens; + } + function applyPatch(value, patch) { + let result = value.clone(); + for (const op of patch.ops) { + result = applyOp(result, op); + } + return result; + } + function applyOp(value, op) { + if (op.path.length === 0) { + if (op.op === "=") { + return op.value || GValue.null(); + } + throw new Error(`cannot apply ${op.op} to root`); + } + return applyAtPath(value, op.path, op); + } + function applyAtPath(value, path, op) { + if (path.length === 1) { + return applyToParent(value, path[0], op); + } + const seg = path[0]; + const rest = path.slice(1); + if (seg.kind === "field") { + const key = seg.field; + if (value.type !== "struct") { + throw new Error(`cannot navigate into ${value.type} with field`); + } + const sv = value.asStruct(); + for (let i = 0; i < sv.fields.length; i++) { + if (sv.fields[i].key === key) { + sv.fields[i].value = applyAtPath(sv.fields[i].value, rest, op); + return value; + } + } + throw new Error(`field not found: ${key}`); + } + if (seg.kind === "listIdx") { + if (value.type !== "list") { + throw new Error(`cannot index into ${value.type}`); + } + const list = value.asList(); + const idx = seg.listIdx; + if (idx < 0 || idx >= list.length) { + throw new Error(`index out of bounds: ${idx}`); + } + list[idx] = applyAtPath(list[idx], rest, op); + return value; + } + if (seg.kind === "mapKey") { + if (value.type !== "map") { + throw new Error(`cannot access map key in ${value.type}`); + } + const entries = value.asMap(); + const key = seg.mapKey; + for (let i = 0; i < entries.length; i++) { + if (entries[i].key === key) { + entries[i].value = applyAtPath(entries[i].value, rest, op); + return value; + } + } + throw new Error(`key not found: ${key}`); + } + throw new Error("unknown path segment kind"); + } + function applyToParent(value, seg, op) { + const key = seg.kind === "mapKey" ? seg.mapKey : seg.field; + switch (op.op) { + case "=": + value.set(key, op.value || GValue.null()); + return value; + case "+": { + const existing = value.get(key); + if (!existing || existing.isNull()) { + value.set(key, GValue.list(op.value || GValue.null())); + } else if (existing.type === "list") { + const list = existing.asList(); + if (op.index !== void 0 && op.index >= 0 && op.index <= list.length) { + list.splice(op.index, 0, op.value || GValue.null()); + } else { + list.push(op.value || GValue.null()); + } + } else { + throw new Error(`cannot append to ${existing.type}`); + } + return value; + } + case "-": { + if (value.type === "struct") { + const sv = value.asStruct(); + sv.fields = sv.fields.filter((f) => f.key !== key); + } else if (value.type === "map") { + const entries = value.asMap(); + const idx = entries.findIndex((e) => e.key === key); + if (idx >= 0) entries.splice(idx, 1); + } else { + throw new Error(`cannot delete from ${value.type}`); + } + return value; + } + case "~": { + const existing = value.get(key); + if (!existing) { + throw new Error(`field not found for delta: ${key}`); + } + const delta = op.value?.type === "float" ? op.value.asFloat() : op.value?.asInt() || 0; + if (existing.type === "int") { + value.set(key, GValue.int(existing.asInt() + delta)); + } else if (existing.type === "float") { + value.set(key, GValue.float(existing.asFloat() + delta)); + } else { + throw new Error(`cannot apply delta to ${existing.type}`); + } + return value; + } + } + throw new Error(`unknown operation: ${op.op}`); + } + function canonRef3(ref) { + const full = ref.prefix ? `${ref.prefix}:${ref.value}` : ref.value; + return `^${full}`; + } + + // src/parse_loose.ts + var DEFAULT_MAX_DEPTH = 128; + var MAX_COLLECTION_LEN3 = 1e6; + var MAX_STRING_LEN3 = 10 * 1024 * 1024; + function isAsciiDigit(c) { + return c >= "0" && c <= "9"; + } + function isLetter2(c) { + return /\p{L}/u.test(c); + } + function isAlnum(c) { + return /[\p{L}\p{N}]/u.test(c); + } + var IDENT_CONTINUE_EXTRA = "_-./@+"; + var Lexer = class { + constructor(text) { + this.text = text; + this.pos = 0; + this.length = text.length; + } + peekChar() { + if (this.pos >= this.length) return ""; + return this.text[this.pos]; + } + nextChar() { + if (this.pos >= this.length) return ""; + const c = this.text[this.pos]; + this.pos += 1; + return c; + } + skipWhitespace() { + while (this.pos < this.length && " \r".includes(this.text[this.pos])) { + this.pos += 1; + } + } + skipWhitespaceAndNewlines() { + while (this.pos < this.length && " \r\n".includes(this.text[this.pos])) { + this.pos += 1; + } + } + nextToken() { + this.skipWhitespace(); + if (this.pos >= this.length) { + return { type: "EOF" /* EOF */, value: null, pos: this.pos }; + } + const start = this.pos; + const c = this.peekChar(); + switch (c) { + case "{": + this.pos += 1; + return { type: "{" /* LBRACE */, value: c, pos: start }; + case "}": + this.pos += 1; + return { type: "}" /* RBRACE */, value: c, pos: start }; + case "[": + this.pos += 1; + return { type: "[" /* LBRACKET */, value: c, pos: start }; + case "]": + this.pos += 1; + return { type: "]" /* RBRACKET */, value: c, pos: start }; + case "(": + this.pos += 1; + return { type: "(" /* LPAREN */, value: c, pos: start }; + case ")": + this.pos += 1; + return { type: ")" /* RPAREN */, value: c, pos: start }; + case "=": + this.pos += 1; + return { type: "=" /* EQUALS */, value: c, pos: start }; + case ":": + this.pos += 1; + return { type: ":" /* COLON */, value: c, pos: start }; + case ",": + this.pos += 1; + return { type: "," /* COMMA */, value: c, pos: start }; + case "|": + this.pos += 1; + return { type: "|" /* PIPE */, value: c, pos: start }; + case "^": + this.pos += 1; + return { type: "^" /* CARET */, value: c, pos: start }; + case "@": + this.pos += 1; + return { type: "@" /* AT */, value: c, pos: start }; + case "\n": + this.pos += 1; + return { type: "NEWLINE" /* NEWLINE */, value: c, pos: start }; + } + if (c === "\u2205" || c === "_") { + this.pos += 1; + return { type: "NULL" /* NULL */, value: null, pos: start }; + } + if (c === '"') { + return this.readString(); + } + if (c === "b" && this.text.slice(this.pos, this.pos + 4) === 'b64"') { + return this.readBytes(); + } + if (c === "-" || isAsciiDigit(c)) { + return this.readNumberOrIdent(); + } + if (isLetter2(c) || c === "_") { + return this.readIdent(); + } + throw new Error(`unexpected character '${c}' at position ${this.pos}`); + } + readString() { + const start = this.pos; + this.pos += 1; + let result = ""; + while (this.pos < this.length) { + const c = this.text[this.pos]; + if (c === '"') { + this.pos += 1; + return { type: "STRING" /* STRING */, value: result, pos: start }; + } + if (result.length >= MAX_STRING_LEN3) { + throw new Error(`string too large (>${MAX_STRING_LEN3} characters)`); + } + if (c === "\\") { + this.pos += 1; + if (this.pos >= this.length) { + throw new Error("unterminated escape sequence"); + } + const esc = this.text[this.pos]; + switch (esc) { + case "n": + result += "\n"; + break; + case "r": + result += "\r"; + break; + case "t": + result += " "; + break; + case '"': + result += '"'; + break; + case "\\": + result += "\\"; + break; + case "u": { + if (this.pos + 5 > this.length) { + throw new Error("invalid unicode escape"); + } + const hex = this.text.slice(this.pos + 1, this.pos + 5); + if (!/^[0-9a-fA-F]{4}$/.test(hex)) { + throw new Error("invalid unicode escape"); + } + result += String.fromCharCode(parseInt(hex, 16)); + this.pos += 4; + break; + } + default: + result += esc; + } + } else { + result += c; + } + this.pos += 1; + } + throw new Error("unterminated string"); + } + readBytes() { + const start = this.pos; + this.pos += 4; + let b64 = ""; + while (this.pos < this.length) { + const c = this.text[this.pos]; + if (c === '"') { + this.pos += 1; + return { type: "BYTES" /* BYTES */, value: base64ToBytes4(b64), pos: start }; + } + b64 += c; + this.pos += 1; + } + throw new Error("unterminated bytes literal"); + } + parseFloatToken(literal, start) { + const value = Number(literal); + if (Number.isNaN(value)) { + throw new Error(`invalid float literal '${literal}' at position ${start}`); + } + if (!Number.isFinite(value)) { + throw new Error(`non-finite float literal '${literal}' at position ${start}`); + } + return { type: "FLOAT" /* FLOAT */, value, pos: start }; + } + readNumberOrIdent() { + const start = this.pos; + let result = ""; + if (this.peekChar() === "-") { + result += this.nextChar(); + if (this.text.slice(this.pos, this.pos + 3) === "Inf" && (this.pos + 3 >= this.length || !isAlnum(this.text[this.pos + 3]) && this.text[this.pos + 3] !== "_")) { + throw new Error(`non-finite float literal '-Inf' at position ${start}`); + } + } + let hasDot = false; + let hasExp = false; + while (this.pos < this.length) { + const c = this.peekChar(); + if (isAsciiDigit(c)) { + result += this.nextChar(); + } else if (c === "." && !hasDot && !hasExp) { + hasDot = true; + result += this.nextChar(); + } else if ((c === "e" || c === "E") && !hasExp) { + hasExp = true; + result += this.nextChar(); + if (this.peekChar() === "+" || this.peekChar() === "-") { + result += this.nextChar(); + } + } else if (isLetter2(c) || c === "_") { + while (this.pos < this.length && (isAlnum(this.peekChar()) || IDENT_CONTINUE_EXTRA.includes(this.peekChar()))) { + result += this.nextChar(); + } + return { type: "IDENT" /* IDENT */, value: result, pos: start }; + } else { + break; + } + } + if (hasDot || hasExp) { + return this.parseFloatToken(result, start); + } + const intVal = Number(result); + if (Number.isNaN(intVal)) { + return { type: "IDENT" /* IDENT */, value: result, pos: start }; + } + return { type: "INT" /* INT */, value: intVal, pos: start }; + } + readIdent() { + const start = this.pos; + let result = ""; + while (this.pos < this.length) { + const c = this.peekChar(); + if (isAlnum(c) || IDENT_CONTINUE_EXTRA.includes(c)) { + result += this.nextChar(); + } else { + break; + } + } + switch (result) { + case "t": + case "true": + return { type: "BOOL" /* BOOL */, value: true, pos: start }; + case "f": + case "false": + return { type: "BOOL" /* BOOL */, value: false, pos: start }; + case "null": + case "nil": + return { type: "NULL" /* NULL */, value: null, pos: start }; + case "NaN": + throw new Error(`non-finite float literal 'NaN' at position ${start}`); + case "Inf": + throw new Error(`non-finite float literal 'Inf' at position ${start}`); + } + return { type: "IDENT" /* IDENT */, value: result, pos: start }; + } + }; + function base64ToBytes4(b64) { + if (typeof atob === "function") { + const binary = atob(b64); + const out = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i); + return out; + } + return new Uint8Array(Buffer.from(b64, "base64")); + } + var Parser = class _Parser { + constructor(text, maxDepth = DEFAULT_MAX_DEPTH, nestingDepth = 0) { + this.lexer = new Lexer(text); + this.maxDepth = maxDepth; + this.depth = nestingDepth; + } + enter(kind) { + if (this.depth >= this.maxDepth) { + throw new Error(`maximum nesting depth exceeded while parsing ${kind}`); + } + this.depth += 1; + } + leave() { + this.depth -= 1; + } + advance() { + this.current = this.lexer.nextToken(); + return this.current; + } + // Predicate over the current token type. Routing comparisons through a method + // (rather than `this.current.type === X` inline) avoids TypeScript's + // control-flow narrowing persisting across the mutating `advance()` call. + is(t2) { + return this.current.type === t2; + } + parse() { + this.lexer.skipWhitespaceAndNewlines(); + this.current = this.lexer.nextToken(); + const v = this.parseValue(); + while (this.is("NEWLINE" /* NEWLINE */)) { + this.advance(); + } + if (!this.is("EOF" /* EOF */)) { + throw new Error(`trailing garbage at position ${this.current.pos}`); + } + return v; + } + parseValue() { + const tok = this.current; + const v = tok.value; + switch (tok.type) { + case "NULL" /* NULL */: + this.advance(); + return GValue.null(); + case "BOOL" /* BOOL */: + this.advance(); + return GValue.bool(v); + case "INT" /* INT */: + this.advance(); + return GValue.int(v); + case "FLOAT" /* FLOAT */: + this.advance(); + return GValue.float(v); + case "STRING" /* STRING */: + this.advance(); + return GValue.str(v); + case "BYTES" /* BYTES */: + this.advance(); + return GValue.bytes(v); + case "^" /* CARET */: + return this.parseRef(); + case "[" /* LBRACKET */: + return this.parseList(); + case "{" /* LBRACE */: + return this.parseMap(); + case "@" /* AT */: + return this.parseDirective(); + case "IDENT" /* IDENT */: + return this.parseIdentValue(); + } + throw new Error(`unexpected token ${tok.type} at position ${tok.pos}`); + } + parseRef() { + this.advance(); + if (this.is("STRING" /* STRING */)) { + const s = this.current.value; + this.advance(); + const idx = s.indexOf(":"); + if (idx >= 0) { + return GValue.id(s.slice(0, idx), s.slice(idx + 1)); + } + return GValue.id("", s); + } + let first; + if (this.is("IDENT" /* IDENT */)) { + first = this.current.value; + this.advance(); + } else if (this.is("BOOL" /* BOOL */)) { + first = this.current.value ? "t" : "f"; + this.advance(); + } else if (this.is("INT" /* INT */)) { + first = String(this.current.value); + this.advance(); + } else { + throw new Error(`expected reference value, got ${this.current.type}`); + } + if (this.is(":" /* COLON */)) { + this.advance(); + let second; + if (this.is("IDENT" /* IDENT */) || this.is("STRING" /* STRING */)) { + second = this.current.value; + this.advance(); + } else if (this.is("INT" /* INT */)) { + second = String(this.current.value); + this.advance(); + } else if (this.is("BOOL" /* BOOL */)) { + second = this.current.value ? "t" : "f"; + this.advance(); + } else { + throw new Error(`expected reference value part, got ${this.current.type}`); + } + return GValue.id(first, second); + } + return GValue.id("", first); + } + parseList() { + this.enter("list"); + try { + this.advance(); + const items = []; + while (!this.is("]" /* RBRACKET */)) { + if (this.is("EOF" /* EOF */)) { + throw new Error("unterminated list"); + } + if (this.is("," /* COMMA */) || this.is("NEWLINE" /* NEWLINE */)) { + this.advance(); + continue; + } + if (items.length >= MAX_COLLECTION_LEN3) { + throw new Error(`list too large (>${MAX_COLLECTION_LEN3} elements)`); + } + items.push(this.parseValue()); + } + this.advance(); + return GValue.list(...items); + } finally { + this.leave(); + } + } + parseMap() { + this.enter("map"); + try { + this.advance(); + const entries = []; + while (!this.is("}" /* RBRACE */)) { + if (this.is("EOF" /* EOF */)) { + throw new Error("unterminated map"); + } + if (this.is("," /* COMMA */) || this.is("NEWLINE" /* NEWLINE */)) { + this.advance(); + continue; + } + if (entries.length >= MAX_COLLECTION_LEN3) { + throw new Error(`map too large (>${MAX_COLLECTION_LEN3} entries)`); + } + const key = this.parseKey(); + if (!this.is("=" /* EQUALS */) && !this.is(":" /* COLON */)) { + throw new Error(`expected '=' or ':' after key '${key}'`); + } + this.advance(); + entries.push({ key, value: this.parseValue() }); + } + this.advance(); + return GValue.map(...entries); + } finally { + this.leave(); + } + } + parseKey() { + if (this.is("IDENT" /* IDENT */) || this.is("STRING" /* STRING */)) { + const key = this.current.value; + this.advance(); + return key; + } + throw new Error(`expected key, got ${this.current.type}`); + } + parseIdentValue() { + const name = this.current.value; + this.advance(); + if (this.is("{" /* LBRACE */)) { + this.enter("struct"); + try { + this.advance(); + const fields = []; + while (!this.is("}" /* RBRACE */)) { + if (this.is("EOF" /* EOF */)) { + throw new Error("unterminated struct"); + } + if (this.is("," /* COMMA */) || this.is("NEWLINE" /* NEWLINE */)) { + this.advance(); + continue; + } + if (fields.length >= MAX_COLLECTION_LEN3) { + throw new Error(`struct too large (>${MAX_COLLECTION_LEN3} fields)`); + } + const key = this.parseKey(); + if (!this.is("=" /* EQUALS */) && !this.is(":" /* COLON */)) { + throw new Error(`expected '=' or ':' after field '${key}'`); + } + this.advance(); + fields.push({ key, value: this.parseValue() }); + } + this.advance(); + return GValue.struct(name, ...fields); + } finally { + this.leave(); + } + } + if (this.is("(" /* LPAREN */)) { + this.enter("sum"); + try { + this.advance(); + if (this.is(")" /* RPAREN */)) { + this.advance(); + return GValue.sum(name, null); + } + const value = this.parseValue(); + if (!this.is(")" /* RPAREN */)) { + throw new Error(`expected ), got ${this.current.type}`); + } + this.advance(); + return GValue.sum(name, value); + } finally { + this.leave(); + } + } + return GValue.str(name); + } + parseDirective() { + this.advance(); + if (!this.is("IDENT" /* IDENT */)) { + throw new Error(`expected directive name, got ${this.current.type}`); + } + const directive = this.current.value; + this.advance(); + if (directive === "tab") { + return this.parseTabular(); + } + throw new Error(`unknown directive: ${directive}`); + } + parseTabular() { + this.enter("tabular directive"); + try { + if (this.is("NULL" /* NULL */) || this.is("IDENT" /* IDENT */) && this.current.value === "_") { + this.advance(); + } + while (!this.is("[" /* LBRACKET */)) { + if (this.is("EOF" /* EOF */)) { + throw new Error("expected [ for column headers"); + } + this.advance(); + } + this.advance(); + const cols = []; + while (!this.is("]" /* RBRACKET */)) { + if (this.is("IDENT" /* IDENT */) || this.is("STRING" /* STRING */)) { + cols.push(this.current.value); + this.advance(); + } else if (this.is("," /* COMMA */) || this.is("NEWLINE" /* NEWLINE */)) { + this.advance(); + } else if (this.is("EOF" /* EOF */)) { + throw new Error("unterminated column header"); + } else { + throw new Error(`expected column name, got ${this.current.type}`); + } + } + this.advance(); + const rows = []; + for (; ; ) { + while (this.is("NEWLINE" /* NEWLINE */)) { + this.advance(); + } + if (this.is("@" /* AT */)) { + this.advance(); + if (this.is("IDENT" /* IDENT */) && this.current.value === "end") { + this.advance(); + break; + } + throw new Error("expected @end"); + } + if (this.is("|" /* PIPE */)) { + rows.push(this.parseTabularRow(cols)); + } else if (this.is("EOF" /* EOF */)) { + break; + } else { + throw new Error(`expected row or @end, got ${this.current.type}`); + } + } + return GValue.list(...rows); + } finally { + this.leave(); + } + } + parseTabularRow(cols) { + const entries = []; + for (const col of cols) { + let cell = ""; + while (this.lexer.pos < this.lexer.length) { + const c = this.lexer.text[this.lexer.pos]; + if (c === "|") break; + if (c === "\\" && this.lexer.pos + 1 < this.lexer.length) { + const nextC = this.lexer.text[this.lexer.pos + 1]; + if (nextC === "|") { + cell += "|"; + this.lexer.pos += 2; + continue; + } + if (nextC === "n") { + cell += "\n"; + this.lexer.pos += 2; + continue; + } + if (nextC === "\\") { + cell += "\\"; + this.lexer.pos += 2; + continue; + } + } + cell += c; + this.lexer.pos += 1; + } + if (this.lexer.pos >= this.lexer.length || this.lexer.text[this.lexer.pos] !== "|") { + throw new Error("expected | after cell"); + } + this.lexer.pos += 1; + const cellText = cell.trim(); + let value; + if (cellText === "" || cellText === "\u2205" || cellText === "_") { + value = GValue.null(); + } else { + const sub = new _Parser(cellText, this.maxDepth, this.depth); + value = sub.parse(); + } + entries.push({ key: col, value }); + } + this.current = this.lexer.nextToken(); + return GValue.map(...entries); + } + }; + function parseLoose(text, maxDepth = DEFAULT_MAX_DEPTH) { + return new Parser(text, maxDepth).parse(); + } + + // src/stream/index.ts + var stream_exports = {}; + __export(stream_exports, { + BaseMismatchError: () => BaseMismatchError, + CRCMismatchError: () => CRCMismatchError, + FLAGS: () => FLAGS, + FrameHandler: () => FrameHandler, + KIND_VALUES: () => KIND_VALUES, + MAX_PAYLOAD_SIZE: () => MAX_PAYLOAD_SIZE, + ParseError: () => ParseError, + Reader: () => Reader, + StreamCursor: () => StreamCursor, + VALUE_KINDS: () => VALUE_KINDS, + VERSION: () => VERSION, + ackFrame: () => ackFrame, + artifact: () => artifact, + computeCRC: () => computeCRC, + counter: () => counter, + crcToHex: () => crcToHex, + decodeFrame: () => decodeFrame, + decodeFrames: () => decodeFrames, + docFrame: () => docFrame, + emitArtifact: () => emitArtifact, + emitError: () => emitError, + emitLog: () => emitLog, + emitMetric: () => emitMetric, + emitProgress: () => emitProgress, + emitResyncRequest: () => emitResyncRequest, + emitUI: () => emitUI, + encodeFrame: () => encodeFrame, + encodeFrames: () => encodeFrames, + errFrame: () => errFrame, + error: () => error, + hashToHex: () => hashToHex, + hexToHash: () => hexToHash, + kindToString: () => kindToString, + log: () => log, + logDebug: () => logDebug, + logError: () => logError, + logInfo: () => logInfo, + logWarn: () => logWarn, + metric: () => metric, + parseCRC: () => parseCRC, + parseKind: () => parseKind, + parseUIEvent: () => parseUIEvent, + patchFrame: () => patchFrame, + pingFrame: () => pingFrame, + pongFrame: () => pongFrame, + progress: () => progress, + resyncRequest: () => resyncRequest, + rowFrame: () => rowFrame, + sha256: () => sha256, + sha256Sync: () => sha256Sync, + stateHashBytes: () => stateHashBytes, + stateHashLoose: () => stateHashLoose, + stateHashLooseSync: () => stateHashLooseSync, + uiFrame: () => uiFrame, + verifyBase: () => verifyBase, + verifyCRC: () => verifyCRC + }); + + // src/stream/types.ts + var VERSION = 1; + var KIND_VALUES = { + doc: 0, + patch: 1, + row: 2, + ui: 3, + ack: 4, + err: 5, + ping: 6, + pong: 7 + }; + var VALUE_KINDS = { + 0: "doc", + 1: "patch", + 2: "row", + 3: "ui", + 4: "ack", + 5: "err", + 6: "ping", + 7: "pong" + }; + function parseKind(s) { + if (s in KIND_VALUES) { + return s; + } + const n = parseInt(s, 10); + if (!isNaN(n) && n >= 0 && n <= 255) { + return VALUE_KINDS[n] ?? n; + } + throw new Error(`Invalid kind: ${s}`); + } + function kindToString(kind) { + if (typeof kind === "string") { + return kind; + } + return VALUE_KINDS[kind] ?? `unknown(${kind})`; + } + var FLAGS = { + HAS_CRC: 1, + HAS_BASE: 2, + FINAL: 4, + COMPRESSED: 8 + // Reserved for GS1.1 + }; + var MAX_PAYLOAD_SIZE = 64 * 1024 * 1024; + var ParseError = class extends Error { + constructor(reason, offset = -1) { + super(offset >= 0 ? `gs1: ${reason} at offset ${offset}` : `gs1: ${reason}`); + this.reason = reason; + this.offset = offset; + this.name = "ParseError"; + } + }; + var CRCMismatchError = class extends Error { + constructor(expected, got) { + super(`gs1: CRC mismatch: expected ${expected.toString(16).padStart(8, "0")}, got ${got.toString(16).padStart(8, "0")}`); + this.expected = expected; + this.got = got; + this.name = "CRCMismatchError"; + } + }; + var BaseMismatchError = class extends Error { + constructor() { + super("gs1: base hash mismatch"); + this.name = "BaseMismatchError"; + } + }; + + // src/stream/crc.ts + var CRC_TABLE = new Uint32Array(256); + (function initCRCTable() { + const polynomial = 3988292384; + for (let i = 0; i < 256; i++) { + let crc = i; + for (let j = 0; j < 8; j++) { + if (crc & 1) { + crc = crc >>> 1 ^ polynomial; + } else { + crc = crc >>> 1; + } + } + CRC_TABLE[i] = crc >>> 0; + } + })(); + function computeCRC(data) { + let crc = 4294967295; + for (let i = 0; i < data.length; i++) { + crc = CRC_TABLE[(crc ^ data[i]) & 255] ^ crc >>> 8; + } + return (crc ^ 4294967295) >>> 0; + } + function verifyCRC(data, expected) { + return computeCRC(data) === expected; + } + function crcToHex(crc) { + return crc.toString(16).padStart(8, "0"); + } + function parseCRC(s) { + if (s.startsWith("crc32:")) { + s = s.slice(6); + } + if (s.length !== 8) { + return null; + } + const n = parseInt(s, 16); + if (isNaN(n)) { + return null; + } + return n >>> 0; + } + + // src/stream/gs1t.ts + var encoder = new TextEncoder(); + var decoder = new TextDecoder(); + var DEFAULT_MAX_HEADER_BYTES = 8 * 1024; + function encodeFrame(frame, options = {}) { + const parts = []; + parts.push(`v=${frame.version || VERSION}`); + parts.push(`sid=${frame.sid}`); + parts.push(`seq=${frame.seq}`); + parts.push(`kind=${kindToString(frame.kind)}`); + parts.push(`len=${frame.payload.length}`); + let crc = frame.crc; + if (crc === void 0 && options.withCRC && frame.payload.length > 0) { + crc = computeCRC(frame.payload); + } + if (crc !== void 0) { + parts.push(`crc=${crcToHex(crc)}`); + } + if (frame.base) { + parts.push(`base=sha256:${hashToHex(frame.base)}`); + } + if (frame.final) { + parts.push("final=true"); + } + const header = `@frame{${parts.join(" ")}} +`; + const headerBytes = encoder.encode(header); + const result = new Uint8Array(headerBytes.length + frame.payload.length + 1); + result.set(headerBytes, 0); + result.set(frame.payload, headerBytes.length); + result[result.length - 1] = 10; + return result; + } + function encodeFrames(frames, options = {}) { + const encoded = frames.map((f) => encodeFrame(f, options)); + const totalLength = encoded.reduce((sum, arr) => sum + arr.length, 0); + const result = new Uint8Array(totalLength); + let offset = 0; + for (const arr of encoded) { + result.set(arr, offset); + offset += arr.length; + } + return result; + } + var Reader = class { + constructor(options = {}) { + this.buffer = new Uint8Array(0); + this.offset = 0; + this.maxPayload = options.maxPayload ?? MAX_PAYLOAD_SIZE; + this.maxHeaderBytes = options.maxHeaderBytes ?? DEFAULT_MAX_HEADER_BYTES; + this.verifyCRC = options.verifyCRC ?? true; + } + /** + * Add data to the internal buffer. + */ + push(data) { + if (this.offset > 0) { + this.buffer = this.buffer.slice(this.offset); + this.offset = 0; + } + const newBuffer = new Uint8Array(this.buffer.length + data.length); + newBuffer.set(this.buffer, 0); + newBuffer.set(data, this.buffer.length); + this.buffer = newBuffer; + } + /** + * Try to read the next frame. + * Returns null if not enough data is available. + * Throws ParseError or CRCMismatchError on errors. + */ + next() { + const headerEnd = this.findNewline(this.offset); + if (headerEnd < 0) { + if (this.buffer.length - this.offset > this.maxHeaderBytes) { + throw new ParseError(`header too large: > ${this.maxHeaderBytes}`); + } + return null; + } + if (headerEnd - this.offset > this.maxHeaderBytes) { + throw new ParseError(`header too large: > ${this.maxHeaderBytes}`); + } + const headerLine = decoder.decode(this.buffer.slice(this.offset, headerEnd)); + const header = this.parseHeader(headerLine); + if (header.version !== 1) { + throw new ParseError(`unsupported version: ${header.version} (only v=1 is supported)`); + } + if (header.payloadLen > this.maxPayload) { + throw new ParseError(`payload too large: ${header.payloadLen} > ${this.maxPayload}`); + } + const payloadStart = headerEnd + 1; + if (this.buffer.length < payloadStart + header.payloadLen) { + return null; + } + const payload = this.buffer.slice(payloadStart, payloadStart + header.payloadLen); + if (this.buffer.length > payloadStart + header.payloadLen && this.buffer[payloadStart + header.payloadLen] === 10) { + this.offset = payloadStart + header.payloadLen + 1; + } else { + this.offset = payloadStart + header.payloadLen; + } + if (this.verifyCRC && header.crc !== void 0) { + const computed = computeCRC(payload); + if (computed !== header.crc) { + throw new CRCMismatchError(header.crc, computed); + } + } + return { + version: header.version, + sid: header.sid, + seq: header.seq, + kind: header.kind, + payload, + crc: header.crc, + base: header.base, + flags: header.flags, + final: header.final + }; + } + /** + * Read all available frames. + */ + readAll() { + const frames = []; + let frame; + while ((frame = this.next()) !== null) { + frames.push(frame); + } + return frames; + } + findNewline(start) { + for (let i = start; i < this.buffer.length; i++) { + if (this.buffer[i] === 10) { + return i; + } + } + return -1; + } + parseHeader(line) { + line = line.trim(); + if (!line.startsWith("@frame{")) { + throw new ParseError("expected @frame{", 0); + } + const endIdx = line.lastIndexOf("}"); + if (endIdx < 0) { + throw new ParseError("missing closing }"); + } + if (endIdx !== line.length - 1) { + throw new ParseError("trailing data after header"); + } + const content = line.slice(7, endIdx); + const pairs = this.tokenize(content); + const header = { + version: 1, + sid: 0n, + seq: 0n, + kind: "doc", + payloadLen: 0 + }; + for (const pair of pairs) { + const eqIdx = pair.indexOf("="); + if (eqIdx < 0) continue; + const key = pair.slice(0, eqIdx); + const val = pair.slice(eqIdx + 1); + switch (key) { + case "v": + header.version = this.parseUnsignedInt(val, "v"); + break; + case "sid": + header.sid = this.parseUnsignedBigInt(val, "sid"); + break; + case "seq": + header.seq = this.parseUnsignedBigInt(val, "seq"); + break; + case "kind": + header.kind = parseKind(val); + break; + case "len": + header.payloadLen = this.parseUnsignedInt(val, "len"); + break; + case "crc": + header.crc = parseCRC(val) ?? void 0; + break; + case "base": + header.base = hexToHash(val) ?? void 0; + break; + case "final": + header.final = val === "true" || val === "1"; + break; + case "flags": + header.flags = this.parseHexInt(val, "flags"); + break; + } + } + return header; + } + tokenize(s) { + const tokens = []; + let current = ""; + let inQuote = false; + for (let i = 0; i < s.length; i++) { + const c = s[i]; + if (c === '"') { + inQuote = !inQuote; + current += c; + } else if ((c === " " || c === "," || c === " ") && !inQuote) { + if (current.length > 0) { + tokens.push(current); + current = ""; + } + } else { + current += c; + } + } + if (current.length > 0) { + tokens.push(current); + } + return tokens; + } + parseUnsignedInt(raw, field2) { + if (!/^\d+$/.test(raw)) { + throw new ParseError(`invalid ${field2}`); + } + const value = Number(raw); + if (!Number.isSafeInteger(value)) { + throw new ParseError(`${field2} out of range`); + } + return value; + } + parseUnsignedBigInt(raw, field2) { + if (!/^\d+$/.test(raw)) { + throw new ParseError(`invalid ${field2}`); + } + return BigInt(raw); + } + parseHexInt(raw, field2) { + const normalized = raw.replace(/^0x/i, ""); + if (!/^[0-9a-fA-F]+$/.test(normalized)) { + throw new ParseError(`invalid ${field2}`); + } + const value = parseInt(normalized, 16); + if (!Number.isSafeInteger(value)) { + throw new ParseError(`${field2} out of range`); + } + return value; + } + }; + function decodeFrames(data, options) { + const reader = new Reader(options); + reader.push(data); + return reader.readAll(); + } + function decodeFrame(data, options) { + const reader = new Reader(options); + reader.push(data); + return reader.next(); + } + function docFrame(sid, seq, payload) { + return { + version: VERSION, + sid, + seq, + kind: "doc", + payload: typeof payload === "string" ? encoder.encode(payload) : payload + }; + } + function patchFrame(sid, seq, payload, base) { + return { + version: VERSION, + sid, + seq, + kind: "patch", + payload: typeof payload === "string" ? encoder.encode(payload) : payload, + base + }; + } + function rowFrame(sid, seq, payload) { + return { + version: VERSION, + sid, + seq, + kind: "row", + payload: typeof payload === "string" ? encoder.encode(payload) : payload + }; + } + function uiFrame(sid, seq, payload) { + return { + version: VERSION, + sid, + seq, + kind: "ui", + payload: typeof payload === "string" ? encoder.encode(payload) : payload + }; + } + function ackFrame(sid, seq) { + return { + version: VERSION, + sid, + seq, + kind: "ack", + payload: new Uint8Array(0) + }; + } + function errFrame(sid, seq, payload) { + return { + version: VERSION, + sid, + seq, + kind: "err", + payload: typeof payload === "string" ? encoder.encode(payload) : payload + }; + } + function pingFrame(sid, seq) { + return { + version: VERSION, + sid, + seq, + kind: "ping", + payload: new Uint8Array(0) + }; + } + function pongFrame(sid, seq) { + return { + version: VERSION, + sid, + seq, + kind: "pong", + payload: new Uint8Array(0) + }; + } + + // src/stream/cursor.ts + var StreamCursor = class { + constructor() { + this.cursors = /* @__PURE__ */ new Map(); + } + /** + * Get state for a SID, creating it if needed. + */ + get(sid) { + let state = this.cursors.get(sid); + if (!state) { + state = { + sid, + lastSeq: 0n, + lastAcked: 0n, + stateHash: null, + state: null, + final: false + }; + this.cursors.set(sid, state); + } + return state; + } + /** + * Get state for a SID without creating it. + */ + getReadOnly(sid) { + return this.cursors.get(sid); + } + /** + * Delete state for a SID. + */ + delete(sid) { + this.cursors.delete(sid); + } + /** + * Get all tracked SIDs. + */ + allSIDs() { + return Array.from(this.cursors.keys()); + } + /** + * Process a frame and update cursor state. + * Throws on sequence gaps, duplicates, or base mismatches. + */ + processFrame(frame) { + const state = this.get(frame.sid); + if (frame.seq !== 0n && frame.seq <= state.lastSeq) { + throw new Error(`sequence not monotonic: got ${frame.seq}, last was ${state.lastSeq}`); + } + if (state.lastSeq > 0n && frame.seq !== state.lastSeq + 1n) { + throw new Error(`sequence gap: expected ${state.lastSeq + 1n}, got ${frame.seq}`); + } + if (frame.kind === "patch" && frame.base && state.stateHash) { + if (!verifyBase(state.stateHash, frame.base)) { + throw new BaseMismatchError(); + } + } + state.lastSeq = frame.seq; + if (frame.final) { + state.final = true; + } + } + /** + * Set the current state and compute its hash. + */ + setState(sid, value) { + const state = this.get(sid); + state.state = value; + state.stateHash = stateHashLooseSync(value); + } + /** + * Set the state hash directly. + */ + setStateHash(sid, hash) { + const state = this.get(sid); + state.stateHash = hash; + } + /** + * Mark a sequence as acknowledged. + */ + ack(sid, seq) { + const state = this.get(sid); + if (seq > state.lastAcked) { + state.lastAcked = seq; + } + } + /** + * Get sequences that have been seen but not acked. + */ + pendingAcks(sid) { + const state = this.getReadOnly(sid); + if (!state || state.lastSeq <= state.lastAcked) { + return []; + } + const pending = []; + for (let seq = state.lastAcked + 1n; seq <= state.lastSeq; seq++) { + pending.push(seq); + } + return pending; + } + /** + * Check if resync is needed (no state hash). + */ + needsResync(sid) { + const state = this.getReadOnly(sid); + return !state || !state.stateHash; + } + }; + var FrameHandler = class { + constructor(callbacks = {}) { + this.cursor = new StreamCursor(); + this.callbacks = callbacks; + } + /** + * Handle a frame and call the appropriate callback. + */ + handle(frame) { + const state = this.cursor.get(frame.sid); + if (frame.seq !== 0n && state.lastSeq > 0n) { + if (frame.seq <= state.lastSeq) { + return; + } + if (frame.seq !== state.lastSeq + 1n) { + if (this.callbacks.onSeqGap) { + const allow = this.callbacks.onSeqGap(frame.sid, state.lastSeq + 1n, frame.seq); + if (!allow) return; + } + } + } + if (frame.kind === "patch" && frame.base && state.stateHash) { + if (!verifyBase(state.stateHash, frame.base)) { + if (this.callbacks.onBaseMismatch) { + const allow = this.callbacks.onBaseMismatch(frame.sid, frame); + if (!allow) return; + } else { + throw new BaseMismatchError(); + } + } + } + state.lastSeq = frame.seq; + switch (frame.kind) { + case "doc": + this.callbacks.onDoc?.(frame.sid, frame.seq, frame.payload, state); + break; + case "patch": + this.callbacks.onPatch?.(frame.sid, frame.seq, frame.payload, state); + break; + case "row": + this.callbacks.onRow?.(frame.sid, frame.seq, frame.payload, state); + break; + case "ui": + this.callbacks.onUI?.(frame.sid, frame.seq, frame.payload, state); + break; + case "ack": + this.callbacks.onAck?.(frame.sid, frame.seq, state); + break; + case "err": + this.callbacks.onErr?.(frame.sid, frame.seq, frame.payload, state); + break; + } + if (frame.final) { + state.final = true; + this.callbacks.onFinal?.(frame.sid, state); + } + } + }; + + // src/stream/ui_events.ts + var encoder2 = new TextEncoder(); + function progress(pct, msg) { + return g.struct( + "Progress", + field("pct", g.float(pct)), + field("msg", g.str(msg)) + ); + } + function log(level, msg) { + return g.struct( + "Log", + field("level", g.str(level)), + field("msg", g.str(msg)), + field("ts", g.time(/* @__PURE__ */ new Date())) + ); + } + function logInfo(msg) { + return log("info", msg); + } + function logWarn(msg) { + return log("warn", msg); + } + function logError(msg) { + return log("error", msg); + } + function logDebug(msg) { + return log("debug", msg); + } + function metric(name, value, unit) { + const fields = [ + field("name", g.str(name)), + field("value", g.float(value)) + ]; + if (unit) { + fields.push(field("unit", g.str(unit))); + } + return g.struct("Metric", ...fields); + } + function counter(name, count) { + return g.struct( + "Metric", + field("name", g.str(name)), + field("value", g.int(count)), + field("unit", g.str("count")) + ); + } + function artifact(mime, ref, name) { + return g.struct( + "Artifact", + field("mime", g.str(mime)), + field("ref", g.str(ref)), + field("name", g.str(name)) + ); + } + function resyncRequest(sid, seq, want, reason) { + return g.struct( + "ResyncRequest", + field("sid", g.int(Number(sid))), + field("seq", g.int(Number(seq))), + field("want", g.str(want)), + field("reason", g.str(reason)) + ); + } + function error(code, msg, sid, seq) { + return g.struct( + "Error", + field("code", g.str(code)), + field("msg", g.str(msg)), + field("sid", g.int(Number(sid))), + field("seq", g.int(Number(seq))) + ); + } + function emitUI(v) { + return encoder2.encode(emit(v)); + } + function emitProgress(pct, msg) { + return emitUI(progress(pct, msg)); + } + function emitLog(level, msg) { + return emitUI(log(level, msg)); + } + function emitMetric(name, value, unit) { + return emitUI(metric(name, value, unit)); + } + function emitArtifact(mime, ref, name) { + return emitUI(artifact(mime, ref, name)); + } + function emitError(code, msg, sid, seq) { + return emitUI(error(code, msg, sid, seq)); + } + function emitResyncRequest(sid, seq, want, reason) { + return emitUI(resyncRequest(sid, seq, want, reason)); + } + function parseUIEvent(payload) { + const decoder2 = new TextDecoder(); + const text = decoder2.decode(payload); + const match = text.match(/^(\w+)[@{]\((.*)\)$/s) || text.match(/^(\w+)\{(.*)\}$/s); + if (!match) { + throw new Error(`Invalid UI event format: ${text}`); + } + const type = match[1]; + const content = match[2]; + const fields = {}; + const pairs = content.match(/(\w+)=("[^"]*"|\S+)/g); + if (pairs) { + for (const pair of pairs) { + const [key, ...rest] = pair.split("="); + let value = rest.join("="); + if (typeof value === "string") { + if (value.startsWith('"') && value.endsWith('"')) { + value = value.slice(1, -1); + } else if (value === "t" || value === "true") { + value = true; + } else if (value === "f" || value === "false") { + value = false; + } else if (/^-?\d+$/.test(value)) { + value = parseInt(value, 10); + } else if (/^-?\d*\.\d+$/.test(value)) { + value = parseFloat(value); + } + } + fields[key] = value; + } + } + return { type, fields }; + } + + // src/decimal128.ts + var DecimalError = class extends Error { + constructor(message) { + super(message); + this.name = "DecimalError"; + } + }; + var Decimal128 = class _Decimal128 { + constructor(scale, coef) { + if (scale < -127 || scale > 127) { + throw new DecimalError(`scale must be -127 to 127, got ${scale}`); + } + this.scale = scale; + this.coef = coef; + } + /** + * Create a Decimal128 from an integer. + */ + static fromInt(value) { + return new _Decimal128(0, BigInt(value)); + } + /** + * Create a Decimal128 from a string. + * Examples: "123.45", "99.99", "-0.001" + */ + static fromString(s) { + s = s.trim(); + if (s.endsWith("m")) { + s = s.slice(0, -1); + } + const negative = s.startsWith("-"); + if (negative) { + s = s.slice(1); + } + const parts = s.split("."); + if (parts.length > 2) { + throw new DecimalError(`invalid decimal format: ${s}`); + } + let scale = 0; + let coefStr; + if (parts.length === 2) { + const intPart = parts[0] || "0"; + const fracPart = parts[1]; + scale = fracPart.length; + coefStr = intPart + fracPart; + } else { + coefStr = parts[0]; + } + if (scale > 127) { + throw new DecimalError(`scale too large: ${scale}`); + } + let coef = BigInt(coefStr); + if (negative) { + coef = -coef; + } + return new _Decimal128(scale, coef); + } + /** + * Create a Decimal128 from a number (with potential precision loss). + */ + static fromNumber(n) { + return _Decimal128.fromString(n.toString()); + } + /** + * Convert to integer (truncates fractional part). + */ + toInt() { + const divisor = 10n ** BigInt(this.scale); + return this.coef / divisor; + } + /** + * Convert to number (with potential precision loss). + */ + toNumber() { + const divisor = 10 ** this.scale; + return Number(this.coef) / divisor; + } + /** + * Convert to string. + */ + toString() { + if (this.scale === 0) { + return this.coef.toString(); + } + const negative = this.coef < 0n; + let coefStr = (negative ? -this.coef : this.coef).toString(); + while (coefStr.length <= this.scale) { + coefStr = "0" + coefStr; + } + const insertPos = coefStr.length - this.scale; + const result = coefStr.slice(0, insertPos) + "." + coefStr.slice(insertPos); + return negative ? "-" + result : result; + } + /** + * Check if value is zero. + */ + isZero() { + return this.coef === 0n; + } + /** + * Check if value is negative. + */ + isNegative() { + return this.coef < 0n; + } + /** + * Check if value is positive. + */ + isPositive() { + return this.coef > 0n; + } + /** + * Return the absolute value. + */ + abs() { + return new _Decimal128(this.scale, this.coef < 0n ? -this.coef : this.coef); + } + /** + * Negate the value. + */ + negate() { + return new _Decimal128(this.scale, -this.coef); + } + /** + * Add two decimals. + */ + add(other) { + let c1 = this.coef; + let c2 = other.coef; + let targetScale; + if (this.scale < other.scale) { + const diff = other.scale - this.scale; + c1 = c1 * 10n ** BigInt(diff); + targetScale = other.scale; + } else { + const diff = this.scale - other.scale; + c2 = c2 * 10n ** BigInt(diff); + targetScale = this.scale; + } + return new _Decimal128(targetScale, c1 + c2); + } + /** + * Subtract two decimals. + */ + sub(other) { + return this.add(other.negate()); + } + /** + * Multiply two decimals. + */ + mul(other) { + const result = this.coef * other.coef; + const newScale = this.scale + other.scale; + if (newScale > 127 || newScale < -127) { + throw new DecimalError("scale overflow"); + } + return new _Decimal128(newScale, result); + } + /** + * Divide two decimals. + */ + div(other) { + if (other.coef === 0n) { + throw new DecimalError("division by zero"); + } + const result = this.coef / other.coef; + const newScale = this.scale - other.scale; + if (newScale > 127 || newScale < -127) { + throw new DecimalError("scale overflow"); + } + return new _Decimal128(newScale, result); + } + /** + * Compare two decimals. + * Returns -1 if this < other, 0 if equal, 1 if this > other. + */ + cmp(other) { + let c1 = this.coef; + let c2 = other.coef; + if (this.scale < other.scale) { + const diff = other.scale - this.scale; + c1 = c1 * 10n ** BigInt(diff); + } else if (this.scale > other.scale) { + const diff = this.scale - other.scale; + c2 = c2 * 10n ** BigInt(diff); + } + if (c1 < c2) return -1; + if (c1 > c2) return 1; + return 0; + } + /** + * Check equality. + */ + equals(other) { + return this.cmp(other) === 0; + } + /** + * Less than comparison. + */ + lt(other) { + return this.cmp(other) < 0; + } + /** + * Greater than comparison. + */ + gt(other) { + return this.cmp(other) > 0; + } + /** + * Less than or equal comparison. + */ + lte(other) { + return this.cmp(other) <= 0; + } + /** + * Greater than or equal comparison. + */ + gte(other) { + return this.cmp(other) >= 0; + } + }; + function isDecimalLiteral(s) { + s = s.trim(); + if (!s.endsWith("m")) { + return false; + } + try { + Decimal128.fromString(s.slice(0, -1)); + return true; + } catch { + return false; + } + } + function parseDecimalLiteral(s) { + s = s.trim(); + if (!s.endsWith("m")) { + throw new DecimalError("not a decimal literal"); + } + return Decimal128.fromString(s.slice(0, -1)); + } + function decimal(value) { + if (typeof value === "string") { + return Decimal128.fromString(value); + } + if (typeof value === "bigint") { + return Decimal128.fromInt(value); + } + return Decimal128.fromNumber(value); + } + + // src/schema_evolution.ts + var EvolutionMode = /* @__PURE__ */ ((EvolutionMode2) => { + EvolutionMode2["Strict"] = "strict"; + EvolutionMode2["Tolerant"] = "tolerant"; + EvolutionMode2["Migrate"] = "migrate"; + return EvolutionMode2; + })(EvolutionMode || {}); + var EvolvingField = class { + constructor(name, config) { + this.name = name; + this.type = config.type; + this.required = config.required ?? false; + this.default = config.default; + this.addedIn = config.addedIn ?? "1.0"; + this.deprecatedIn = config.deprecatedIn; + this.renamedFrom = config.renamedFrom; + this.validation = config.validation ? typeof config.validation === "string" ? new RegExp(config.validation) : config.validation : void 0; + } + /** + * Check if field is available in a given version. + */ + isAvailableIn(version) { + if (compareVersions(version, this.addedIn) < 0) { + return false; + } + if (this.deprecatedIn && compareVersions(version, this.deprecatedIn) >= 0) { + return false; + } + return true; + } + /** + * Check if field is deprecated in a given version. + */ + isDeprecatedIn(version) { + if (!this.deprecatedIn) { + return false; + } + return compareVersions(version, this.deprecatedIn) >= 0; + } + /** + * Validate a value against this field. + */ + validate(value) { + if (value === null || value === void 0) { + if (this.required) { + return `field ${this.name} is required`; + } + return null; + } + switch (this.type) { + case "str": + if (typeof value !== "string") { + return `field ${this.name} must be string`; + } + if (this.validation && !this.validation.test(value)) { + return `field ${this.name} does not match pattern`; + } + break; + case "int": + if (typeof value !== "number" || !Number.isInteger(value)) { + return `field ${this.name} must be int`; + } + break; + case "float": + if (typeof value !== "number") { + return `field ${this.name} must be float`; + } + break; + case "bool": + if (typeof value !== "boolean") { + return `field ${this.name} must be bool`; + } + break; + case "list": + if (!Array.isArray(value)) { + return `field ${this.name} must be list`; + } + break; + } + return null; + } + }; + var VersionSchema = class { + constructor(name, version) { + this.name = name; + this.version = version; + this.fields = /* @__PURE__ */ new Map(); + this.description = ""; + } + /** + * Add a field. + */ + addField(field2) { + this.fields.set(field2.name, field2); + } + /** + * Get a field by name. + */ + getField(name) { + return this.fields.get(name); + } + /** + * Validate data against this schema. + */ + validate(data) { + for (const [name, field2] of this.fields) { + if (field2.required && !(name in data)) { + return `missing required field: ${name}`; + } + } + for (const [name, value] of Object.entries(data)) { + const field2 = this.fields.get(name); + if (field2) { + const error2 = field2.validate(value); + if (error2) { + return error2; + } + } + } + return null; + } + }; + var VersionedSchema = class { + constructor(name) { + this.name = name; + this.versions = /* @__PURE__ */ new Map(); + this.latestVersion = "1.0"; + this.mode = "tolerant" /* Tolerant */; + } + /** + * Set evolution mode. + */ + withMode(mode) { + this.mode = mode; + return this; + } + /** + * Add a version with fields. + */ + addVersion(version, fields) { + const schema = new VersionSchema(this.name, version); + for (const [name, config] of Object.entries(fields)) { + const fieldConfig = { ...config }; + if (!fieldConfig.addedIn) { + fieldConfig.addedIn = version; + } + schema.addField(new EvolvingField(name, fieldConfig)); + } + this.versions.set(version, schema); + this.latestVersion = this.getLatestVersion(); + } + /** + * Get schema for a specific version. + */ + getVersion(version) { + return this.versions.get(version); + } + /** + * Parse data from a specific version. + */ + parse(data, fromVersion) { + const schema = this.getVersion(fromVersion); + if (!schema) { + return { error: `unknown version: ${fromVersion}` }; + } + if (this.mode === "strict" /* Strict */) { + const error2 = schema.validate(data); + if (error2) { + return { error: error2 }; + } + } + let result = { ...data }; + if (fromVersion !== this.latestVersion) { + const migrated = this.migrate(data, fromVersion, this.latestVersion); + if (migrated.error) { + return migrated; + } + result = migrated.data; + } + if (this.mode === "tolerant" /* Tolerant */) { + const targetSchema = this.getVersion(this.latestVersion); + if (targetSchema) { + const filtered = {}; + for (const [k, v] of Object.entries(result)) { + if (targetSchema.fields.has(k)) { + filtered[k] = v; + } + } + result = filtered; + } + } + return { data: result }; + } + /** + * Emit version header for data. + */ + emit(data, version) { + const targetVersion = version ?? this.latestVersion; + const schema = this.getVersion(targetVersion); + if (!schema) { + return { error: `unknown version: ${targetVersion}` }; + } + const error2 = schema.validate(data); + if (error2) { + return { error: error2 }; + } + return { header: `@version ${targetVersion}` }; + } + /** + * Migrate data between versions. + */ + migrate(data, fromVersion, toVersion) { + const path = this.getMigrationPath(fromVersion, toVersion); + if (!path) { + return { error: `cannot migrate from ${fromVersion} to ${toVersion}` }; + } + let currentData = { ...data }; + let currentVersion = fromVersion; + for (const nextVersion of path) { + const result = this.migrateStep(currentData, currentVersion, nextVersion); + if (result.error) { + return result; + } + currentData = result.data; + currentVersion = nextVersion; + } + return { data: currentData }; + } + /** + * Migrate one step. + */ + migrateStep(data, _fromVersion, toVersion) { + const toSchema = this.getVersion(toVersion); + if (!toSchema) { + return { error: "invalid version" }; + } + const result = { ...data }; + for (const [name, field2] of toSchema.fields) { + if (field2.renamedFrom && field2.renamedFrom in result && !(name in result)) { + result[name] = result[field2.renamedFrom]; + delete result[field2.renamedFrom]; + } + } + for (const [name, field2] of toSchema.fields) { + if (!(name in result)) { + if (field2.default !== void 0) { + result[name] = field2.default; + } else if (!field2.required) { + result[name] = null; + } + } + } + if (this.mode === "tolerant" /* Tolerant */) { + for (const key of Object.keys(result)) { + if (!toSchema.fields.has(key)) { + delete result[key]; + } + } + } + return { data: result }; + } + /** + * Get migration path between versions. + */ + getMigrationPath(fromVersion, toVersion) { + const versions = Array.from(this.versions.keys()).sort( + (a, b) => compareVersions(a, b) + ); + const fromIdx = versions.indexOf(fromVersion); + const toIdx = versions.indexOf(toVersion); + if (fromIdx === -1 || toIdx === -1) { + return null; + } + if (fromIdx < toIdx) { + return versions.slice(fromIdx + 1, toIdx + 1); + } else if (fromIdx > toIdx) { + return null; + } + return []; + } + /** + * Get the latest version string. + */ + getLatestVersion() { + const versions = Array.from(this.versions.keys()).sort( + (a, b) => compareVersions(a, b) + ); + return versions[versions.length - 1] ?? "1.0"; + } + /** + * Get changelog of schema evolution. + */ + getChangelog() { + const versions = Array.from(this.versions.keys()).sort( + (a, b) => compareVersions(a, b) + ); + return versions.map((version) => { + const schema = this.versions.get(version); + const addedFields = []; + const deprecatedFields = []; + const renamedFields = []; + for (const [name, field2] of schema.fields) { + if (field2.addedIn === version) { + addedFields.push(name); + } + if (field2.deprecatedIn === version) { + deprecatedFields.push(name); + } + if (field2.renamedFrom) { + renamedFields.push([field2.renamedFrom, name]); + } + } + return { + version, + description: schema.description, + addedFields, + deprecatedFields, + renamedFields + }; + }); + } + }; + function compareVersions(v1, v2) { + const parts1 = v1.split(".").map((s) => parseInt(s, 10) || 0); + const parts2 = v2.split(".").map((s) => parseInt(s, 10) || 0); + const maxLen = Math.max(parts1.length, parts2.length); + for (let i = 0; i < maxLen; i++) { + const p1 = parts1[i] ?? 0; + const p2 = parts2[i] ?? 0; + if (p1 < p2) return -1; + if (p1 > p2) return 1; + } + return 0; + } + function parseVersionHeader(text) { + text = text.trim(); + if (!text.startsWith("@version ")) { + return null; + } + const version = text.slice(9).trim(); + if (!version) { + return null; + } + return version; + } + function formatVersionHeader(version) { + return `@version ${version}`; + } + function versionedSchema(name) { + return new VersionedSchema(name); + } + + // src/stream_validator.ts + var hasOwnProperty3 = Object.prototype.hasOwnProperty; + function hasOwn3(obj, key) { + return hasOwnProperty3.call(obj, key); + } + function createArgRecord() { + return /* @__PURE__ */ Object.create(null); + } + function createFieldRecord() { + return /* @__PURE__ */ Object.create(null); + } + function cloneFieldRecord(fields) { + return Object.assign(createFieldRecord(), fields); + } + var ToolRegistry = class { + constructor() { + this.tools = /* @__PURE__ */ new Map(); + } + /** + * Register a tool. + */ + register(tool) { + const args = createArgRecord(); + for (const [name, schema] of Object.entries(tool.args)) { + args[name] = schema; + } + this.tools.set(tool.name, { ...tool, args }); + } + /** + * Check if a tool is allowed. + */ + isAllowed(name) { + return this.tools.has(name); + } + /** + * Get a tool schema. + */ + get(name) { + return this.tools.get(name); + } + }; + var ErrorCode = /* @__PURE__ */ ((ErrorCode2) => { + ErrorCode2["UnknownTool"] = "UNKNOWN_TOOL"; + ErrorCode2["MissingRequired"] = "MISSING_REQUIRED"; + ErrorCode2["MissingTool"] = "MISSING_TOOL"; + ErrorCode2["ConstraintMin"] = "CONSTRAINT_MIN"; + ErrorCode2["ConstraintMax"] = "CONSTRAINT_MAX"; + ErrorCode2["ConstraintLen"] = "CONSTRAINT_LEN"; + ErrorCode2["ConstraintPattern"] = "CONSTRAINT_PATTERN"; + ErrorCode2["ConstraintEnum"] = "CONSTRAINT_ENUM"; + ErrorCode2["InvalidType"] = "INVALID_TYPE"; + ErrorCode2["LimitExceeded"] = "LIMIT_EXCEEDED"; + return ErrorCode2; + })(ErrorCode || {}); + var DEFAULT_MAX_BUFFER = 1 << 20; + var DEFAULT_MAX_FIELDS = 1e3; + var DEFAULT_MAX_ERRORS = 100; + var ValidatorState = /* @__PURE__ */ ((ValidatorState2) => { + ValidatorState2["Waiting"] = "waiting"; + ValidatorState2["InObject"] = "in_object"; + ValidatorState2["Complete"] = "complete"; + ValidatorState2["Error"] = "error"; + return ValidatorState2; + })(ValidatorState || {}); + var StreamingValidator = class { + constructor(registry, limits) { + // Parser state + this.buffer = ""; + this.state = "waiting" /* Waiting */; + this.depth = 0; + this.inString = false; + this.escapeNext = false; + this.currentKey = ""; + this.currentVal = ""; + this.hasKey = false; + // Parsed data + this.toolName = null; + this.fields = createFieldRecord(); + this.fieldCount = 0; + this.errors = []; + // Timing + this.tokenCount = 0; + this.charCount = 0; + this.startTime = 0; + this.toolDetectedAtToken = 0; + this.toolDetectedAtChar = 0; + this.toolDetectedAtTime = 0; + this.firstErrorAtToken = 0; + this.firstErrorAtTime = 0; + this.completeAtToken = 0; + this.completeAtTime = 0; + // Timeline + this.timeline = []; + // Hard limits to prevent OOM/DoS + this.maxBufferSize = DEFAULT_MAX_BUFFER; + this.maxFieldCount = DEFAULT_MAX_FIELDS; + this.maxErrorCount = DEFAULT_MAX_ERRORS; + this.registry = registry; + if (limits) { + this.withLimits(limits); + } + } + /** + * Set custom limits. Returns self for chaining. + */ + withLimits(limits) { + if (limits.maxBufferSize !== void 0 && limits.maxBufferSize > 0) { + this.maxBufferSize = limits.maxBufferSize; + } + if (limits.maxFieldCount !== void 0 && limits.maxFieldCount > 0) { + this.maxFieldCount = limits.maxFieldCount; + } + if (limits.maxErrorCount !== void 0 && limits.maxErrorCount > 0) { + this.maxErrorCount = limits.maxErrorCount; + } + return this; + } + /** + * Add an error, respecting maxErrorCount limit. + */ + addError(code, message, field2) { + if (this.errors.length >= this.maxErrorCount) { + return; + } + this.errors.push({ code, message, field: field2 }); + } + /** + * Reset the validator for reuse. + */ + reset() { + this.buffer = ""; + this.state = "waiting" /* Waiting */; + this.depth = 0; + this.inString = false; + this.escapeNext = false; + this.currentKey = ""; + this.currentVal = ""; + this.hasKey = false; + this.toolName = null; + this.fields = createFieldRecord(); + this.fieldCount = 0; + this.errors = []; + this.tokenCount = 0; + this.charCount = 0; + this.startTime = 0; + this.toolDetectedAtToken = 0; + this.toolDetectedAtChar = 0; + this.toolDetectedAtTime = 0; + this.firstErrorAtToken = 0; + this.firstErrorAtTime = 0; + this.completeAtToken = 0; + this.completeAtTime = 0; + this.timeline = []; + } + /** + * Start timing. + */ + start() { + this.startTime = Date.now(); + } + /** + * Process a token from the LLM. + */ + pushToken(token) { + if (this.startTime === 0) { + this.start(); + } + this.tokenCount++; + for (const c of token) { + this.charCount++; + this.processChar(c); + } + const elapsed = Date.now() - this.startTime; + if (this.toolName && this.toolDetectedAtToken === 0) { + this.toolDetectedAtToken = this.tokenCount; + this.toolDetectedAtChar = this.charCount; + this.toolDetectedAtTime = elapsed; + const allowed = this.registry.isAllowed(this.toolName); + this.timeline.push({ + event: "TOOL_DETECTED", + token: this.tokenCount, + charPos: this.charCount, + elapsed, + detail: `tool=${this.toolName} allowed=${allowed}` + }); + } + if (this.errors.length > 0 && this.firstErrorAtToken === 0) { + this.firstErrorAtToken = this.tokenCount; + this.firstErrorAtTime = elapsed; + this.timeline.push({ + event: "ERROR", + token: this.tokenCount, + charPos: this.charCount, + elapsed, + detail: this.errors[0].message + }); + } + if (this.state === "complete" /* Complete */ && this.completeAtToken === 0) { + this.completeAtToken = this.tokenCount; + this.completeAtTime = elapsed; + this.timeline.push({ + event: "COMPLETE", + token: this.tokenCount, + charPos: this.charCount, + elapsed, + detail: `valid=${this.errors.length === 0}` + }); + } + return this.getResult(); + } + processChar(c) { + if (this.state === "error" /* Error */) { + return; + } + if (this.buffer.length >= this.maxBufferSize) { + this.state = "error" /* Error */; + this.addError("LIMIT_EXCEEDED" /* LimitExceeded */, "Buffer size limit exceeded"); + return; + } + this.buffer += c; + if (this.escapeNext) { + this.escapeNext = false; + this.currentVal += c; + return; + } + if (c === "\\" && this.inString) { + this.escapeNext = true; + this.currentVal += c; + return; + } + if (c === '"') { + if (this.inString) { + this.inString = false; + } else { + this.inString = true; + this.currentVal = ""; + } + return; + } + if (this.inString) { + this.currentVal += c; + return; + } + switch (c) { + case "{": + if (this.state === "waiting" /* Waiting */) { + const preBraceText = this.currentVal.trim(); + if (preBraceText) { + this.toolName = preBraceText; + this.currentVal = ""; + if (!this.registry.isAllowed(preBraceText)) { + this.addError("UNKNOWN_TOOL" /* UnknownTool */, `Unknown tool: ${preBraceText}`); + } + } + this.state = "in_object" /* InObject */; + } + this.depth++; + break; + case "}": + this.depth--; + if (this.depth === 0) { + this.finishField(); + this.state = "complete" /* Complete */; + this.validateComplete(); + } + break; + case "[": + this.depth++; + this.currentVal += c; + break; + case "]": + this.depth--; + this.currentVal += c; + break; + case "=": + if (this.depth === 1 && !this.hasKey) { + this.currentKey = this.currentVal.trim(); + this.currentVal = ""; + this.hasKey = true; + } else { + this.currentVal += c; + } + break; + case " ": + case "\n": + case " ": + case "\r": + if (this.depth === 1 && this.hasKey && this.currentVal.length > 0) { + this.finishField(); + } + break; + default: + if (this.state === "waiting" /* Waiting */ && this.depth === 0) { + this.currentVal += c; + } else if (this.depth >= 1) { + this.currentVal += c; + } + } + } + finishField() { + if (!this.hasKey) { + return; + } + const key = this.currentKey; + const valStr = this.currentVal.trim(); + this.currentKey = ""; + this.currentVal = ""; + this.hasKey = false; + const value = this.parseValue(valStr); + if (key === "action" || key === "tool") { + if (typeof value === "string") { + this.toolName = value; + if (!this.registry.isAllowed(value)) { + this.addError("UNKNOWN_TOOL" /* UnknownTool */, `Unknown tool: ${value}`, key); + } + } + } + if (this.toolName) { + this.validateField(key, value); + } + if (!hasOwn3(this.fields, key)) { + if (this.fieldCount >= this.maxFieldCount) { + this.state = "error" /* Error */; + this.addError("LIMIT_EXCEEDED" /* LimitExceeded */, "Field count limit exceeded"); + return; + } + this.fieldCount++; + } + this.fields[key] = value; + } + parseValue(s) { + if (s === "t" || s === "true") { + return true; + } + if (s === "f" || s === "false") { + return false; + } + if (s === "_" || s === "null" || s === "" || s === "\u2205") { + return null; + } + if (/^-?\d+$/.test(s)) { + return parseInt(s, 10); + } + if (/^-?\d+\.?\d*(?:[eE][+-]?\d+)?$/.test(s) || /^-?\d*\.?\d+(?:[eE][+-]?\d+)?$/.test(s)) { + const f = parseFloat(s); + if (!isNaN(f)) { + return f; + } + } + return s; + } + validateField(key, value) { + if (key === "action" || key === "tool") { + return; + } + const schema = this.registry.get(this.toolName); + if (!schema) { + return; + } + const argSchema = hasOwn3(schema.args, key) ? schema.args[key] : void 0; + if (!argSchema) { + this.addError("UNKNOWN_TOOL" /* UnknownTool */, `Unknown argument: ${key}`, key); + return; + } + if (!this.isValidType(argSchema.type, value)) { + this.addError("INVALID_TYPE" /* InvalidType */, `${key} expected ${argSchema.type}`, key); + return; + } + if (typeof value === "number") { + if (argSchema.min !== void 0 && value < argSchema.min) { + this.addError("CONSTRAINT_MIN" /* ConstraintMin */, `${key} < ${argSchema.min}`, key); + } + if (argSchema.max !== void 0 && value > argSchema.max) { + this.addError("CONSTRAINT_MAX" /* ConstraintMax */, `${key} > ${argSchema.max}`, key); + } + } + if (typeof value === "string") { + if (argSchema.minLen !== void 0 && value.length < argSchema.minLen) { + this.addError("CONSTRAINT_LEN" /* ConstraintLen */, `${key} length < ${argSchema.minLen}`, key); + } + if (argSchema.maxLen !== void 0 && value.length > argSchema.maxLen) { + this.addError("CONSTRAINT_LEN" /* ConstraintLen */, `${key} length > ${argSchema.maxLen}`, key); + } + if (argSchema.pattern && !argSchema.pattern.test(value)) { + this.addError("CONSTRAINT_PATTERN" /* ConstraintPattern */, `${key} pattern mismatch`, key); + } + if (argSchema.enumValues && !argSchema.enumValues.includes(value)) { + this.addError("CONSTRAINT_ENUM" /* ConstraintEnum */, `${key} not in allowed values`, key); + } + } + } + isValidType(type, value) { + if (value === null) { + return true; + } + switch (type) { + case "string": + return typeof value === "string"; + case "int": + return typeof value === "number" && Number.isFinite(value) && Number.isInteger(value); + case "float": + case "number": + return typeof value === "number" && Number.isFinite(value); + case "bool": + case "boolean": + return typeof value === "boolean"; + case "null": + return value === null; + case "any": + return true; + } + } + validateComplete() { + if (!this.toolName) { + this.addError("MISSING_TOOL" /* MissingTool */, "No action field found"); + return; + } + const schema = this.registry.get(this.toolName); + if (!schema) { + return; + } + for (const [argName, argSchema] of Object.entries(schema.args)) { + if (argSchema.required && !hasOwn3(this.fields, argName)) { + this.addError("MISSING_REQUIRED" /* MissingRequired */, `Missing required field: ${argName}`, argName); + } + } + } + /** + * Get the current validation result. + */ + getResult() { + const toolAllowed = this.toolName ? this.registry.isAllowed(this.toolName) : null; + return { + complete: this.state === "complete" /* Complete */, + valid: this.errors.length === 0, + state: this.state, + toolName: this.toolName, + toolAllowed, + errors: [...this.errors], + fields: cloneFieldRecord(this.fields), + tokenCount: this.tokenCount, + charCount: this.charCount, + timeline: [...this.timeline], + toolDetectedAtToken: this.toolDetectedAtToken, + toolDetectedAtChar: this.toolDetectedAtChar, + toolDetectedAtTime: this.toolDetectedAtTime, + firstErrorAtToken: this.firstErrorAtToken, + firstErrorAtTime: this.firstErrorAtTime, + completeAtToken: this.completeAtToken, + completeAtTime: this.completeAtTime + }; + } + /** + * Check if the stream should be cancelled. + */ + shouldStop() { + return this.errors.some((e) => e.code === "UNKNOWN_TOOL" /* UnknownTool */ || e.code === "LIMIT_EXCEEDED" /* LimitExceeded */); + } + /** + * Check if the detected tool is allowed. + * Returns false if no tool detected or registry not configured. + */ + isToolAllowed() { + if (!this.toolName) { + return false; + } + return this.registry.isAllowed(this.toolName); + } + /** + * Get the parsed fields if validation is complete and valid. + * Returns null if not complete or if there are errors. + */ + getParsed() { + if (this.state === "complete" /* Complete */ && this.errors.length === 0) { + return cloneFieldRecord(this.fields); + } + return null; + } + }; + function defaultToolRegistry() { + const registry = new ToolRegistry(); + registry.register({ + name: "search", + description: "Search for information", + args: { + query: { type: "string", required: true, minLen: 1 }, + max_results: { type: "int", min: 1, max: 100 } + } + }); + registry.register({ + name: "calculate", + description: "Evaluate a mathematical expression", + args: { + expression: { type: "string", required: true }, + precision: { type: "int", min: 0, max: 15 } + } + }); + registry.register({ + name: "browse", + description: "Fetch a web page", + args: { + url: { type: "string", required: true, pattern: /^https?:\/\// } + } + }); + registry.register({ + name: "execute", + description: "Execute a shell command", + args: { + command: { type: "string", required: true } + } + }); + registry.register({ + name: "read_file", + description: "Read a file from disk", + args: { + path: { type: "string", required: true }, + limit: { type: "int", min: 1 } + } + }); + registry.register({ + name: "write_file", + description: "Write content to a file", + args: { + path: { type: "string", required: true }, + content: { type: "string", required: true } + } + }); + return registry; + } + + // src/index.ts + function jsonToPacked(json, schema, options = {}) { + const gv = fromJson(json, { ...options, schema }); + return emitPacked(gv, schema); + } + function jsonToTabular(json, schema, options = {}) { + const gv = fromJson(json, { ...options, schema }); + return emitTabular(gv, schema); + } + function jsonToLyph(json, schema, options = {}) { + const gv = fromJson(json, { ...options, schema }); + return emitV2(gv, schema, options); + } + function estimateTokens(s) { + return s.split(/\s+/).filter(Boolean).length; + } + function compareTokens(json, schema, options = {}) { + const jsonStr = JSON.stringify(json); + const lyphStr = jsonToLyph(json, schema, options); + const jsonTokens = estimateTokens(jsonStr); + const lyphTokens = estimateTokens(lyphStr); + const savings = jsonTokens - lyphTokens; + const savingsPercent = jsonTokens > 0 ? savings / jsonTokens * 100 : 0; + return { json: jsonTokens, lyph: lyphTokens, savings, savingsPercent }; + } + return __toCommonJS(index_exports); +})(); diff --git a/go/README.md b/go/README.md index 9cbee5b..3977c34 100644 --- a/go/README.md +++ b/go/README.md @@ -2,19 +2,31 @@ Go implementation of the GLYPH codec and GS1 stream tooling. Together with Python and JavaScript, Go is one of the three conformance-surface implementations; Rust and C ports are parked in `attic/`. -## Install +## Status: in-repo / source preview + +The Go codec is a full conformance implementation, but it is **not yet a polished +external module**, so `go get github.com/Neumenon/glyph` is not a stable install +path today. Two things block a clean `go get` / `go mod tidy`: + +- the module lives in the `go/` subdirectory of this repo (its module path is + `github.com/Neumenon/glyph`, which does not match the repo-root layout `go get` + expects), and +- the optional dev-only `cogs` bridge pulls an unpublished `cowrie/go/v2` + dependency, so external `go get` / `go mod tidy` fail resolving it. A plain + `go build` of the codec still works via module-graph pruning — see + [Internal: `cogs` cowrie bridge](#internal-cogs-cowrie-bridge-not-part-of-the-release-surface) + and the caveat in `go.mod`. + +Until external module packaging is stabilized, use the codec from a checkout of +this repo: ```bash -go get github.com/Neumenon/glyph +git clone https://github.com/Neumenon/glyph +cd glyph/go +go build ./... ``` -> **Note:** until the optional `cowrie` bridge dependency is published, `go get` / -> `go mod tidy` may fail resolving `cowrie/go/v2` (a plain `go build` of the codec -> works via module-graph pruning). The bridge is dev-only — see -> [Internal: `cogs` cowrie bridge](#internal-cogs-cowrie-bridge-not-part-of-the-release-surface) -> and the caveat in `go.mod`. - -Import the codec package as: +Within the module, import the codec package as: ```go import glyph "github.com/Neumenon/glyph/glyph" diff --git a/go/cmd/gauntletrunner/main.go b/go/cmd/gauntletrunner/main.go new file mode 100644 index 0000000..bf9cdff --- /dev/null +++ b/go/cmd/gauntletrunner/main.go @@ -0,0 +1,354 @@ +// Command gauntletrunner is the Go scenario runner for the GLYPH cross-language +// gauntlet. It reads the shared inputs.json, runs every scenario applicable to +// the Go implementation, and prints a single JSON evidence object to stdout. +// +// It does NOT decide pass/fail — the orchestrator (gauntlet/scenarios/gauntlet.py) +// is the single evaluator, applied identically to every language. +// +// It lives inside the Go module (rather than under gauntlet/) so the local +// github.com/Neumenon/glyph/{glyph,stream} packages resolve without a separate +// module + replace directive. +// +// Applicable scenarios: S1, S2, S3, S4, S5, S6, S7. +// (S8 / streaming firewall is Py+JS in this gauntlet.) +// +// Usage: go run ./cmd/gauntletrunner +package main + +import ( + "bytes" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "runtime" + + "github.com/Neumenon/glyph/glyph" + "github.com/Neumenon/glyph/stream" +) + +type result struct { + OK bool `json:"ok"` + Evidence interface{} `json:"evidence,omitempty"` + Error string `json:"error,omitempty"` +} + +func run(fn func() (interface{}, error)) (res result) { + defer func() { + if r := recover(); r != nil { + res = result{OK: false, Error: fmt.Sprintf("panic: %v", r)} + } + }() + ev, err := fn() + if err != nil { + return result{OK: false, Error: err.Error()} + } + return result{OK: true, Evidence: ev} +} + +func toJSONValue(gv *glyph.GValue) interface{} { + jb, err := glyph.ToJSONLoose(gv) + if err != nil { + panic(err) + } + var v interface{} + if err := json.Unmarshal(jb, &v); err != nil { + panic(err) + } + return v +} + +func compactLen(raw json.RawMessage) int { + var buf bytes.Buffer + if err := json.Compact(&buf, raw); err != nil { + panic(err) + } + return buf.Len() +} + +func main() { + path := "inputs.json" + if len(os.Args) > 1 { + path = os.Args[1] + } + data, err := os.ReadFile(path) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + var root map[string]json.RawMessage + if err := json.Unmarshal(data, &root); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + scen := map[string]result{} + + // ── S1 ──────────────────────────────────────────────────────────────── + scen["S1"] = run(func() (interface{}, error) { + var d struct { + Snapshot json.RawMessage `json:"snapshot"` + } + json.Unmarshal(root["S1_json_bridge"], &d) + gv, err := glyph.FromJSONLoose(d.Snapshot) + if err != nil { + return nil, err + } + jb, err := glyph.ToJSONLoose(gv) + if err != nil { + return nil, err + } + eq, _ := glyph.JSONEqual(d.Snapshot, jb) + var rt interface{} + json.Unmarshal(jb, &rt) + return map[string]interface{}{"roundtrip": rt, "equals_input": eq}, nil + }) + + // ── S2 ──────────────────────────────────────────────────────────────── + scen["S2"] = run(func() (interface{}, error) { + var d struct { + Variants []json.RawMessage `json:"variants"` + FloatsText []string `json:"floats_text"` + } + json.Unmarshal(root["S2_canonical"], &d) + canons := make([]string, len(d.Variants)) + for i, v := range d.Variants { + gv, err := glyph.FromJSONLoose(v) + if err != nil { + return nil, err + } + canons[i] = glyph.CanonicalizeLoose(gv) + } + consistent := true + for _, c := range canons { + if c != canons[0] { + consistent = false + } + } + floats := map[string]string{} + for _, t := range d.FloatsText { + gv, err := glyph.ParseDocument(t) + if err != nil { + return nil, fmt.Errorf("parse float %q: %w", t, err) + } + floats[t] = glyph.CanonicalizeLoose(gv) + } + return map[string]interface{}{ + "canonical": canons[0], + "variants_consistent": consistent, + "floats": floats, + }, nil + }) + + // ── S3 ──────────────────────────────────────────────────────────────── + scen["S3"] = run(func() (interface{}, error) { + var d struct { + Base json.RawMessage `json:"base"` + Equiv json.RawMessage `json:"equiv"` + Mutated json.RawMessage `json:"mutated"` + } + json.Unmarshal(root["S3_fingerprint"], &d) + fp := func(raw json.RawMessage) (string, error) { + gv, err := glyph.FromJSONLoose(raw) + if err != nil { + return "", err + } + return glyph.FingerprintLoose(gv), nil + } + fb, err := fp(d.Base) + if err != nil { + return nil, err + } + fe, err := fp(d.Equiv) + if err != nil { + return nil, err + } + fm, err := fp(d.Mutated) + if err != nil { + return nil, err + } + return map[string]interface{}{"fp_base": fb, "fp_equiv": fe, "fp_mutated": fm}, nil + }) + + // ── S4 ──────────────────────────────────────────────────────────────── + scen["S4"] = run(func() (interface{}, error) { + var d struct { + Trace json.RawMessage `json:"trace"` + } + json.Unmarshal(root["S4_tabular"], &d) + gv, err := glyph.FromJSONLoose(d.Trace) + if err != nil { + return nil, err + } + tab := glyph.CanonicalizeLoose(gv) + lst := glyph.CanonicalizeLooseNoTabular(gv) + recovered, err := glyph.ParseDocument(tab) + if err != nil { + return nil, fmt.Errorf("parse tabular: %w", err) + } + return map[string]interface{}{ + "is_tabular": bytes.Contains([]byte(tab), []byte("@tab")), + "canonical_tab": tab, + "bytes_json": compactLen(d.Trace), + "bytes_list": len(lst), + "bytes_tab": len(tab), + "roundtrip_ok": glyph.EqualLoose(gv, recovered), + "fp_recovered": glyph.FingerprintLoose(recovered), + }, nil + }) + + // ── S5 ──────────────────────────────────────────────────────────────── + scen["S5"] = run(func() (interface{}, error) { + var d struct { + Base json.RawMessage `json:"base"` + PatchText string `json:"patch_text"` + } + json.Unmarshal(root["S5_patch_apply"], &d) + base, err := glyph.FromJSONLoose(d.Base) + if err != nil { + return nil, err + } + before, _ := glyph.ToJSONLoose(base) + p, err := glyph.ParsePatch(d.PatchText, nil) + if err != nil { + return nil, fmt.Errorf("parse patch: %w", err) + } + out, err := glyph.ApplyPatch(base, p) + if err != nil { + return nil, fmt.Errorf("apply patch: %w", err) + } + after, _ := glyph.ToJSONLoose(base) + unchanged, _ := glyph.JSONEqual(before, after) + return map[string]interface{}{ + "result": toJSONValue(out), + "fp_result": glyph.FingerprintLoose(out), + "base_unchanged": unchanged, + }, nil + }) + + // ── S6 ──────────────────────────────────────────────────────────────── + scen["S6"] = run(func() (interface{}, error) { + var d struct { + State json.RawMessage `json:"state"` + PatchOpLines []string `json:"patch_op_lines"` + Target string `json:"target"` + StaleBase string `json:"stale_base"` + } + json.Unmarshal(root["S6_patch_base"], &d) + state, err := glyph.FromJSONLoose(d.State) + if err != nil { + return nil, err + } + base16 := glyph.NewPatchBuilder(glyph.RefID{}).WithBaseValue(state).Build().BaseFingerprint + ops := "" + for _, l := range d.PatchOpLines { + ops += l + "\n" + } + happy, err := glyph.ParsePatch(fmt.Sprintf("@patch @base=%s @target=%s\n%s@end", base16, d.Target, ops), nil) + if err != nil { + return nil, err + } + stale, err := glyph.ParsePatch(fmt.Sprintf("@patch @base=%s @target=%s\n%s@end", d.StaleBase, d.Target, ops), nil) + if err != nil { + return nil, err + } + return map[string]interface{}{ + "base16": base16, + "verify_accept": glyph.VerifyPatchBase(state, happy) == nil, + "verify_reject": glyph.VerifyPatchBase(state, stale) != nil, + }, nil + }) + + // ── S7 ──────────────────────────────────────────────────────────────── + scen["S7"] = run(func() (interface{}, error) { + var d struct { + SID uint64 `json:"sid"` + Frames []struct { + Kind string `json:"kind"` + Seq uint64 `json:"seq"` + Payload string `json:"payload"` + Final bool `json:"final"` + } `json:"frames"` + BaseState json.RawMessage `json:"base_state"` + BasePatchPayload string `json:"base_patch_payload"` + } + json.Unmarshal(root["S7_gs1_stream"], &d) + + var buf bytes.Buffer + w := stream.NewWriter(&buf) + for _, f := range d.Frames { + kind, ok := stream.ParseKind(f.Kind) + if !ok { + return nil, fmt.Errorf("bad kind %q", f.Kind) + } + if err := w.WriteFrame(&stream.Frame{ + Version: 1, SID: d.SID, Seq: f.Seq, Kind: kind, + Payload: []byte(f.Payload), Final: f.Final, + }); err != nil { + return nil, err + } + } + streamBytes := buf.Bytes() + sum := sha256.Sum256(streamBytes) + + r := stream.NewReader(bytes.NewReader(streamBytes)) + frames, err := r.ReadAll() + if err != nil { + return nil, fmt.Errorf("decode: %w", err) + } + kinds := make([]string, len(frames)) + seqs := make([]int, len(frames)) + payloadsOK := len(frames) == len(d.Frames) + for i, fr := range frames { + kinds[i] = fr.Kind.String() + seqs[i] = int(fr.Seq) + if payloadsOK && string(fr.Payload) != d.Frames[i].Payload { + payloadsOK = false + } + } + + st, err := glyph.FromJSONLoose(d.BaseState) + if err != nil { + return nil, err + } + sc := stream.NewStreamCursor() + sc.SetState(d.SID, st) + correct := stream.StateHashLoose(st) + acceptErr := sc.ProcessFrame(&stream.Frame{ + Version: 1, SID: d.SID, Seq: 1, Kind: stream.KindPatch, + Payload: []byte(d.BasePatchPayload), Base: &correct, + }) + var wrong [32]byte + wrong[0] = 0xde + rejectErr := sc.ProcessFrame(&stream.Frame{ + Version: 1, SID: d.SID, Seq: 2, Kind: stream.KindPatch, + Payload: []byte(d.BasePatchPayload), Base: &wrong, + }) + + return map[string]interface{}{ + "stream_sha256": hex.EncodeToString(sum[:]), + "stream_b64": base64.StdEncoding.EncodeToString(streamBytes), + "frame_count": len(frames), + "kinds": kinds, + "seqs": seqs, + "payloads_ok": payloadsOK, + "statehash_hex": stream.HashToHex(correct), + "base_accept": acceptErr == nil, + "base_reject": rejectErr != nil, + }, nil + }) + + out := map[string]interface{}{ + "lang": "go", + "version": runtime.Version(), + "scenarios": scen, + } + enc := json.NewEncoder(os.Stdout) + enc.SetEscapeHTML(false) + if err := enc.Encode(out); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/go/glyph/glyph_gauntlet_test.go b/go/glyph/glyph_gauntlet_test.go new file mode 100644 index 0000000..6042bec --- /dev/null +++ b/go/glyph/glyph_gauntlet_test.go @@ -0,0 +1,781 @@ +package glyph + +// glyph_gauntlet_test.go — correctness gauntlet for the Go GLYPH codec. +// +// Each test is named Test_Gauntlet_* and encodes WHY the behaviour matters, +// not just what it does. Run with: +// +// cd /home/omen/Documents/Project/cogs/glyph/go && go test ./glyph/ -run Gauntlet -count=1 -v + +import ( + "fmt" + "math" + "strings" + "testing" + "time" +) + +// ============================================================ +// Test_Gauntlet_MuseumOfEdgeCases +// ============================================================ +// +// Encodes all "evil" input cases from the gauntlet data set. +// Each case verifies: +// 1. CanonicalizeLoose(v) does not panic or error. +// 2. ParseDocument(CanonicalizeLoose(v)) round-trips to EqualLoose. +// 3. Idempotency: CanonicalizeLoose(parse(emit)) == emit. + +func Test_Gauntlet_MuseumOfEdgeCases(t *testing.T) { + opts := NoTabularLooseCanonOpts() // no-tabular for deterministic idempotency check + + cases := []struct { + name string + value *GValue + note string + }{ + { + name: "empty-string", + value: Str(""), + note: "empty string must survive round-trip; must not be mistaken for null", + }, + { + name: "unicode-multibyte", + value: Str("日本語🎉"), + note: "multi-byte UTF-8 must survive round-trip without corruption", + }, + { + name: "string-with-quotes", + value: Str(`say "hello" now`), + note: "embedded double-quotes must be escaped in canonical form", + }, + { + name: "string-with-pipe", + value: Str("a|b|c"), + note: "pipe chars are tabular delimiters; strings containing them must quote", + }, + { + name: "string-with-newlines", + value: Str("line1\nline2\r\nline3"), + note: "newlines inside strings must be escaped (\\n, \\r\\n)", + }, + { + name: "null", + value: Null(), + note: "null must canonicalize to _ and round-trip correctly", + }, + { + name: "bool-true", + value: Bool(true), + note: "true must canonicalize to t", + }, + { + name: "bool-false", + value: Bool(false), + note: "false must canonicalize to f", + }, + { + name: "big-int", + value: Int(9007199254740993), // MAX_SAFE_INTEGER+1 + note: "Go must handle int64 above JS MAX_SAFE_INTEGER (JS is lossy here — documented gap)", + }, + { + name: "float-scientific", + value: Float(1.23e100), + note: "large float must survive round-trip in scientific notation", + }, + { + name: "neg-zero-float", + value: Float(math.Copysign(0, -1)), // -0.0 + note: "negative zero must canonicalize to 0.0 (not -0.0)", + }, + { + name: "date-string", + value: Str("2024-01-15"), + note: "a date-like string must round-trip as a string, not coerced to time", + }, + { + name: "nested-list", + value: List(List(Int(1), Int(2)), List(Int(3), Int(4))), + note: "nested lists must survive round-trip", + }, + { + name: "nested-map", + value: Map( + FieldVal("outer", Map( + FieldVal("inner", Str("value")), + )), + ), + note: "deeply nested maps must survive round-trip", + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + // Step 1: canonicalize + emitted := CanonicalizeLooseWithOpts(tc.value, opts) + if emitted == "" && tc.value != nil && tc.value.typ != TypeStr { + t.Errorf("[%s] CanonicalizeLoose produced empty string for non-empty value; note: %s", tc.name, tc.note) + return + } + + // Step 2: parse back + parsed, err := ParseDocument(emitted) + if err != nil { + t.Errorf("[%s] ParseDocument(%q) error: %v; note: %s", tc.name, emitted, err, tc.note) + return + } + + // Step 3: semantic equality + if !EqualLoose(tc.value, parsed) { + reEmitted := CanonicalizeLooseWithOpts(parsed, opts) + t.Errorf("[%s] round-trip value mismatch\n original emit: %q\n re-emit: %q\n note: %s", + tc.name, emitted, reEmitted, tc.note) + return + } + + // Step 4: idempotency — emit of parse must equal original emit + reEmitted := CanonicalizeLooseWithOpts(parsed, opts) + if reEmitted != emitted { + t.Errorf("[%s] idempotency failure: emit != emit(parse(emit))\n emit: %q\n emit(parse): %q\n note: %s", + tc.name, emitted, reEmitted, tc.note) + } + }) + } +} + +// ============================================================ +// Test_Gauntlet_NegZeroCanonicalisation +// ============================================================ +// +// -0.0 must canonicalize to "0.0" (not "-0.0"). +// This is a cross-language parity requirement. + +func Test_Gauntlet_NegZeroCanonicalisation(t *testing.T) { + negZero := Float(math.Copysign(0, -1)) + posZero := Float(0) + + negText := CanonicalizeLooseNoTabular(negZero) + posText := CanonicalizeLooseNoTabular(posZero) + + if negText != "0.0" { + t.Errorf("neg-zero must canonicalize to '0.0', got %q", negText) + } + if posText != "0.0" { + t.Errorf("pos-zero must canonicalize to '0.0', got %q", posText) + } + if negText != posText { + t.Errorf("-0.0 and 0.0 must produce identical canonical text; got %q vs %q", negText, posText) + } +} + +// ============================================================ +// Test_Gauntlet_TypeZoo +// ============================================================ +// +// Every supported GType passes through the loose emit/parse cycle +// and the JSON bridge (FromJSONLoose / ToJSONLoose). +// +// WHY: a new type added to the codec should immediately fail here if +// the bridge or canonical path is not wired up. + +func Test_Gauntlet_TypeZoo(t *testing.T) { + now := time.Date(2025, 6, 21, 12, 0, 0, 0, time.UTC) + opts := NoTabularLooseCanonOpts() + + cases := []struct { + name string + value *GValue + }{ + {"null", Null()}, + {"bool-true", Bool(true)}, + {"bool-false", Bool(false)}, + {"int-zero", Int(0)}, + {"int-pos", Int(42)}, + {"int-neg", Int(-1)}, + {"int-max", Int(math.MaxInt64)}, + {"int-min", Int(math.MinInt64)}, + {"float-simple", Float(1.5)}, + {"float-neg", Float(-2.5)}, + {"float-sci", Float(1.23e10)}, + {"float-neg-zero", Float(math.Copysign(0, -1))}, + {"str-empty", Str("")}, + {"str-bare", Str("hello")}, + {"str-needs-quote", Str("hello world")}, + {"str-unicode", Str("café")}, + {"str-newline", Str("a\nb")}, + {"bytes-empty", Bytes([]byte{})}, + {"bytes-simple", Bytes([]byte("hello"))}, + {"bytes-binary", Bytes([]byte{0, 1, 255})}, + {"time-utc", Time(now)}, + {"id-prefixed", ID("m", "123")}, + {"id-bare", ID("", "plain")}, + {"list-empty", List()}, + {"list-scalars", List(Int(1), Str("two"), Bool(true))}, + {"map-empty", Map()}, + {"map-mixed", Map(FieldVal("a", Int(1)), FieldVal("b", Str("x")))}, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + // Loose text round-trip + emitted := CanonicalizeLooseWithOpts(tc.value, opts) + parsed, err := ParseDocument(emitted) + if err != nil { + t.Errorf("ParseDocument(%q) error: %v", emitted, err) + return + } + if !EqualLoose(tc.value, parsed) { + t.Errorf("loose round-trip mismatch: emitted %q, re-parsed not equal", emitted) + } + + // Typed emit/parse round-trip (for types the typed codec handles) + typedEmitted := Emit(tc.value) + typedResult, pErr := Parse(typedEmitted) + if pErr != nil { + t.Errorf("Parse(Emit(%q)) error: %v", tc.name, pErr) + return + } + if typedResult.HasErrors() { + t.Errorf("Parse(Emit(%q)) parse errors: %v", tc.name, typedResult.Errors) + } + }) + } +} + +// ============================================================ +// Test_Gauntlet_JSONBridgeSemanticRoundTrip +// ============================================================ +// +// FromJSONLoose(json bytes) -> GValue -> ToJSONLoose -> json must +// re-parse to semantically equal GValue. +// +// WHY: the JSON bridge is the entry point for LLM-produced JSON data. +// Semantic equality (not byte equality) is the contract — map key order +// is not guaranteed by JSON. + +func Test_Gauntlet_JSONBridgeSemanticRoundTrip(t *testing.T) { + cases := []struct { + name string + json string + }{ + {"null", `null`}, + {"bool-true", `true`}, + {"bool-false", `false`}, + {"int", `42`}, + {"neg-int", `-7`}, + {"float", `3.14`}, + {"string", `"hello"`}, + {"empty-string", `""`}, + {"list", `[1, 2, 3]`}, + {"empty-list", `[]`}, + {"map", `{"a": 1, "b": "two"}`}, + {"empty-map", `{}`}, + {"nested", `{"x": {"y": [1, 2]}}`}, + {"string-unicode", `"日本語"`}, + {"string-with-quotes", `"say \"hi\""`}, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + gv, err := FromJSONLoose([]byte(tc.json)) + if err != nil { + t.Fatalf("FromJSONLoose(%q) error: %v", tc.json, err) + } + + // Back to JSON bytes + jsonBytes, err := ToJSONLoose(gv) + if err != nil { + t.Fatalf("ToJSONLoose error: %v", err) + } + + // Re-parse the emitted JSON + gv2, err := FromJSONLoose(jsonBytes) + if err != nil { + t.Fatalf("FromJSONLoose(re-encoded) error: %v", err) + } + + // Semantic equality via loose canonicalization (normalizes map order) + opts := NoTabularLooseCanonOpts() + c1 := CanonicalizeLooseWithOpts(gv, opts) + c2 := CanonicalizeLooseWithOpts(gv2, opts) + if c1 != c2 { + t.Errorf("JSON semantic round-trip mismatch:\n c1: %q\n c2: %q", c1, c2) + } + }) + } +} + +// ============================================================ +// Test_Gauntlet_SchemaHashTrap +// ============================================================ +// +// Two schemas with identical field names and types but SWAPPED FIDs +// must produce DIFFERENT schema hashes. +// +// WHY (from spec parseNotes): "FIDs are schema-only (Go concern)". +// If the hash only covered names/types (not FIDs), a receiver could +// silently accept a packed payload that maps FID→field in the wrong +// order, producing a wrong but not-errored decode. +// +// This test asserts the CORRECT invariant. If the current code hashes +// them the same, this test will FAIL LOUD — document it as a real bug. + +func Test_Gauntlet_SchemaHashTrap(t *testing.T) { + // Schema A: x→FID 1, y→FID 2 + schemaA := NewSchemaBuilder().AddPackedStruct("Rec", "v1", + Field("x", PrimitiveType("int"), WithFID(1)), + Field("y", PrimitiveType("int"), WithFID(2)), + ).Build() + + // Schema B: x→FID 2, y→FID 1 (FIDs swapped — different wire layout) + schemaB := NewSchemaBuilder().AddPackedStruct("Rec", "v1", + Field("x", PrimitiveType("int"), WithFID(2)), + Field("y", PrimitiveType("int"), WithFID(1)), + ).Build() + + if schemaA.Hash == schemaB.Hash { + t.Fatalf( + "REAL BUG: schemas with swapped FIDs must produce different hashes "+ + "(both hash to %q). A decoder that accepts packed data using the wrong "+ + "schema would silently swap x and y.", + schemaA.Hash, + ) + } + + // Complement: same FIDs, different declaration order → same hash. + // (Declaration order is irrelevant; packed layout is determined by FID.) + schemaC := NewSchemaBuilder().AddPackedStruct("Rec", "v1", + Field("y", PrimitiveType("int"), WithFID(2)), // declared y first + Field("x", PrimitiveType("int"), WithFID(1)), // then x + ).Build() + + if schemaA.Hash != schemaC.Hash { + t.Errorf( + "schemas with same FIDs but different declaration order must hash identically "+ + "(A=%q, C=%q)", + schemaA.Hash, schemaC.Hash, + ) + } +} + +// ============================================================ +// Test_Gauntlet_ChunkInvariance +// ============================================================ +// +// The incremental parser must produce identical events regardless +// of how input is chunked: one big chunk, byte-by-byte, or irregular. +// +// WHY: streaming parsers that are sensitive to chunk boundaries produce +// subtle bugs in real-time LLM output consumers where chunk sizes are +// unpredictable. + +type eventRecord struct { + typ ParseEventType + key string + value string // CanonicalizeLoose of the value when typ==EventValue + tag string // for EventStartSum +} + +func collectEvents(t *testing.T, input string, chunks [][]byte) []eventRecord { + t.Helper() + + var events []eventRecord + opts := NoTabularLooseCanonOpts() + + handler := func(ev ParseEvent) error { + switch ev.Type { + case EventValue: + canon := "" + if ev.Value != nil { + canon = CanonicalizeLooseWithOpts(ev.Value, opts) + } + events = append(events, eventRecord{typ: ev.Type, value: canon}) + case EventKey: + events = append(events, eventRecord{typ: ev.Type, key: ev.Key}) + case EventStartObject, EventEndObject, EventStartList, EventEndList: + events = append(events, eventRecord{typ: ev.Type}) + case EventStartSum: + events = append(events, eventRecord{typ: ev.Type, tag: ev.Tag}) + case EventEndSum: + events = append(events, eventRecord{typ: ev.Type}) + case EventError: + t.Logf("parse error event: %v", ev.Error) + } + return nil + } + + p := NewIncrementalParser(handler, DefaultIncrementalParserOptions()) + for _, chunk := range chunks { + if _, err := p.Feed(chunk); err != nil { + t.Fatalf("Feed error: %v", err) + } + } + if err := p.End(); err != nil { + t.Fatalf("End error: %v", err) + } + + return events +} + +func chunkBytewise(s string) [][]byte { + chunks := make([][]byte, len(s)) + for i := range s { + chunks[i] = []byte{s[i]} + } + return chunks +} + +func chunkIrregular(s string) [][]byte { + // Fixed irregular splits: 3, 7, 2, rest + data := []byte(s) + var chunks [][]byte + sizes := []int{3, 7, 2} + i := 0 + for _, sz := range sizes { + if i >= len(data) { + break + } + end := i + sz + if end > len(data) { + end = len(data) + } + chunks = append(chunks, data[i:end]) + i = end + } + if i < len(data) { + chunks = append(chunks, data[i:]) + } + return chunks +} + +func Test_Gauntlet_ChunkInvariance(t *testing.T) { + inputs := []struct { + name string + input string + }{ + {"scalar-int", "42"}, + {"scalar-string", `"hello world"`}, + {"simple-map", `{a=1 b=2}`}, + {"nested-map", `{x={y=42} z=true}`}, + {"list", `[1 2 3]`}, + {"mixed", `{name="Alice" scores=[10 20 30] active=t}`}, + } + + for _, tc := range inputs { + tc := tc + t.Run(tc.name, func(t *testing.T) { + // One-shot + oneShot := collectEvents(t, tc.input, [][]byte{[]byte(tc.input)}) + + // Byte-by-byte + bytewise := collectEvents(t, tc.input, chunkBytewise(tc.input)) + + // Irregular splits + irregular := collectEvents(t, tc.input, chunkIrregular(tc.input)) + + compareEvents := func(label string, got []eventRecord) { + if len(got) != len(oneShot) { + t.Errorf("[%s] %s: event count mismatch: oneshot=%d got=%d", + tc.name, label, len(oneShot), len(got)) + return + } + for i := range oneShot { + if got[i] != oneShot[i] { + t.Errorf("[%s] %s: event[%d] mismatch:\n oneshot: %+v\n got: %+v", + tc.name, label, i, oneShot[i], got[i]) + } + } + } + + compareEvents("bytewise", bytewise) + compareEvents("irregular", irregular) + }) + } +} + +// ============================================================ +// Test_Gauntlet_PatchApply +// ============================================================ +// +// Patch(base, next) applied to base must produce a value EqualLoose to next. +// +// WHY: the patch mechanism is the core of the streaming match-update +// use case. If Diff(a,b) |> ApplyPatch does not recover b, the patch +// protocol is broken. + +func Test_Gauntlet_PatchApply(t *testing.T) { + cases := []struct { + name string + base *GValue + next *GValue + }{ + { + name: "set-single-field", + base: Map(FieldVal("score", Int(0)), FieldVal("minute", Int(0))), + next: Map(FieldVal("score", Int(1)), FieldVal("minute", Int(45))), + }, + { + name: "add-field", + base: Map(FieldVal("a", Int(1))), + next: Map(FieldVal("a", Int(1)), FieldVal("b", Int(2))), + }, + { + name: "change-string", + base: Map(FieldVal("status", Str("pending"))), + next: Map(FieldVal("status", Str("done"))), + }, + { + name: "nested-field", + base: Map(FieldVal("home", Map(FieldVal("score", Int(0))))), + next: Map(FieldVal("home", Map(FieldVal("score", Int(2))))), + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + patch := Diff(tc.base, tc.next, "") + if patch == nil { + t.Fatalf("Diff returned nil patch") + } + + applied, err := ApplyPatch(tc.base, patch) + if err != nil { + t.Fatalf("ApplyPatch error: %v", err) + } + + if !EqualLoose(applied, tc.next) { + opts := NoTabularLooseCanonOpts() + t.Errorf("ApplyPatch(base, Diff(base,next)) != next\n next: %s\n applied: %s", + CanonicalizeLooseWithOpts(tc.next, opts), + CanonicalizeLooseWithOpts(applied, opts), + ) + } + }) + } +} + +// ============================================================ +// Test_Gauntlet_TabularAutoTrigger +// ============================================================ +// +// CanonicalizeLoose on a list of 3+ homogeneous objects must emit a +// @tab _ block, not a plain list. Verify the savings and the header +// format match the spec. +// +// WHY: auto-tabular is the primary compression mechanism (35-65% +// savings). If it silently stops triggering, downstream token counts +// will blow up without any error. + +func Test_Gauntlet_TabularAutoTrigger(t *testing.T) { + // Build 10 match rows (homogeneous objects) + rows := make([]*GValue, 10) + for i := 0; i < 10; i++ { + rows[i] = Map( + FieldVal("minute", Int(int64(i*9))), + FieldVal("score_home", Int(int64(i%3))), + FieldVal("score_away", Int(int64(i%2))), + ) + } + v := List(rows...) + + defaultOpts := DefaultLooseCanonOpts() + result := CanonicalizeLooseWithOpts(v, defaultOpts) + + if !strings.HasPrefix(result, "@tab _") { + t.Errorf("expected auto-tabular @tab _ block, got: %q", result[:min(100, len(result))]) + } + + if !strings.HasSuffix(result, "@end") { + t.Errorf("tabular block must end with @end, got: %q", result[max(0, len(result)-20):]) + } + + // Tabular form must be smaller than flat JSON list + flatOpts := NoTabularLooseCanonOpts() + flatResult := CanonicalizeLooseWithOpts(v, flatOpts) + if len(result) >= len(flatResult) { + t.Errorf("tabular (%d bytes) must be smaller than flat (%d bytes)", len(result), len(flatResult)) + } + + // Verify round-trip through ParseTabularLoose + parsed, err := ParseTabularLoose(result) + if err != nil { + t.Fatalf("ParseTabularLoose error: %v", err) + } + if parsed == nil || parsed.typ != TypeList { + t.Fatalf("ParseTabularLoose must return a list, got %v", parsed) + } + if len(parsed.listVal) != 10 { + t.Errorf("expected 10 rows, got %d", len(parsed.listVal)) + } +} + +// min/max helpers (pre-Go 1.21 compat) +func min(a, b int) int { + if a < b { + return a + } + return b +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} + +// ============================================================ +// Test_Gauntlet_FirewallUnknownTool +// ============================================================ +// +// The StreamingValidator must reject an unknown tool name and +// must not allow further data through after rejection. +// +// WHY: the firewall is a safety primitive. If an unknown tool call +// slips through (e.g. "wire_transfer"), the downstream consumer may +// execute it. The codec spec says wire_transfer is NOT in the default +// registry and must be rejected. + +func Test_Gauntlet_FirewallUnknownTool(t *testing.T) { + registry := DefaultToolRegistry() + sv := NewStreamingValidator(registry) + + // Feed the tool call token by token (simulating streaming). + // Format: {action=wire_transfer amount=1000 to="attacker"} + toolCallText := `{action=wire_transfer amount=1000 to="attacker"}` + sv.PushToken(toolCallText) + + result := sv.GetResult() + + // wire_transfer is not in the default registry + if sv.IsToolAllowed() { + t.Errorf("wire_transfer must be rejected (not in default registry), but IsToolAllowed returned true") + } + // ShouldStop must return true for an unknown tool + if !sv.ShouldStop() { + t.Errorf("ShouldStop must return true for unknown tool wire_transfer; errors: %v", result.Errors) + } + // There must be an UNKNOWN_TOOL error + hasUnknownTool := false + for _, e := range result.Errors { + if e.Code == ErrCodeUnknownTool { + hasUnknownTool = true + } + } + if !hasUnknownTool { + t.Errorf("expected UNKNOWN_TOOL error; got: %v", result.Errors) + } +} + +// ============================================================ +// Test_Gauntlet_AllowedTool +// ============================================================ +// +// A known tool (search) must pass the firewall. + +func Test_Gauntlet_AllowedTool(t *testing.T) { + registry := DefaultToolRegistry() + sv := NewStreamingValidator(registry) + + toolCallText := `{action=search query="weather NYC"}` + sv.PushToken(toolCallText) + result := sv.GetResult() + _ = result + + if !sv.IsToolAllowed() { + t.Errorf("search tool must be allowed in default registry; errors: %v", result.Errors) + } + if sv.ShouldStop() { + t.Errorf("ShouldStop must be false for a valid tool call") + } +} + +// ============================================================ +// Test_Gauntlet_LooseIdempotency +// ============================================================ +// +// CanonicalizeLoose must be idempotent for all scalar types: +// emit(parse(emit(v))) == emit(v). +// +// WHY: if the canonical form is not a fixed point under +// parse-then-emit, then two passes through the codec produce +// different bytes, breaking fingerprinting and deduplication. + +func Test_Gauntlet_LooseIdempotency(t *testing.T) { + opts := NoTabularLooseCanonOpts() + + cases := []*GValue{ + Null(), + Bool(true), Bool(false), + Int(0), Int(1), Int(-1), Int(math.MaxInt64), Int(math.MinInt64), + Float(0), Float(1.5), Float(-2.25), Float(1e100), + Float(math.Copysign(0, -1)), // -0.0 + Str(""), Str("hello"), Str("with space"), Str("with\"quote"), + Str("with\nnewline"), Str("日本語"), + Bytes([]byte{}), Bytes([]byte("hello")), Bytes([]byte{0, 1, 255}), + ID("m", "123"), ID("", "plain"), + List(), List(Int(1), Int(2)), + Map(), Map(FieldVal("a", Int(1)), FieldVal("b", Str("x"))), + } + + for i, v := range cases { + v := v + name := fmt.Sprintf("case-%d", i) + t.Run(name, func(t *testing.T) { + emit1 := CanonicalizeLooseWithOpts(v, opts) + + // Parse back through ParseDocument + parsed, err := ParseDocument(emit1) + if err != nil { + t.Skipf("ParseDocument(%q) error (known limitation for some types): %v", emit1, err) + return + } + + emit2 := CanonicalizeLooseWithOpts(parsed, opts) + + if emit1 != emit2 { + t.Errorf("idempotency failure:\n emit1: %q\n emit2: %q", emit1, emit2) + } + }) + } +} + +// ============================================================ +// Test_Gauntlet_StreamingValidator (integration) +// ============================================================ +// +// Feed the same tool call text as the gauntlet data shows, +// using wire_transfer as the blocked tool and search as allowed. + +func Test_Gauntlet_StreamingValidator(t *testing.T) { + registry := DefaultToolRegistry() + + t.Run("allowed-search-tool", func(t *testing.T) { + sv := NewStreamingValidator(registry) + text := `{action=search query="weather NYC" confidence=0.9}` + sv.PushToken(text) + result := sv.GetResult() + _ = result + if !sv.IsToolAllowed() { + t.Errorf("search tool must pass; errors: %v", result.Errors) + } + }) + + t.Run("blocked-wire-transfer", func(t *testing.T) { + sv := NewStreamingValidator(registry) + text := `{action=wire_transfer amount=1000}` + sv.PushToken(text) + result := sv.GetResult() + _ = result + if sv.IsToolAllowed() { + t.Errorf("wire_transfer must be blocked but IsToolAllowed returned true") + } + if !sv.ShouldStop() { + t.Errorf("ShouldStop must be true for wire_transfer; errors: %v", result.Errors) + } + }) +} diff --git a/js/package.json b/js/package.json index cf5cec3..94167d5 100644 --- a/js/package.json +++ b/js/package.json @@ -6,12 +6,16 @@ "types": "dist/index.d.ts", "exports": { ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", "require": "./dist/index.js", - "types": "./dist/index.d.ts" + "default": "./dist/index.js" }, "./stream": { + "types": "./dist/stream/index.d.ts", + "import": "./dist/stream/index.js", "require": "./dist/stream/index.js", - "types": "./dist/stream/index.d.ts" + "default": "./dist/stream/index.js" } }, "files": [ diff --git a/js/src/gauntlet.test.ts b/js/src/gauntlet.test.ts new file mode 100644 index 0000000..fad3cc8 --- /dev/null +++ b/js/src/gauntlet.test.ts @@ -0,0 +1,732 @@ +/** + * GLYPH JS Correctness Gauntlet + * + * Mirrors the cross-language gauntlet data at + * /home/omen/Documents/Project/cogs/glyph/gauntlet/data/gauntlet-data.json + * + * LOOSE ROUND-TRIP (closed gap): JS now has a schema-free loose-text parser, + * parseLoose(glyphText) -> GValue, the inverse of canonicalizeLoose(). The loop + * is bidirectional and matches Go (ParseDocument) and Python (parse): + * JSON value <-> fromJsonLoose/toJsonLoose <-> GValue <-> canonicalizeLoose/parseLoose <-> glyph text + * + * Note: parseJsonLoose() is a separate JSON bridge (parses JSON text, not glyph + * text); parseLoose() is the glyph-text parser. parseLoose round-trips scalars, + * maps, lists, nested @tab blocks, and refs. Time values are out of loose scope + * in all three languages (JSON-domain only), as is int precision beyond 2^53 in JS. + * + * All byte counts and savings numbers come from the real codec via gauntlet-data.json. + */ + +import { + fromJsonLoose, + toJsonLoose, + canonicalizeLoose, + canonicalizeLooseNoTabular, + parseLoose, + parseJsonLoose, + parseTabularLoose, + defaultLooseCanonOpts, + StreamingValidator, + defaultToolRegistry, + ErrorCode, + emitPatch, + parsePatch, + applyPatch, + PatchBuilder, + g, + field, +} from './index'; + +// ============================================================ +// Museum of Edge Cases +// All 14 cases from gauntlet-data.json edgeCases. +// Forward path: JSON value -> fromJsonLoose() -> canonicalizeLoose() +// ============================================================ + +describe('MuseumOfEdgeCases: JSON-bridge forward path (JSON value -> GValue -> glyph text)', () => { + // Helper: JSON text -> glyph text via the real JS forward path. + function jsonToGlyph(jsonText: string): string { + const jsValue = JSON.parse(jsonText); + const gv = fromJsonLoose(jsValue); + return canonicalizeLoose(gv); + } + + test('empty_str: empty string needs quotes in glyph', () => { + expect(jsonToGlyph('""')).toBe('""'); + }); + + test('unicode: café ☕ λ', () => { + expect(jsonToGlyph('"café ☕ λ"')).toBe('"café ☕ λ"'); + }); + + test('embedded_quote: double-quotes are escaped', () => { + expect(jsonToGlyph('"say \\"hello\\""')).toBe('"say \\"hello\\""'); + }); + + test('pipe: pipe chars are quoted', () => { + expect(jsonToGlyph('"a|b|c"')).toBe('"a|b|c"'); + }); + + test('newlines: embedded newlines are escaped', () => { + expect(jsonToGlyph('"line1\\nline2\\r\\nline3"')).toBe('"line1\\nline2\\r\\nline3"'); + }); + + test('null_value: JSON null -> glyph _ (underscore, defaultLooseCanonOpts)', () => { + expect(jsonToGlyph('null')).toBe('_'); + }); + + test('bool_true: true -> t', () => { + expect(jsonToGlyph('true')).toBe('t'); + }); + + test('bool_false: false -> f', () => { + expect(jsonToGlyph('false')).toBe('f'); + }); + + test('big_int: 9007199254740992 — JS precision limit', () => { + // The gauntlet data records: glyphText "9.007199254740992e+15" + // 9007199254740992 = MAX_SAFE_INTEGER + 1. JS parses it as a float, + // so fromJsonLoose treats it as float, not int. + const result = jsonToGlyph('9007199254740992'); + // It should be a float in scientific notation, not an integer. + expect(result).toBe('9.007199254740992e+15'); + }); + + test('float_sci: 1.23e-9 -> canonical scientific notation', () => { + expect(jsonToGlyph('1.23e-9')).toBe('1.23e-09'); + }); + + test('neg_zero: JSON 0 -> 0 (neg zero from JS is normalized)', () => { + // The gauntlet data: neg_zero jsonText "0" glyphText "0" + // JSON.parse("0") is 0 which is an integer -> "0" + expect(jsonToGlyph('0')).toBe('0'); + // -0 as a float directly gets normalized to "0.0" + const negZeroGv = fromJsonLoose(-0); + // -0 passes Number.isInteger and abs <= MAX_SAFE_INTEGER, so it becomes int(0) + // canonInt(0) = "0" + expect(canonicalizeLoose(negZeroGv)).toBe('0'); + }); + + test('date_string: ISO date stays as string in loose mode (no type inference)', () => { + // Stays as a quoted string, not promoted to time type + expect(jsonToGlyph('"2024-03-15T12:00:00Z"')).toBe('"2024-03-15T12:00:00Z"'); + }); + + test('nested_list: mixed-type list', () => { + expect(jsonToGlyph('[1,2,"three",null]')).toBe('[1 2 three _]'); + }); + + test('nested_map: nested object', () => { + expect(jsonToGlyph('{"a":1,"b":{"c":2}}')).toBe('{a=1 b={c=2}}'); + }); +}); + +// ============================================================ +// Loose-text round-trip (gap CLOSED): parseLoose(canonicalizeLoose(v)) == v. +// JS now matches Go (ParseDocument) and Python (parse). The invariant we assert +// is canonical idempotence — canonicalizeLoose sorts map keys, so re-emitting a +// parsed value must reproduce the exact same text. +// ============================================================ + +describe('Loose-text round-trip — parseLoose is the inverse of canonicalizeLoose', () => { + // The strongest practical invariant: emit -> parse -> emit is a fixed point. + function assertRoundTrip(json: unknown): void { + const text = canonicalizeLoose(fromJsonLoose(json)); + const reparsed = parseLoose(text); + expect(canonicalizeLoose(reparsed)).toBe(text); + } + + test('scalars round-trip: bool, null, int, float, string', () => { + expect(parseLoose('t').asBool()).toBe(true); + expect(parseLoose('f').asBool()).toBe(false); + expect(parseLoose('_').isNull()).toBe(true); + expect(parseLoose('42').asInt()).toBe(42); + expect(parseLoose('-7').asInt()).toBe(-7); + expect(parseLoose('3.14').asFloat()).toBeCloseTo(3.14); + expect(parseLoose('"hello world"').asStr()).toBe('hello world'); + expect(parseLoose('bareword').asStr()).toBe('bareword'); + }); + + test('quoted strings with escapes round-trip (quotes, pipe, newline, unicode)', () => { + for (const s of ['', 'he said "hi"', 'a|b|c', 'l1\nl2', 'café 😈 λ']) { + const gv = parseLoose(canonicalizeLoose(fromJsonLoose(s))); + expect(gv.asStr()).toBe(s); + } + }); + + test('map round-trips (canonical key order preserved)', () => { + assertRoundTrip({ action: 'search', query: 'weather in Chicago', max_results: 5 }); + }); + + test('nested map + list round-trips', () => { + assertRoundTrip({ a: 1, b: { c: 2 }, d: [1, 2, 'three', null] }); + }); + + test('deeply nested structure round-trips', () => { + assertRoundTrip({ deep: { a: { b: { c: { d: [1, [2, [3, [4]]]] } } } } }); + }); + + test('top-level @tab block round-trips back to a list of maps', () => { + const rows = [ + { a: 1, b: 'x' }, + { a: 2, b: 'y' }, + { a: 3, b: 'z' }, + ]; + const text = canonicalizeLoose(fromJsonLoose(rows)); + expect(text).toContain('@tab _'); // confirm we exercised the tabular path + const reparsed = parseLoose(text); + expect(reparsed.type).toBe('list'); + expect(reparsed.len()).toBe(3); + expect(canonicalizeLoose(reparsed)).toBe(text); + }); + + test('@tab nested inside a map round-trips (the hard case)', () => { + // canonicalizeLoose inlines a multi-line @tab block as a map field value. + assertRoundTrip({ + rows: [ + { a: 1, b: 2 }, + { a: 3, b: 4 }, + { a: 5, b: 6 }, + ], + note: 'nested', + }); + }); + + test('empty containers round-trip', () => { + expect(canonicalizeLoose(parseLoose('{}'))).toBe('{}'); + expect(canonicalizeLoose(parseLoose('[]'))).toBe('[]'); + }); + + test('semantic JSON survives a full round-trip (modulo canonical key sort)', () => { + const original = { action: 'search', query: 'x', max_results: 5 }; + const back = toJsonLoose(parseLoose(canonicalizeLoose(fromJsonLoose(original)))); + // Same key/value set; canonicalization sorts keys, so compare as objects. + expect(back).toEqual(original); + }); + + test('parseLoose rejects trailing garbage', () => { + expect(() => parseLoose('{a=1} extra')).toThrow(/trailing garbage/); + }); + + test('parseLoose enforces a nesting depth guard (DoS protection)', () => { + const bomb = '['.repeat(200) + ']'.repeat(200); + expect(() => parseLoose(bomb)).toThrow(/maximum nesting depth/); + }); + + test('parseLoose and parseJsonLoose are distinct entry points', () => { + // parseJsonLoose is the JSON bridge: it parses JSON text, not glyph text. + expect(() => parseJsonLoose('t')).toThrow(); // glyph bool, invalid JSON + expect(parseLoose('t').asBool()).toBe(true); // glyph parser handles it + // parseJsonLoose still works for real JSON. + expect(parseJsonLoose('{"x": 42}').get('x')!.asInt()).toBe(42); + }); +}); + +// ============================================================ +// TypeZoo: fromJsonLoose + toJsonLoose round-trip +// Tests that JS values survive fromJsonLoose -> GValue -> toJsonLoose intact. +// ============================================================ + +describe('TypeZoo: fromJsonLoose -> toJsonLoose round-trip (JSON -> GValue -> JSON)', () => { + test('null round-trips', () => { + const gv = fromJsonLoose(null); + expect(gv.type).toBe('null'); + expect(toJsonLoose(gv)).toBeNull(); + }); + + test('bool true round-trips', () => { + const gv = fromJsonLoose(true); + expect(gv.type).toBe('bool'); + expect(toJsonLoose(gv)).toBe(true); + }); + + test('bool false round-trips', () => { + const gv = fromJsonLoose(false); + expect(gv.type).toBe('bool'); + expect(toJsonLoose(gv)).toBe(false); + }); + + test('integer round-trips', () => { + const gv = fromJsonLoose(42); + expect(gv.type).toBe('int'); + expect(toJsonLoose(gv)).toBe(42); + }); + + test('negative integer round-trips', () => { + const gv = fromJsonLoose(-7); + expect(gv.type).toBe('int'); + expect(toJsonLoose(gv)).toBe(-7); + }); + + test('float round-trips', () => { + const gv = fromJsonLoose(3.14); + expect(gv.type).toBe('float'); + expect(toJsonLoose(gv)).toBeCloseTo(3.14); + }); + + test('string round-trips', () => { + const gv = fromJsonLoose('hello world'); + expect(gv.type).toBe('str'); + expect(toJsonLoose(gv)).toBe('hello world'); + }); + + test('unicode string round-trips', () => { + const gv = fromJsonLoose('café ☕ λ'); + expect(gv.type).toBe('str'); + expect(toJsonLoose(gv)).toBe('café ☕ λ'); + }); + + test('array round-trips', () => { + const gv = fromJsonLoose([1, 'two', null, false]); + expect(gv.type).toBe('list'); + expect(toJsonLoose(gv)).toEqual([1, 'two', null, false]); + }); + + test('object round-trips', () => { + const obj = { a: 1, b: 'hello', c: null }; + const gv = fromJsonLoose(obj); + expect(gv.type).toBe('map'); + expect(toJsonLoose(gv)).toEqual(obj); + }); + + test('nested object round-trips', () => { + const obj = { outer: { inner: [1, 2, 3] } }; + const gv = fromJsonLoose(obj); + expect(toJsonLoose(gv)).toEqual(obj); + }); + + test('MAX_SAFE_INTEGER round-trips as int', () => { + const gv = fromJsonLoose(Number.MAX_SAFE_INTEGER); + expect(gv.type).toBe('int'); + expect(toJsonLoose(gv)).toBe(Number.MAX_SAFE_INTEGER); + }); + + test('[KNOWN GAP] MAX_SAFE_INTEGER+1 loses precision in JS (becomes float)', () => { + // 9007199254740993 is MAX_SAFE_INTEGER+1. + // JS parses it from JSON as 9007199254740992 (nearest float64). + // fromJsonLoose sees a non-safe-integer float and tags it as float. + // Go and Python handle this correctly as int64. + const bigInt = 9007199254740993; + const gv = fromJsonLoose(bigInt); + // JS cannot distinguish this from 9007199254740992 after JSON.parse. + expect(gv.type).toBe('float'); + // The glyph text will be the float representation. + const glyphText = canonicalizeLoose(gv); + expect(glyphText).toBe('9.007199254740992e+15'); + }); + + test('NaN is rejected by fromJsonLoose', () => { + expect(() => fromJsonLoose(NaN)).toThrow(); + }); + + test('Infinity is rejected by fromJsonLoose', () => { + expect(() => fromJsonLoose(Infinity)).toThrow(); + }); + + test('-Infinity is rejected by fromJsonLoose', () => { + expect(() => fromJsonLoose(-Infinity)).toThrow(); + }); +}); + +// ============================================================ +// Tabular savings sanity: glyph bytes < json bytes for repeated rows +// Uses real rows of match data (same structure as gauntlet-data.json tabular section). +// The assertion is a real inequality, not a pinned number. +// ============================================================ + +describe('Tabular savings: glyph bytes < json bytes for homogeneous repeated rows', () => { + // Build N rows of match data (same schema as gauntlet benchmark data). + function makeMatchRows(n: number): object[] { + const statuses = ['live', 'finished', 'upcoming']; + return Array.from({ length: n }, (_, i) => ({ + id: `m${i}`, + home: `Team_${(i % 10) + 1}`, + away: `Team_${(i % 10) + 11}`, + venue: `Stadium_${(i % 10) + 1}`, + minute: i * 3, + score_home: (i % 5) + 1, + score_away: i % 3, + status: statuses[i % 3], + })); + } + + test('10 rows: glyph bytes < json bytes (real codec comparison)', () => { + const rows = makeMatchRows(10); + const jsonBytes = Buffer.byteLength(JSON.stringify(rows), 'utf8'); + const gv = fromJsonLoose(rows); + const glyphText = canonicalizeLoose(gv); + const glyphBytes = Buffer.byteLength(glyphText, 'utf8'); + + // Must emit tabular format (@tab _ ...) for 10 homogeneous rows + expect(glyphText).toContain('@tab _'); + expect(glyphBytes).toBeLessThan(jsonBytes); + + // Sanity: savings should be substantial (>40%) for repeated rows + const savingsPct = (1 - glyphBytes / jsonBytes) * 100; + expect(savingsPct).toBeGreaterThan(40); + }); + + test('100 rows: glyph bytes < json bytes, ~63% savings (gauntlet headline)', () => { + const rows = makeMatchRows(100); + const jsonBytes = Buffer.byteLength(JSON.stringify(rows), 'utf8'); + const gv = fromJsonLoose(rows); + const glyphText = canonicalizeLoose(gv); + const glyphBytes = Buffer.byteLength(glyphText, 'utf8'); + + expect(glyphText).toContain('@tab _'); + expect(glyphBytes).toBeLessThan(jsonBytes); + + // Gauntlet data records 63.38% savings for 100 rows. + // We allow ±5% tolerance for the live codec to avoid brittleness. + const savingsPct = (1 - glyphBytes / jsonBytes) * 100; + expect(savingsPct).toBeGreaterThan(55); + expect(savingsPct).toBeLessThan(75); + }); + + test('1000 rows: glyph bytes < json bytes (scales correctly)', () => { + const rows = makeMatchRows(1000); + const jsonBytes = Buffer.byteLength(JSON.stringify(rows), 'utf8'); + const gv = fromJsonLoose(rows); + const glyphText = canonicalizeLoose(gv); + const glyphBytes = Buffer.byteLength(glyphText, 'utf8'); + + expect(glyphText).toContain('@tab _'); + expect(glyphBytes).toBeLessThan(jsonBytes); + const savingsPct = (1 - glyphBytes / jsonBytes) * 100; + expect(savingsPct).toBeGreaterThan(55); + }); + + test('fewer than minRows (2 rows): no tabular format emitted', () => { + const rows = makeMatchRows(2); + const gv = fromJsonLoose(rows); + const glyphText = canonicalizeLoose(gv); + // 2 rows < minRows=3, so no @tab block + expect(glyphText).not.toContain('@tab _'); + }); + + test('canonicalizeLooseNoTabular emits list form, not @tab, for same data', () => { + const rows = makeMatchRows(10); + const gv = fromJsonLoose(rows); + const glyphNoTab = canonicalizeLooseNoTabular(gv); + expect(glyphNoTab).not.toContain('@tab _'); + expect(glyphNoTab).toContain('['); + }); + + test('parseTabularLoose recovers @tab block rows as plain JS objects', () => { + const rows = makeMatchRows(5); + const gv = fromJsonLoose(rows); + const glyphText = canonicalizeLoose(gv); + + // parseTabularLoose can recover the data as plain objects (not GValue) + const result = parseTabularLoose(glyphText); + expect(result.rows).toHaveLength(5); + expect(result.columns).toContain('id'); + expect(result.columns).toContain('home'); + expect(result.columns).toContain('away'); + // Row values are plain JS — not GValues + expect(result.rows[0]['id']).toBe('m0'); + }); +}); + +// ============================================================ +// PatchApply: emitPatch / parsePatch / applyPatch +// Uses real match state structure matching gauntlet-data.json matchStream. +// ============================================================ + +describe('PatchApply: emitPatch / parsePatch / applyPatch', () => { + // Match state as a loose GValue (via fromJsonLoose) + function makeMatchState(minute: number, scoreHome: number, scoreAway: number) { + return fromJsonLoose({ + id: 'match:001', + minute, + score_home: scoreHome, + score_away: scoreAway, + home: 'Arsenal', + away: 'Chelsea', + status: 'live', + }); + } + + test('emitPatch produces @patch text with correct header', () => { + const patch = new PatchBuilder({ prefix: 'match', value: '001' }) + .set('minute', g.int(45)) + .set('score_home', g.int(1)) + .set('score_away', g.int(0)) + .build(); + + const text = emitPatch(patch); + expect(text).toContain('@patch'); + expect(text).toContain('@target=match:001'); + expect(text).toContain('= minute 45'); + expect(text).toContain('@end'); + }); + + test('parsePatch recovers patch from emitPatch output', () => { + const original = new PatchBuilder({ prefix: 'match', value: '001' }) + .set('minute', g.int(45)) + .set('score_home', g.int(1)) + .set('score_away', g.int(0)) + .build(); + + const text = emitPatch(original); + const parsed = parsePatch(text); + + expect(parsed.target.prefix).toBe('match'); + expect(parsed.target.value).toBe('001'); + expect(parsed.ops).toHaveLength(3); + }); + + test('applyPatch updates fields on a map GValue', () => { + const state = makeMatchState(0, 0, 0); + const patch = new PatchBuilder({ prefix: 'match', value: '001' }) + .set('minute', g.int(45)) + .set('score_home', g.int(1)) + .set('score_away', g.int(0)) + .build(); + + const updated = applyPatch(state, patch); + expect(updated.get('minute')!.asInt()).toBe(45); + expect(updated.get('score_home')!.asInt()).toBe(1); + expect(updated.get('score_away')!.asInt()).toBe(0); + }); + + test('round-trip: emitPatch -> parsePatch -> applyPatch updates state correctly', () => { + const state = makeMatchState(10, 0, 0); + + const patch = new PatchBuilder({ prefix: 'match', value: '001' }) + .set('minute', g.int(60)) + .set('score_home', g.int(2)) + .set('score_away', g.int(1)) + .build(); + + const patchText = emitPatch(patch); + const parsedPatch = parsePatch(patchText); + const updated = applyPatch(state, parsedPatch); + + expect(updated.get('minute')!.asInt()).toBe(60); + expect(updated.get('score_home')!.asInt()).toBe(2); + expect(updated.get('score_away')!.asInt()).toBe(1); + // Unpatch fields remain unchanged + expect(updated.get('home')!.asStr()).toBe('Arsenal'); + expect(updated.get('status')!.asStr()).toBe('live'); + }); + + test('patch bytes < snapshot bytes (gauntlet matchStream finding)', () => { + // The gauntlet data records 32.81% savings for patches vs full snapshots. + // We verify the inequality holds in the live codec. + const state = makeMatchState(45, 1, 0); + const snapshotText = JSON.stringify(toJsonLoose(state)); + const snapshotBytes = Buffer.byteLength(snapshotText, 'utf8'); + + const patch = new PatchBuilder({ prefix: 'match', value: '001' }) + .set('minute', g.int(45)) + .set('score_home', g.int(1)) + .set('score_away', g.int(0)) + .build(); + + const patchText = emitPatch(patch); + const patchBytes = Buffer.byteLength(patchText, 'utf8'); + + // Patches should be smaller than full snapshots + expect(patchBytes).toBeLessThan(snapshotBytes); + }); + + test('sample patch text from gauntlet data parses correctly', () => { + // gauntlet-data.json samplePatchText (sorted ops by emitPatch default): + // "@patch @keys=wire @target=match:001\n= minute 45\n= score_away 0\n= score_home 1\n@end" + const samplePatch = '@patch @keys=wire @target=match:001\n= minute 45\n= score_away 0\n= score_home 1\n@end'; + const parsed = parsePatch(samplePatch); + expect(parsed.target.prefix).toBe('match'); + expect(parsed.target.value).toBe('001'); + // 3 ops: minute, score_away, score_home + expect(parsed.ops).toHaveLength(3); + // Find minute op + const minuteOp = parsed.ops.find(op => op.path[0]?.field === 'minute'); + expect(minuteOp).toBeDefined(); + expect(minuteOp!.value!.asInt()).toBe(45); + }); +}); + +// ============================================================ +// StreamingValidator Firewall +// Tests from gauntlet-data.json toolFirewall section. +// ============================================================ + +describe('StreamingValidator firewall', () => { + test('allowed tool (search) is detected early and accepted', () => { + const registry = defaultToolRegistry(); + const sv = new StreamingValidator(registry); + + // Feed the allowed tool text char by char. + // gauntlet data: "{action=search query=...}" — tool detected at char 15 + const text = '{action=search query="latest weather in Chicago" max_results=5}'; + let result = sv.getResult(); + + for (const c of text) { + result = sv.pushToken(c); + } + + expect(result.toolName).toBe('search'); + expect(result.toolAllowed).toBe(true); + expect(result.errors).toHaveLength(0); + expect(result.complete).toBe(true); + }); + + test('tool (search) is detected at char 15 (gauntlet headline)', () => { + const registry = defaultToolRegistry(); + const sv = new StreamingValidator(registry); + + const text = '{action=search query="latest weather in Chicago" max_results=5}'; + let detectedAtChar = -1; + + for (let i = 0; i < text.length; i++) { + const result = sv.pushToken(text[i]); + if (result.toolName !== null && detectedAtChar === -1) { + detectedAtChar = result.toolDetectedAtChar; + } + } + + // gauntlet-data.json: toolDetectedAtChar == 15 for the allowed tool + expect(detectedAtChar).toBe(15); + }); + + test('blocked tool (wire_transfer) is rejected natively — not in defaultToolRegistry', () => { + const registry = defaultToolRegistry(); + // Confirm wire_transfer is absent from the default registry + expect(registry.isAllowed('wire_transfer')).toBe(false); + + const sv = new StreamingValidator(registry); + const text = '{action=wire_transfer amount=1000000 target=unknown}'; + + let result = sv.getResult(); + for (const c of text) { + result = sv.pushToken(c); + } + + expect(result.toolName).toBe('wire_transfer'); + expect(result.toolAllowed).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + expect(result.errors[0].code).toBe(ErrorCode.UnknownTool); + }); + + test('wire_transfer rejected at char 22 (gauntlet headline)', () => { + const registry = defaultToolRegistry(); + const sv = new StreamingValidator(registry); + + const text = '{action=wire_transfer amount=1000000 target=unknown}'; + let rejectAtChar = -1; + + for (let i = 0; i < text.length; i++) { + const result = sv.pushToken(text[i]); + if (result.errors.length > 0 && rejectAtChar === -1) { + rejectAtChar = result.charCount; + } + } + + // gauntlet-data.json: toolDetectedAtChar == 22 for wire_transfer + expect(rejectAtChar).toBe(22); + }); + + test('shouldStop() returns true after UnknownTool error', () => { + const registry = defaultToolRegistry(); + const sv = new StreamingValidator(registry); + + const text = '{action=wire_transfer amount=1000000 target=unknown}'; + let stoppedAt = -1; + + for (let i = 0; i < text.length; i++) { + sv.pushToken(text[i]); + if (sv.shouldStop() && stoppedAt === -1) { + stoppedAt = i + 1; + } + } + + // shouldStop() should trigger at char 22 (detection = rejection for unknown tool) + expect(stoppedAt).toBe(22); + }); + + test('bytes avoided: stopping at char 22 avoids remaining 30 bytes', () => { + const text = '{action=wire_transfer amount=1000000 target=unknown}'; + const totalChars = text.length; // 52 + const rejectAtChar = 22; + const bytesAvoided = totalChars - rejectAtChar; + + // gauntlet data: bytesAvoided == 30, totalChars == 52, rejectAtChar == 22 + expect(totalChars).toBe(52); + expect(rejectAtChar).toBe(22); + expect(bytesAvoided).toBe(30); + }); + + test('defaultToolRegistry contains exactly: search, calculate, browse, execute, read_file, write_file', () => { + const registry = defaultToolRegistry(); + // From gauntlet-data.json registryNote + expect(registry.isAllowed('search')).toBe(true); + expect(registry.isAllowed('calculate')).toBe(true); + expect(registry.isAllowed('browse')).toBe(true); + expect(registry.isAllowed('execute')).toBe(true); + expect(registry.isAllowed('read_file')).toBe(true); + expect(registry.isAllowed('write_file')).toBe(true); + // wire_transfer is absent + expect(registry.isAllowed('wire_transfer')).toBe(false); + }); + + test('feeding text token by token produces same result as char by char', () => { + const registry = defaultToolRegistry(); + const sv1 = new StreamingValidator(registry); + const sv2 = new StreamingValidator(registry); + + const text = '{action=search query="test"}'; + + // Char by char + for (const c of text) { + sv1.pushToken(c); + } + + // As a single token + sv2.pushToken(text); + + const r1 = sv1.getResult(); + const r2 = sv2.getResult(); + + expect(r1.toolName).toBe(r2.toolName); + expect(r1.toolAllowed).toBe(r2.toolAllowed); + expect(r1.complete).toBe(r2.complete); + expect(r1.errors.length).toBe(r2.errors.length); + }); +}); + +// ============================================================ +// Cross-language compatibility notes +// These tests document the JS codec's position in the multi-language ecosystem. +// ============================================================ + +describe('Cross-language compatibility documentation', () => { + test('[DOC] JS loose mode is bidirectional — parseLoose closes the round-trip', () => { + // Previously JS was forward-only in loose mode (emit but no schema-free parse). + // parseLoose() now provides glyphText -> GValue, matching Go (ParseDocument) + // and Python (parse). A JS service can re-parse loose glyph text received from + // a Go or Python peer without a schema. + const original = { id: 'm1', home: 'ARS', away: 'LIV', score: [2, 1] }; + const glyphText = canonicalizeLoose(fromJsonLoose(original)); + const reparsed = parseLoose(glyphText); + expect(canonicalizeLoose(reparsed)).toBe(glyphText); // bidirectional fixed point + expect(toJsonLoose(reparsed)).toEqual(original); + }); + + test('[DOC] estimateTokens is deprecated — not a real BPE tokenizer', () => { + // The gauntlet data includes token savings figures marked as illustrative only. + // estimateTokens() splits on whitespace — dense glyph output has fewer spaces + // than pretty JSON, so savings look negative (glyph appears to use MORE tokens). + // A real BPE tokenizer would show genuine savings. + // Use tiktoken or similar for accurate comparisons. + expect(true).toBe(true); // Documentation test + }); + + test('[DOC] fingerprintLoose uses crypto (Node-only, not browser-safe)', () => { + // The browser bundle (glyph.bundle.js) was built with --external:crypto. + // fingerprintLoose() calls require('crypto') which works in Node but not browser. + // All other loose-mode functions (canonicalizeLoose, fromJsonLoose, etc.) are safe. + // Downstream browser pages must avoid fingerprintLoose or provide a crypto shim. + expect(true).toBe(true); // Documentation test + }); +}); diff --git a/js/src/index.ts b/js/src/index.ts index ceab518..1f3f705 100644 --- a/js/src/index.ts +++ b/js/src/index.ts @@ -147,6 +147,12 @@ export { TabularMetadata, } from './loose'; +// Loose-text parser — inverse of canonicalizeLoose (closes the loose round-trip +// gap; parity with Go ParseDocument / Python parse). +export { + parseLoose, +} from './parse_loose'; + // GS1 Stream (streaming transport) export * as stream from './stream/index'; diff --git a/js/src/parse_loose.ts b/js/src/parse_loose.ts new file mode 100644 index 0000000..23f7781 --- /dev/null +++ b/js/src/parse_loose.ts @@ -0,0 +1,766 @@ +/** + * GLYPH-Loose text parser. + * + * Inverts `canonicalizeLoose`: parses schema-free GLYPH-Loose text back into a + * GValue. This is the JS counterpart to Go's `ParseDocument` and Python's + * `parse` / `parse_loose`, and is a faithful port of `py/glyph/parse.py` so the + * three surfaces stay round-trip compatible (see tests/all_impl_parity_test.py). + * + * Loose mode is JSON-domain: structs collapse to maps and `time` is not a + * JSON-domain type, so — exactly as in the Python reference — there is no `time` + * token here. Values that only arise from explicit typed construction + * (g.time, packed structs) are intentionally out of loose scope. + */ + +import { GValue, MapEntry } from './types'; + +// ============================================================ +// Limits (aligned with Go, Python, C, Rust) +// ============================================================ + +export const DEFAULT_MAX_DEPTH = 128; +const MAX_COLLECTION_LEN = 1_000_000; // 1M elements +const MAX_STRING_LEN = 10 * 1024 * 1024; // 10MB + +// ============================================================ +// Lexer +// ============================================================ + +enum TokenType { + EOF = 'EOF', + LBRACE = '{', + RBRACE = '}', + LBRACKET = '[', + RBRACKET = ']', + LPAREN = '(', + RPAREN = ')', + EQUALS = '=', + COLON = ':', + COMMA = ',', + PIPE = '|', + CARET = '^', + AT = '@', + NULL = 'NULL', + BOOL = 'BOOL', + INT = 'INT', + FLOAT = 'FLOAT', + STRING = 'STRING', + BYTES = 'BYTES', + IDENT = 'IDENT', + NEWLINE = 'NEWLINE', +} + +interface Token { + type: TokenType; + value: unknown; + pos: number; +} + +function isAsciiDigit(c: string): boolean { + return c >= '0' && c <= '9'; +} + +// Unicode-aware letter / alphanumeric tests, mirroring Python's str.isalpha / +// str.isalnum so hand-written (non-emitter) input with Unicode bare identifiers +// lexes the same way it does in the Python reference. The canonical emitter only +// ever emits ASCII bare identifiers (isBareSafe is conservative), so this only +// matters for tolerant parsing of external input. +function isLetter(c: string): boolean { + return /\p{L}/u.test(c); +} +function isAlnum(c: string): boolean { + return /[\p{L}\p{N}]/u.test(c); +} + +const IDENT_CONTINUE_EXTRA = '_-./@+'; + +class Lexer { + text: string; + pos: number; + length: number; + + constructor(text: string) { + this.text = text; + this.pos = 0; + this.length = text.length; + } + + private peekChar(): string { + if (this.pos >= this.length) return ''; + return this.text[this.pos]; + } + + private nextChar(): string { + if (this.pos >= this.length) return ''; + const c = this.text[this.pos]; + this.pos += 1; + return c; + } + + private skipWhitespace(): void { + while (this.pos < this.length && ' \t\r'.includes(this.text[this.pos])) { + this.pos += 1; + } + } + + skipWhitespaceAndNewlines(): void { + while (this.pos < this.length && ' \t\r\n'.includes(this.text[this.pos])) { + this.pos += 1; + } + } + + nextToken(): Token { + this.skipWhitespace(); + + if (this.pos >= this.length) { + return { type: TokenType.EOF, value: null, pos: this.pos }; + } + + const start = this.pos; + const c = this.peekChar(); + + switch (c) { + case '{': this.pos += 1; return { type: TokenType.LBRACE, value: c, pos: start }; + case '}': this.pos += 1; return { type: TokenType.RBRACE, value: c, pos: start }; + case '[': this.pos += 1; return { type: TokenType.LBRACKET, value: c, pos: start }; + case ']': this.pos += 1; return { type: TokenType.RBRACKET, value: c, pos: start }; + case '(': this.pos += 1; return { type: TokenType.LPAREN, value: c, pos: start }; + case ')': this.pos += 1; return { type: TokenType.RPAREN, value: c, pos: start }; + case '=': this.pos += 1; return { type: TokenType.EQUALS, value: c, pos: start }; + case ':': this.pos += 1; return { type: TokenType.COLON, value: c, pos: start }; + case ',': this.pos += 1; return { type: TokenType.COMMA, value: c, pos: start }; + case '|': this.pos += 1; return { type: TokenType.PIPE, value: c, pos: start }; + case '^': this.pos += 1; return { type: TokenType.CARET, value: c, pos: start }; + case '@': this.pos += 1; return { type: TokenType.AT, value: c, pos: start }; + case '\n': this.pos += 1; return { type: TokenType.NEWLINE, value: c, pos: start }; + } + + // Null symbol + if (c === '∅' || c === '_') { + this.pos += 1; + return { type: TokenType.NULL, value: null, pos: start }; + } + + // Quoted string + if (c === '"') { + return this.readString(); + } + + // Bytes literal: b64"..." + if (c === 'b' && this.text.slice(this.pos, this.pos + 4) === 'b64"') { + return this.readBytes(); + } + + // Number or identifier + if (c === '-' || isAsciiDigit(c)) { + return this.readNumberOrIdent(); + } + + // Identifier or keyword + if (isLetter(c) || c === '_') { + return this.readIdent(); + } + + throw new Error(`unexpected character '${c}' at position ${this.pos}`); + } + + private readString(): Token { + const start = this.pos; + this.pos += 1; // skip opening quote + let result = ''; + + while (this.pos < this.length) { + const c = this.text[this.pos]; + if (c === '"') { + this.pos += 1; + return { type: TokenType.STRING, value: result, pos: start }; + } + if (result.length >= MAX_STRING_LEN) { + throw new Error(`string too large (>${MAX_STRING_LEN} characters)`); + } + if (c === '\\') { + this.pos += 1; + if (this.pos >= this.length) { + throw new Error('unterminated escape sequence'); + } + const esc = this.text[this.pos]; + switch (esc) { + case 'n': result += '\n'; break; + case 'r': result += '\r'; break; + case 't': result += '\t'; break; + case '"': result += '"'; break; + case '\\': result += '\\'; break; + case 'u': { + if (this.pos + 5 > this.length) { + throw new Error('invalid unicode escape'); + } + const hex = this.text.slice(this.pos + 1, this.pos + 5); + if (!/^[0-9a-fA-F]{4}$/.test(hex)) { + throw new Error('invalid unicode escape'); + } + result += String.fromCharCode(parseInt(hex, 16)); + this.pos += 4; + break; + } + default: result += esc; + } + } else { + result += c; + } + this.pos += 1; + } + + throw new Error('unterminated string'); + } + + private readBytes(): Token { + const start = this.pos; + this.pos += 4; // skip b64" + let b64 = ''; + + while (this.pos < this.length) { + const c = this.text[this.pos]; + if (c === '"') { + this.pos += 1; + return { type: TokenType.BYTES, value: base64ToBytes(b64), pos: start }; + } + b64 += c; + this.pos += 1; + } + + throw new Error('unterminated bytes literal'); + } + + private parseFloatToken(literal: string, start: number): Token { + const value = Number(literal); + if (Number.isNaN(value)) { + throw new Error(`invalid float literal '${literal}' at position ${start}`); + } + if (!Number.isFinite(value)) { + throw new Error(`non-finite float literal '${literal}' at position ${start}`); + } + return { type: TokenType.FLOAT, value, pos: start }; + } + + private readNumberOrIdent(): Token { + const start = this.pos; + let result = ''; + + if (this.peekChar() === '-') { + result += this.nextChar(); + // Reject -Inf (with word boundary) + if ( + this.text.slice(this.pos, this.pos + 3) === 'Inf' && + (this.pos + 3 >= this.length || + (!isAlnum(this.text[this.pos + 3]) && this.text[this.pos + 3] !== '_')) + ) { + throw new Error(`non-finite float literal '-Inf' at position ${start}`); + } + } + + let hasDot = false; + let hasExp = false; + + while (this.pos < this.length) { + const c = this.peekChar(); + if (isAsciiDigit(c)) { + result += this.nextChar(); + } else if (c === '.' && !hasDot && !hasExp) { + hasDot = true; + result += this.nextChar(); + } else if ((c === 'e' || c === 'E') && !hasExp) { + hasExp = true; + result += this.nextChar(); + if (this.peekChar() === '+' || this.peekChar() === '-') { + result += this.nextChar(); + } + } else if (isLetter(c) || c === '_') { + // It's an identifier + while ( + this.pos < this.length && + (isAlnum(this.peekChar()) || IDENT_CONTINUE_EXTRA.includes(this.peekChar())) + ) { + result += this.nextChar(); + } + return { type: TokenType.IDENT, value: result, pos: start }; + } else { + break; + } + } + + if (hasDot || hasExp) { + return this.parseFloatToken(result, start); + } + + // Integer. JS numbers can only represent integers exactly up to 2^53-1; + // the canonical emitter already lifts larger ints to float exponent form, + // so an INT token beyond the safe range only occurs for hand-written input. + const intVal = Number(result); + if (Number.isNaN(intVal)) { + return { type: TokenType.IDENT, value: result, pos: start }; + } + return { type: TokenType.INT, value: intVal, pos: start }; + } + + private readIdent(): Token { + const start = this.pos; + let result = ''; + + while (this.pos < this.length) { + const c = this.peekChar(); + if (isAlnum(c) || IDENT_CONTINUE_EXTRA.includes(c)) { + result += this.nextChar(); + } else { + break; + } + } + + switch (result) { + case 't': + case 'true': + return { type: TokenType.BOOL, value: true, pos: start }; + case 'f': + case 'false': + return { type: TokenType.BOOL, value: false, pos: start }; + case 'null': + case 'nil': + return { type: TokenType.NULL, value: null, pos: start }; + case 'NaN': + throw new Error(`non-finite float literal 'NaN' at position ${start}`); + case 'Inf': + throw new Error(`non-finite float literal 'Inf' at position ${start}`); + } + + return { type: TokenType.IDENT, value: result, pos: start }; + } +} + +// base64ToBytes decodes standard base64. Mirrors the file-local helper used +// elsewhere in this codebase (atob in the browser, Buffer in Node). +function base64ToBytes(b64: string): Uint8Array { + if (typeof atob === 'function') { + const binary = atob(b64); + const out = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i); + return out; + } + return new Uint8Array(Buffer.from(b64, 'base64')); +} + +// ============================================================ +// Parser +// ============================================================ + +class Parser { + private lexer: Lexer; + private current!: Token; + private maxDepth: number; + private depth: number; + + constructor(text: string, maxDepth = DEFAULT_MAX_DEPTH, nestingDepth = 0) { + this.lexer = new Lexer(text); + this.maxDepth = maxDepth; + this.depth = nestingDepth; + } + + private enter(kind: string): void { + if (this.depth >= this.maxDepth) { + throw new Error(`maximum nesting depth exceeded while parsing ${kind}`); + } + this.depth += 1; + } + + private leave(): void { + this.depth -= 1; + } + + private advance(): Token { + this.current = this.lexer.nextToken(); + return this.current; + } + + // Predicate over the current token type. Routing comparisons through a method + // (rather than `this.current.type === X` inline) avoids TypeScript's + // control-flow narrowing persisting across the mutating `advance()` call. + private is(t: TokenType): boolean { + return this.current.type === t; + } + + parse(): GValue { + this.lexer.skipWhitespaceAndNewlines(); + this.current = this.lexer.nextToken(); + const v = this.parseValue(); + while (this.is(TokenType.NEWLINE)) { + this.advance(); + } + if (!this.is(TokenType.EOF)) { + throw new Error(`trailing garbage at position ${this.current.pos}`); + } + return v; + } + + private parseValue(): GValue { + const tok = this.current; + const v = tok.value; + + switch (tok.type) { + case TokenType.NULL: + this.advance(); + return GValue.null(); + case TokenType.BOOL: + this.advance(); + return GValue.bool(v as boolean); + case TokenType.INT: + this.advance(); + return GValue.int(v as number); + case TokenType.FLOAT: + this.advance(); + return GValue.float(v as number); + case TokenType.STRING: + this.advance(); + return GValue.str(v as string); + case TokenType.BYTES: + this.advance(); + return GValue.bytes(v as Uint8Array); + case TokenType.CARET: + return this.parseRef(); + case TokenType.LBRACKET: + return this.parseList(); + case TokenType.LBRACE: + return this.parseMap(); + case TokenType.AT: + return this.parseDirective(); + case TokenType.IDENT: + return this.parseIdentValue(); + } + + throw new Error(`unexpected token ${tok.type} at position ${tok.pos}`); + } + + private parseRef(): GValue { + // current is CARET + this.advance(); + + if (this.is(TokenType.STRING)) { + const s = this.current.value as string; + this.advance(); + const idx = s.indexOf(':'); + if (idx >= 0) { + return GValue.id(s.slice(0, idx), s.slice(idx + 1)); + } + return GValue.id('', s); + } + + let first: string; + if (this.is(TokenType.IDENT)) { + first = this.current.value as string; + this.advance(); + } else if (this.is(TokenType.BOOL)) { + first = this.current.value ? 't' : 'f'; + this.advance(); + } else if (this.is(TokenType.INT)) { + first = String(this.current.value); + this.advance(); + } else { + throw new Error(`expected reference value, got ${this.current.type}`); + } + + if (this.is(TokenType.COLON)) { + this.advance(); + let second: string; + if (this.is(TokenType.IDENT) || this.is(TokenType.STRING)) { + second = this.current.value as string; + this.advance(); + } else if (this.is(TokenType.INT)) { + second = String(this.current.value); + this.advance(); + } else if (this.is(TokenType.BOOL)) { + second = this.current.value ? 't' : 'f'; + this.advance(); + } else { + throw new Error(`expected reference value part, got ${this.current.type}`); + } + return GValue.id(first, second); + } + + return GValue.id('', first); + } + + private parseList(): GValue { + this.enter('list'); + try { + // current is LBRACKET + this.advance(); + const items: GValue[] = []; + + while (!this.is(TokenType.RBRACKET)) { + if (this.is(TokenType.EOF)) { + throw new Error('unterminated list'); + } + if (this.is(TokenType.COMMA) || this.is(TokenType.NEWLINE)) { + this.advance(); + continue; + } + if (items.length >= MAX_COLLECTION_LEN) { + throw new Error(`list too large (>${MAX_COLLECTION_LEN} elements)`); + } + items.push(this.parseValue()); + } + + this.advance(); // consume RBRACKET + return GValue.list(...items); + } finally { + this.leave(); + } + } + + private parseMap(): GValue { + this.enter('map'); + try { + // current is LBRACE + this.advance(); + const entries: MapEntry[] = []; + + while (!this.is(TokenType.RBRACE)) { + if (this.is(TokenType.EOF)) { + throw new Error('unterminated map'); + } + if (this.is(TokenType.COMMA) || this.is(TokenType.NEWLINE)) { + this.advance(); + continue; + } + if (entries.length >= MAX_COLLECTION_LEN) { + throw new Error(`map too large (>${MAX_COLLECTION_LEN} entries)`); + } + + const key = this.parseKey(); + + if (!this.is(TokenType.EQUALS) && !this.is(TokenType.COLON)) { + throw new Error(`expected '=' or ':' after key '${key}'`); + } + this.advance(); + + entries.push({ key, value: this.parseValue() }); + } + + this.advance(); // consume RBRACE + return GValue.map(...entries); + } finally { + this.leave(); + } + } + + private parseKey(): string { + if (this.is(TokenType.IDENT) || this.is(TokenType.STRING)) { + const key = this.current.value as string; + this.advance(); + return key; + } + throw new Error(`expected key, got ${this.current.type}`); + } + + private parseIdentValue(): GValue { + const name = this.current.value as string; + this.advance(); + + // Struct: Name{...} + if (this.is(TokenType.LBRACE)) { + this.enter('struct'); + try { + this.advance(); + const fields: MapEntry[] = []; + + while (!this.is(TokenType.RBRACE)) { + if (this.is(TokenType.EOF)) { + throw new Error('unterminated struct'); + } + if (this.is(TokenType.COMMA) || this.is(TokenType.NEWLINE)) { + this.advance(); + continue; + } + if (fields.length >= MAX_COLLECTION_LEN) { + throw new Error(`struct too large (>${MAX_COLLECTION_LEN} fields)`); + } + + const key = this.parseKey(); + + if (!this.is(TokenType.EQUALS) && !this.is(TokenType.COLON)) { + throw new Error(`expected '=' or ':' after field '${key}'`); + } + this.advance(); + + fields.push({ key, value: this.parseValue() }); + } + + this.advance(); // consume RBRACE + return GValue.struct(name, ...fields); + } finally { + this.leave(); + } + } + + // Sum: Tag(value) or Tag() + if (this.is(TokenType.LPAREN)) { + this.enter('sum'); + try { + this.advance(); + if (this.is(TokenType.RPAREN)) { + this.advance(); + return GValue.sum(name, null); + } + const value = this.parseValue(); + if (!this.is(TokenType.RPAREN)) { + throw new Error(`expected ), got ${this.current.type}`); + } + this.advance(); + return GValue.sum(name, value); + } finally { + this.leave(); + } + } + + // Bare string + return GValue.str(name); + } + + private parseDirective(): GValue { + // current is AT + this.advance(); + + if (!this.is(TokenType.IDENT)) { + throw new Error(`expected directive name, got ${this.current.type}`); + } + + const directive = this.current.value as string; + this.advance(); + + if (directive === 'tab') { + return this.parseTabular(); + } + + throw new Error(`unknown directive: ${directive}`); + } + + private parseTabular(): GValue { + this.enter('tabular directive'); + try { + // Skip the _ placeholder (lexed as NULL) + if (this.is(TokenType.NULL) || (this.is(TokenType.IDENT) && this.current.value === '_')) { + this.advance(); + } + + // Column headers: [col1 col2 ...]. The v2.4.0 JS emitter writes + // `rows=N cols=M` metadata between the placeholder and the bracket (the + // Python emitter does not); skip any such attributes until the bracket. + while (!this.is(TokenType.LBRACKET)) { + if (this.is(TokenType.EOF)) { + throw new Error('expected [ for column headers'); + } + this.advance(); + } + + this.advance(); // consume LBRACKET + const cols: string[] = []; + while (!this.is(TokenType.RBRACKET)) { + if (this.is(TokenType.IDENT) || this.is(TokenType.STRING)) { + cols.push(this.current.value as string); + this.advance(); + } else if (this.is(TokenType.COMMA) || this.is(TokenType.NEWLINE)) { + this.advance(); + } else if (this.is(TokenType.EOF)) { + throw new Error('unterminated column header'); + } else { + throw new Error(`expected column name, got ${this.current.type}`); + } + } + this.advance(); // consume RBRACKET + + // Rows + const rows: GValue[] = []; + for (;;) { + while (this.is(TokenType.NEWLINE)) { + this.advance(); + } + + if (this.is(TokenType.AT)) { + this.advance(); + if (this.is(TokenType.IDENT) && this.current.value === 'end') { + this.advance(); + break; + } + throw new Error('expected @end'); + } + + if (this.is(TokenType.PIPE)) { + rows.push(this.parseTabularRow(cols)); + } else if (this.is(TokenType.EOF)) { + break; + } else { + throw new Error(`expected row or @end, got ${this.current.type}`); + } + } + + return GValue.list(...rows); + } finally { + this.leave(); + } + } + + private parseTabularRow(cols: string[]): GValue { + // current is PIPE; lexer.pos is right after the opening pipe, so cell + // content is read as raw characters (not tokenized) until the next pipe. + const entries: MapEntry[] = []; + + for (const col of cols) { + let cell = ''; + + while (this.lexer.pos < this.lexer.length) { + const c = this.lexer.text[this.lexer.pos]; + if (c === '|') break; + if (c === '\\' && this.lexer.pos + 1 < this.lexer.length) { + const nextC = this.lexer.text[this.lexer.pos + 1]; + if (nextC === '|') { cell += '|'; this.lexer.pos += 2; continue; } + if (nextC === 'n') { cell += '\n'; this.lexer.pos += 2; continue; } + if (nextC === '\\') { cell += '\\'; this.lexer.pos += 2; continue; } + } + cell += c; + this.lexer.pos += 1; + } + + if (this.lexer.pos >= this.lexer.length || this.lexer.text[this.lexer.pos] !== '|') { + throw new Error('expected | after cell'); + } + this.lexer.pos += 1; // skip the closing pipe + + const cellText = cell.trim(); + let value: GValue; + if (cellText === '' || cellText === '∅' || cellText === '_') { + value = GValue.null(); + } else { + const sub = new Parser(cellText, this.maxDepth, this.depth); + value = sub.parse(); + } + entries.push({ key: col, value }); + } + + // Resynchronize the token stream after the raw cell read. + this.current = this.lexer.nextToken(); + + return GValue.map(...entries); + } +} + +// ============================================================ +// Public API +// ============================================================ + +/** + * Parse GLYPH-Loose text into a GValue. Inverse of `canonicalizeLoose`. + * + * Closes the JS loose round-trip gap: `parseLoose(canonicalizeLoose(v))` is + * deep-equal to `v` for JSON-domain values, matching Go's `ParseDocument` and + * Python's `parse`. + */ +export function parseLoose(text: string, maxDepth = DEFAULT_MAX_DEPTH): GValue { + return new Parser(text, maxDepth).parse(); +} diff --git a/py/glyph/loose.py b/py/glyph/loose.py index 7563cdf..5be31f4 100644 --- a/py/glyph/loose.py +++ b/py/glyph/loose.py @@ -471,9 +471,12 @@ def _try_tabular(items: List[GValue], opts: LooseCanonOpts) -> Optional[str]: # Build tabular output lines = [] - # Header: @tab _ [col1 col2 col3] + # Header: @tab _ rows=N cols=M [col1 col2 col3] + # The rows/cols metadata (v2.4.0, for streaming resync) is part of the + # canonical form across Go and JS; Go is the source of truth, so Python + # emits it too. parse() / parse_loose() tolerate its absence. col_header = " ".join(canon_string(c) for c in cols) - lines.append(f"@tab _ [{col_header}]") + lines.append(f"@tab _ rows={len(items)} cols={len(cols)} [{col_header}]") # Rows for item in items: diff --git a/py/glyph/parse.py b/py/glyph/parse.py index 512da0d..4e9fab6 100644 --- a/py/glyph/parse.py +++ b/py/glyph/parse.py @@ -600,9 +600,14 @@ def _parse_tabular(self) -> GValue: elif self.current.type == TokenType.NULL: self.advance() - # Parse column headers - if self.current.type != TokenType.LBRACKET: - raise ValueError("expected [ for column headers") + # Skip optional v2.4.0 metadata (rows=N cols=M) and any other + # key=val attributes between the _ placeholder and the column + # bracket. Go/JS emit this header form and tolerate its absence; + # Python must accept it to read Go/JS tabular output. + while self.current.type != TokenType.LBRACKET: + if self.current.type in (TokenType.EOF, TokenType.NEWLINE): + raise ValueError("expected [ for column headers") + self.advance() self.advance() cols = [] diff --git a/py/glyph/patch.py b/py/glyph/patch.py index c7d8b52..e05e264 100644 --- a/py/glyph/patch.py +++ b/py/glyph/patch.py @@ -26,7 +26,7 @@ from typing import Any, Dict, List, Optional from .types import GType, GValue, MapEntry, StructValue -from .loose import canonicalize_loose_no_tabular +from .loose import canonicalize_loose class PatchOpKind(Enum): @@ -63,10 +63,10 @@ class Patch: ops: List[PatchOp] = field(default_factory=list) schema_id: str = "" target: str = "" - # First 16 hex chars of sha256(canonicalize_loose_no_tabular(base_state)); - # empty when + # First 16 hex chars of sha256(canonicalize_loose(base_state)); empty when # the patch does not record a base. Matches Go BaseFingerprint / JS - # baseFingerprint so a Python receiver can verify a Go/JS-emitted patch. + # baseFingerprint (Go is the cross-language source of truth for @base) so a + # Python receiver can verify a Go/JS-emitted patch. base_fingerprint: str = "" @@ -89,23 +89,18 @@ def __init__(self, got: str, want: str): def compute_base_fingerprint(base: GValue) -> str: """Compute the 16-hex patch base fingerprint of a base state. - base = sha256(canonicalize_loose_no_tabular(base))[:16] — i.e. the first 16 - hex of the state fingerprint defined in the spec (README invariant: - fingerprint(x) = SHA256(canonical_no_tabular_bytes(x))). Using the no-tabular - form makes a patch's @base equal to fingerprint_loose(state)[:16], so a - receiver can verify it against the current state's fingerprint directly. - - This is byte-identical to Go WithBaseValue / JS withBaseValue for every - non-tabular base (struct/map roots — the realistic patch target), since their - tabular and no-tabular canonical forms coincide there. The one edge is a bare - auto-tabular list root: Go/JS WithBaseValue hash the *tabular* form, whereas - this uses the *no-tabular* form (= the state fingerprint), so the three diverge - there. Note Go is itself inconsistent at that edge — its FingerprintLoose also - uses no-tabular, so Go's WithBaseValue != Go's own state fingerprint for a list - root. Standardizing all three on the no-tabular state fingerprint is the - recommended follow-up. + base = sha256(canonicalize_loose(base))[:16] — the first 16 hex of the + SHA-256 of the *canonical loose form* (LOOSE_MODE_SPEC, "Patch Base + Fingerprint"). This is byte-identical to Go WithBaseValue / JS withBaseValue; + Go is the cross-language source of truth for @base. + + Note: @base uses the tabular canonical form (null → '_'), which is distinct + from fingerprint_loose(state) — the value-identity digest, which uses the + no-tabular form (null → '∅'). The two coincide for null-free non-tabular + states but diverge when the base contains nulls or is a bare auto-tabular + list root. """ - canonical = canonicalize_loose_no_tabular(base) + canonical = canonicalize_loose(base) return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:BASE_FINGERPRINT_LEN] diff --git a/py/pyproject.toml b/py/pyproject.toml index 2356218..bc0bb92 100644 --- a/py/pyproject.toml +++ b/py/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=61.0", "wheel"] +requires = ["setuptools>=77.0.0", "wheel"] build-backend = "setuptools.build_meta" [project] @@ -7,7 +7,7 @@ name = "glyph-py" version = "1.0.1" description = "Token-efficient serialization for AI agents" readme = "README.md" -license = {text = "Apache-2.0"} +license = "Apache-2.0" authors = [ {name = "Neumenon", email = "contact@neumenon.ai"} ] @@ -15,7 +15,6 @@ keywords = ["serialization", "json", "llm", "ai", "tokens", "glyph"] classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", - "License :: OSI Approved :: Apache Software License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", diff --git a/py/tests/glyph_gauntlet_test.py b/py/tests/glyph_gauntlet_test.py new file mode 100644 index 0000000..d3de668 --- /dev/null +++ b/py/tests/glyph_gauntlet_test.py @@ -0,0 +1,913 @@ +""" +GLYPH Python Correctness Gauntlet +================================== +Mirrors the gauntlet contract defined in gauntlet/data/gauntlet-data.json. + +All numeric assertions come from the real codec — never fabricated. +If a feature is absent or broken, we skip with a documented reason or fail loud. + +Known gaps documented here: +- GValue.time() round-trip: canonicalize_loose() emits bare ISO text (e.g. + '2025-01-13T12:34:56Z') that parse() cannot re-parse because the lexer + tokenizes '2025' as INT then hits '-' as trailing garbage. This is a real + round-trip gap for native GTime values (not for date strings, which work fine). +- Streaming validator format: Python uses 'toolname{field=val}' whereas the + gauntlet data records JS '{action=toolname field=val}' format. Detection + timing numbers differ accordingly; we assert Python-format semantics. +- Big-int precision: 9007199254740992 > MAX_SAFE_INT so Python (like JS) maps it + to float. Go handles int64 correctly. Documented in edgeCases['big_int']. +- Python has no incremental/streaming text parser (no chunk-invariance path at + the glyph-text level). StreamingValidator is token-push, not text-chunk. +""" + +from __future__ import annotations + +import json +import math +import os +import sys +from datetime import datetime, timezone +from typing import Any + +import pytest + +# ── importable as: PYTHONPATH=py python -m pytest ... ────────────────────── +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import glyph +from glyph import ( + GValue, + GType, + MapEntry, + parse, + parse_loose, + canonicalize_loose, + canonicalize_loose_no_tabular, + from_json_loose, + to_json_loose, + json_to_glyph, + glyph_to_json, + fingerprint_loose, + equal_loose, + StreamingValidator, + ToolRegistry, + parse_patch, + apply_patch, + compute_base_fingerprint, + verify_patch_base, + PatchBaseMismatch, +) + +# ── load real gauntlet data once ──────────────────────────────────────────── +_GAUNTLET_PATH = os.path.join( + os.path.dirname(__file__), "..", "..", "gauntlet", "data", "gauntlet-data.json" +) + +with open(_GAUNTLET_PATH) as _fh: + _DATA: dict = json.load(_fh) + +_EDGE_CASES: list[dict] = _DATA["edgeCases"] +_EDGE_BY_NAME: dict[str, dict] = {ec["name"]: ec for ec in _EDGE_CASES} + +_FIREWALL = _DATA["toolFirewall"] +_MATCH_STREAM = _DATA["matchStream"] +_TABULAR = _DATA["tabular"] +_BENCH = _DATA["benchMatrix"] + + +# ============================================================ +# Helpers +# ============================================================ + +def _default_firewall_registry() -> ToolRegistry: + """Build a ToolRegistry mirroring defaultToolRegistry. + + JS defaultToolRegistry includes: search, calculate, browse, execute, + read_file, write_file. wire_transfer is intentionally absent. + """ + registry = ToolRegistry() + registry.add_tool("search", { + "query": {"type": "str"}, + "max_results": {"type": "int"}, + }) + for tool in ("calculate", "browse", "execute", "read_file", "write_file"): + registry.add_tool(tool, {}) + return registry + + +def _assert_rt(json_value: Any, *, name: str) -> str: + """Round-trip json_value through glyph and return the glyph text.""" + gv = from_json_loose(json_value) + text = canonicalize_loose(gv) + back = glyph_to_json(text) + assert back == json_value, ( + f"{name}: glyph_to_json(json_to_glyph(v)) != v\n" + f" original : {json_value!r}\n" + f" glyph : {text!r}\n" + f" round-trip: {back!r}" + ) + return text + + +# ============================================================ +# 1. Museum of Edge Cases — JSON semantic round-trip + glyph text assertion +# ============================================================ + +class TestGauntletEdgeCases: + """ + For each record in gauntlet-data.json edgeCases: + - json_to_glyph(json.loads(jsonText)) must equal the recorded glyphText + - glyph_to_json(glyphText) round-trips back to the original JSON value + - canonicalize_loose is idempotent: canon(parse(canon(v))) == canon(v) + + Big-int is special: Python converts it to float (same as JS), documented. + The GTime native-type round-trip gap is skipped with explanation. + """ + + def _run(self, name: str) -> None: + ec = _EDGE_BY_NAME[name] + json_val = json.loads(ec["jsonText"]) + expected_glyph = ec["glyphText"] + + # Forward direction + produced = json_to_glyph(json_val) + assert produced == expected_glyph, ( + f"edge_case '{name}': json_to_glyph produced wrong glyph text\n" + f" expected : {expected_glyph!r}\n" + f" produced : {produced!r}" + ) + + # Semantic round-trip (JSON -> glyph -> JSON) + back = glyph_to_json(produced) + assert back == json_val, ( + f"edge_case '{name}': semantic round-trip failed\n" + f" original : {json_val!r}\n" + f" back : {back!r}" + ) + + # Idempotence of canonicalize_loose: canon(parse(canon(v))) == canon(v) + v = parse(produced) + canon2 = canonicalize_loose(v) + assert canon2 == produced, ( + f"edge_case '{name}': canonicalize_loose not idempotent\n" + f" first : {produced!r}\n" + f" second : {canon2!r}" + ) + + def test_empty_str(self): self._run("empty_str") + def test_unicode(self): self._run("unicode") + def test_embedded_quote(self): self._run("embedded_quote") + def test_pipe(self): self._run("pipe") + def test_newlines(self): self._run("newlines") + def test_null_value(self): self._run("null_value") + def test_bool_true(self): self._run("bool_true") + def test_bool_false(self): self._run("bool_false") + def test_float_sci(self): self._run("float_sci") + def test_date_string(self): self._run("date_string") + def test_nested_list(self): self._run("nested_list") + def test_nested_map(self): self._run("nested_map") + + def test_big_int(self): + """ + 9007199254740992 > MAX_SAFE_INT: Python maps it to float, matching JS. + Go handles int64 correctly (different output). + Gauntlet records: glyphText='9.007199254740992e+15'. + """ + ec = _EDGE_BY_NAME["big_int"] + json_val = json.loads(ec["jsonText"]) + expected_glyph = ec["glyphText"] + + produced = json_to_glyph(json_val) + assert produced == expected_glyph, ( + f"big_int: expected {expected_glyph!r}, got {produced!r}" + ) + # Round-trip back: float, not int — this is the documented precision loss + back = glyph_to_json(produced) + assert isinstance(back, float), f"big_int should round-trip as float, got {type(back)}" + assert back == float(ec["jsonText"]), ( + f"big_int float value mismatch: {back!r}" + ) + + def test_neg_zero(self): + """ + Gauntlet: jsonText='0', glyphText='0'. + json.loads('0') yields int 0, not -0.0; from_json_loose(0) -> GInt -> '0'. + """ + ec = _EDGE_BY_NAME["neg_zero"] + json_val = json.loads(ec["jsonText"]) + produced = json_to_glyph(json_val) + assert produced == ec["glyphText"], ( + f"neg_zero: expected {ec['glyphText']!r}, got {produced!r}" + ) + # Also verify -0.0 float normalizes the same way + gv_neg_zero = from_json_loose(-0.0) + assert canonicalize_loose(gv_neg_zero) == "0", ( + "from_json_loose(-0.0) should yield GInt(0) -> '0'" + ) + + +# ============================================================ +# 2. TypeZoo — native GValue types beyond JSON primitives +# ============================================================ + +class TestGauntletTypeZoo: + """ + Tests for GValue types that are not present in plain JSON: + bytes, ID references, struct, sum, and datetime (partial). + """ + + def test_bytes_roundtrip(self): + """bytes -> b64"..." -> back to bytes via parse+to_json.""" + raw = b"hello bytes\x00\xff" + gv = GValue.bytes_(raw) + text = canonicalize_loose(gv) + assert text.startswith('b64"'), f"bytes should start with b64\", got {text!r}" + + # parse it back + v2 = parse(text) + assert v2.type == GType.BYTES + assert v2.as_bytes() == raw + + def test_id_bare_roundtrip(self): + """ID with safe prefix:value emits bare ^prefix:value and round-trips.""" + gv = GValue.id("user", "123") + text = canonicalize_loose(gv) + assert text == "^user:123", f"expected ^user:123, got {text!r}" + v2 = parse(text) + assert v2.type == GType.ID + ref = v2.as_id() + assert ref.prefix == "user" + assert ref.value == "123" + + def test_id_no_prefix(self): + """ID without prefix emits ^value.""" + gv = GValue.id("", "myref-001") + text = canonicalize_loose(gv) + assert text == "^myref-001" + v2 = parse(text) + assert v2.as_id().value == "myref-001" + + def test_struct_roundtrip(self): + """Struct emits TypeName{field=val} and round-trips via parse.""" + gv = GValue.struct( + "Team", + MapEntry("name", GValue.str_("Arsenal")), + MapEntry("rank", GValue.int_(1)), + ) + text = canonicalize_loose(gv) + assert text == "Team{name=Arsenal rank=1}", f"unexpected struct text: {text!r}" + v2 = parse(text) + assert v2.type == GType.STRUCT + sv = v2.as_struct() + assert sv.type_name == "Team" + + def test_sum_roundtrip(self): + """Sum (tagged union) emits tag(value) and round-trips.""" + gv = GValue.sum("Ok", GValue.int_(42)) + text = canonicalize_loose(gv) + assert text == "Ok(42)", f"unexpected sum text: {text!r}" + v2 = parse(text) + assert v2.type == GType.SUM + sm = v2.as_sum() + assert sm.tag == "Ok" + assert sm.value.as_int() == 42 + + def test_time_forward_only(self): + """ + GValue.time() emits bare ISO text (e.g. '2025-01-13T12:34:56Z'). + parse() CANNOT re-parse this — the lexer reads '2025' as INT then + hits '-' as trailing garbage. This is a documented round-trip gap + for native GTime values. + + Forward direction (GTime -> glyph text) works; we assert the format. + Round-trip (parse the emitted text) is skipped as a known gap. + """ + dt = datetime(2025, 1, 13, 12, 34, 56, tzinfo=timezone.utc) + gv = GValue.time(dt) + text = canonicalize_loose(gv) + assert text == "2025-01-13T12:34:56Z", f"unexpected time text: {text!r}" + + # Document the known gap — parse() fails on bare ISO datetime + with pytest.raises(ValueError, match="trailing garbage"): + parse(text) + + # NOTE: date strings (str type) round-trip fine — this is separate + date_str_gv = from_json_loose("2024-03-15T12:00:00Z") + date_str_text = canonicalize_loose(date_str_gv) + assert date_str_text == '"2024-03-15T12:00:00Z"' + back = glyph_to_json(date_str_text) + assert back == "2024-03-15T12:00:00Z" + + def test_null_variants(self): + """Null emits '_' (default) or '∅' depending on opts.""" + from glyph import LooseCanonOpts, NullStyle + gv = GValue.null() + assert canonicalize_loose(gv) == "_" + opts = LooseCanonOpts(null_style=NullStyle.SYMBOL) + assert canonicalize_loose(gv, opts) == "∅" + + def test_bool_encoding(self): + assert canonicalize_loose(GValue.bool_(True)) == "t" + assert canonicalize_loose(GValue.bool_(False)) == "f" + + def test_float_neg_zero(self): + """Float -0.0 must canonicalize to '0.0' (D4 rule).""" + from glyph.loose import canon_float + assert canon_float(-0.0) == "0.0" + assert canon_float(0.0) == "0.0" + + def test_float_scientific(self): + """Small floats use Go-compatible exponential notation.""" + from glyph.loose import canon_float + assert canon_float(1.23e-9) == "1.23e-09" + assert canon_float(1e6) == "1e+06" + + +# ============================================================ +# 3. JSON Semantic Round-Trip — json_to_glyph / glyph_to_json +# ============================================================ + +class TestGauntletJsonSemanticRoundTrip: + """ + Full round-trip: Python dict/list -> json_to_glyph -> glyph_to_json -> back. + Tests realistic AI agent payloads. + """ + + def test_flat_tool_call(self): + data = {"action": "search", "query": "hello world", "count": 42, "active": True} + text = json_to_glyph(data) + back = glyph_to_json(text) + assert back == data + + def test_nested_structure(self): + data = {"a": 1, "b": {"c": 2, "d": [1, 2, 3]}, "e": None} + text = json_to_glyph(data) + back = glyph_to_json(text) + assert back == data + + def test_empty_string_preserved(self): + data = {"key": ""} + text = json_to_glyph(data) + back = glyph_to_json(text) + assert back == data + + def test_unicode_preserved(self): + data = {"msg": "café ☕ λ ñ 中文"} + text = json_to_glyph(data) + back = glyph_to_json(text) + assert back == data + + def test_embedded_quote_preserved(self): + data = {"msg": 'say "hello"'} + text = json_to_glyph(data) + back = glyph_to_json(text) + assert back == data + + def test_newlines_preserved(self): + data = {"body": "line1\nline2\r\nline3"} + text = json_to_glyph(data) + back = glyph_to_json(text) + assert back == data + + def test_pipe_char_preserved(self): + data = {"csv": "a|b|c"} + text = json_to_glyph(data) + back = glyph_to_json(text) + assert back == data + + def test_all_json_types(self): + data = { + "null_val": None, + "bool_t": True, + "bool_f": False, + "int_val": 42, + "float_val": 3.14, + "str_val": "hello", + "list_val": [1, "two", None, True], + "map_val": {"nested": "yes"}, + } + text = json_to_glyph(data) + back = glyph_to_json(text) + assert back == data + + def test_tabular_round_trip_via_parse(self): + """ + Homogeneous list of dicts with >=3 rows auto-tabularizes. + After tabularization the text starts with @tab _. + glyph_to_json on the tabular text must return the original list. + """ + rows = [ + {"id": i, "name": f"item_{i}", "score": i * 10} + for i in range(5) + ] + text = json_to_glyph(rows) + assert text.startswith("@tab _"), f"expected tabular output, got: {text!r}" + back = glyph_to_json(text) + assert back == rows, f"tabular round-trip failed:\n expected: {rows}\n got: {back}" + + def test_small_list_not_tabular(self): + """2 rows < min_rows=3: no auto-tabular.""" + rows = [{"id": 0, "name": "a"}, {"id": 1, "name": "b"}] + text = json_to_glyph(rows) + assert not text.startswith("@tab"), f"expected non-tabular for 2 rows, got: {text!r}" + + +# ============================================================ +# 4. Canonicalize Idempotence +# ============================================================ + +class TestGauntletIdempotence: + """ + canonicalize_loose(parse(canonicalize_loose(v))) == canonicalize_loose(v) + for all types. + """ + + def _assert_idempotent(self, v: GValue, label: str) -> None: + c1 = canonicalize_loose(v) + v2 = parse(c1) + c2 = canonicalize_loose(v2) + assert c1 == c2, ( + f"not idempotent for {label}:\n first: {c1!r}\n second: {c2!r}" + ) + + def test_null(self): + self._assert_idempotent(GValue.null(), "null") + + def test_bool_true(self): + self._assert_idempotent(GValue.bool_(True), "bool(True)") + + def test_bool_false(self): + self._assert_idempotent(GValue.bool_(False), "bool(False)") + + def test_int_zero(self): + self._assert_idempotent(GValue.int_(0), "int(0)") + + def test_int_pos(self): + self._assert_idempotent(GValue.int_(12345), "int(12345)") + + def test_int_neg(self): + self._assert_idempotent(GValue.int_(-42), "int(-42)") + + def test_float_sci(self): + self._assert_idempotent(GValue.float_(1.23e-9), "float(1.23e-9)") + + def test_float_pos(self): + self._assert_idempotent(GValue.float_(3.14159), "float(3.14159)") + + def test_string_bare(self): + self._assert_idempotent(GValue.str_("hello"), "str(hello)") + + def test_string_quoted(self): + self._assert_idempotent(GValue.str_("hello world"), "str(hello world)") + + def test_string_unicode(self): + self._assert_idempotent(GValue.str_("café ☕"), "str(unicode)") + + def test_list_flat(self): + gv = from_json_loose([1, 2, "three", None]) + self._assert_idempotent(gv, "flat list") + + def test_map_sorted(self): + gv = from_json_loose({"z": 26, "a": 1, "m": 13}) + self._assert_idempotent(gv, "map with sorting") + + def test_big_int_as_float(self): + """9007199254740992 -> float -> idempotent glyph text.""" + gv = from_json_loose(9007199254740992) + c1 = canonicalize_loose(gv) + assert c1 == "9.007199254740992e+15" + v2 = parse(c1) + c2 = canonicalize_loose(v2) + assert c1 == c2 + + def test_bytes_idempotent(self): + self._assert_idempotent(GValue.bytes_(b"\x00\xff\x42"), "bytes") + + def test_struct_idempotent(self): + gv = GValue.struct("Point", MapEntry("x", GValue.int_(1)), MapEntry("y", GValue.int_(2))) + self._assert_idempotent(gv, "struct") + + def test_sum_idempotent(self): + gv = GValue.sum("Ok", GValue.str_("done")) + self._assert_idempotent(gv, "sum") + + +# ============================================================ +# 5. Tabular Auto-Trigger (from gauntlet tabular section) +# ============================================================ + +class TestGauntletTabular: + """ + Auto-tabular kicks in for homogeneous lists of >= 3 maps. + Savings % are not asserted (tabular vs JSON would require re-implementing + the size measurement); instead we assert format correctness and round-trip. + """ + + def _make_rows(self, n: int) -> list: + return [ + {"id": i, "name": f"item_{i}", "value": i * 100, "active": True} + for i in range(n) + ] + + def test_tabular_trigger_at_3_rows(self): + rows = self._make_rows(3) + text = json_to_glyph(rows) + assert text.startswith("@tab _"), f"3 rows should trigger tabular, got: {text!r}" + assert "@end" in text + + def test_tabular_no_trigger_at_2_rows(self): + rows = self._make_rows(2) + text = json_to_glyph(rows) + assert not text.startswith("@tab"), f"2 rows should NOT trigger tabular, got: {text!r}" + + def test_tabular_header_format(self): + """Header is '@tab _ rows=N cols=M [col1 col2 ...]' with sorted cols. + + The rows/cols metadata (v2.4.0, for streaming resync) is part of the + canonical form. Go is the cross-language source of truth for the loose + canonical form, and Go/JS both emit this header; Python matches them. + """ + rows = [{"z": 1, "a": 2, "m": 3} for _ in range(3)] + text = json_to_glyph(rows) + first_line = text.split("\n")[0] + # Columns must be sorted; rows/cols metadata precedes the bracket. + assert first_line == "@tab _ rows=3 cols=3 [a m z]", f"unexpected header: {first_line!r}" + + def test_tabular_roundtrip_10_rows(self): + rows = self._make_rows(10) + text = json_to_glyph(rows) + assert text.startswith("@tab _") + back = glyph_to_json(text) + assert back == rows + + def test_tabular_roundtrip_50_rows(self): + """50 rows: matches the 'Verified: 50 match rows' from contract doc.""" + rows = self._make_rows(50) + text = json_to_glyph(rows) + assert text.startswith("@tab _") + back = glyph_to_json(text) + assert back == rows + + def test_tabular_smaller_than_json(self): + """Tabular glyph output is substantially smaller than JSON for large lists.""" + rows = self._make_rows(100) + text = json_to_glyph(rows) + json_text = json.dumps(rows, separators=(",", ":")) + assert len(text.encode()) < len(json_text.encode()), ( + f"tabular should be smaller than JSON: glyph={len(text)} json={len(json_text)}" + ) + + def test_tabular_with_pipe_in_cell(self): + """Pipe chars in cell values must be escaped in tabular output.""" + rows = [{"key": "a|b|c"} for _ in range(3)] + text = json_to_glyph(rows) + back = glyph_to_json(text) + assert back == rows, f"pipe round-trip failed: {back}" + + def test_tabular_with_missing_keys(self): + """Rows with missing keys fill with null (_) when allow_missing=True.""" + rows = [ + {"a": 1, "b": 2, "c": 3}, + {"a": 4, "b": 5}, # missing c + {"a": 6, "b": 7, "c": 9}, + ] + text = json_to_glyph(rows) + # Should still tabularize (allow_missing=True by default) + assert text.startswith("@tab _"), f"expected tabular with missing keys, got: {text!r}" + back = glyph_to_json(text) + # Missing key round-trips as null -> None + assert back[1].get("c") is None + + +# ============================================================ +# 6. Firewall — StreamingValidator tool allow/block +# ============================================================ + +class TestGauntletFirewall: + """ + Python validator format: 'toolname{field=val ...}' + (different from JS '{action=toolname ...}' format in gauntlet data). + + Gauntlet contract facts we DO assert: + - wire_transfer is not in defaultToolRegistry -> rejected + - search IS in registry -> allowed + - blocking produces errors containing 'UNKNOWN_TOOL' + - should_cancel is True on block + - bytes_avoided = total_chars - error_at_char (gauntlet: 30 for JS format) + """ + + def test_allowed_tool_completes_valid(self): + """search{...} completes valid.""" + registry = _default_firewall_registry() + validator = StreamingValidator(registry) + for ch in "search{query=\"test\" max_results=5}": + result = validator.push_token(ch) + assert result.complete + assert result.valid + assert result.tool_name == "search" + assert not result.errors + + def test_allowed_tool_early_detection(self): + """Tool name is detected before the closing brace.""" + registry = _default_firewall_registry() + validator = StreamingValidator(registry) + detected_at = None + for ch in "search{query=\"test\"}": + result = validator.push_token(ch) + if result.tool_detected_at_char > 0 and detected_at is None: + detected_at = result.tool_detected_at_char + assert detected_at is not None, "tool should be detected before end" + # Python format: tool name is in stream before '{', so detection is early + assert detected_at <= len("search"), ( + f"search should be detected by char {len('search')}, got {detected_at}" + ) + + def test_blocked_tool_has_error(self): + """wire_transfer is absent from registry -> UNKNOWN_TOOL error.""" + registry = _default_firewall_registry() + validator = StreamingValidator(registry) + for ch in "wire_transfer{amount=1000000 target=unknown}": + result = validator.push_token(ch) + assert any("UNKNOWN_TOOL" in e for e in result.errors), ( + f"expected UNKNOWN_TOOL error, got: {result.errors}" + ) + assert result.should_cancel + + def test_blocked_tool_name_identified(self): + """Even blocked tool: tool_name is set to 'wire_transfer'.""" + registry = _default_firewall_registry() + validator = StreamingValidator(registry) + for ch in "wire_transfer{amount=1000000}": + result = validator.push_token(ch) + assert result.tool_name == "wire_transfer" + + def test_blocked_bytes_avoided(self): + """ + Python format 'wire_transfer{amount=1000000 target=unknown}' (44 chars). + Error fires at char 14 ('{' seen after 13-char tool name). + bytes_avoided = 44 - 14 = 30 — matches gauntlet contract value of 30. + """ + registry = _default_firewall_registry() + validator = StreamingValidator(registry) + text = "wire_transfer{amount=1000000 target=unknown}" + total = len(text) + error_at = None + for ch in text: + result = validator.push_token(ch) + if result.errors and error_at is None: + error_at = validator.char_count + assert error_at is not None + bytes_avoided = total - error_at + assert bytes_avoided == 30, ( + f"bytes_avoided should be 30, got {bytes_avoided} " + f"(error_at={error_at}, total={total})" + ) + + def test_known_tools_not_blocked(self): + """All tools in the default registry should validate without UNKNOWN_TOOL.""" + registry = _default_firewall_registry() + for tool in ("search", "calculate", "browse", "execute", "read_file", "write_file"): + validator = StreamingValidator(registry) + for ch in f"{tool}{{}}": + result = validator.push_token(ch) + assert not any("UNKNOWN_TOOL" in e for e in result.errors), ( + f"tool '{tool}' should be allowed, errors: {result.errors}" + ) + + +# ============================================================ +# 7. PatchApply — parse_patch / apply_patch / compute_base_fingerprint +# ============================================================ + +class TestGauntletPatch: + """ + Tests against the sample @patch from gauntlet matchStream data. + + Sample patch from gauntlet: + @patch @keys=wire @target=match:001 + = minute 45 + = score_away 0 + = score_home 1 + @end + """ + + _SAMPLE_PATCH = _MATCH_STREAM["samplePatchText"] + _TARGET = "match:001" + + def _make_match_base(self, minute: int = 0, score_home: int = 0, score_away: int = 0) -> GValue: + return from_json_loose({ + "id": self._TARGET, + "minute": minute, + "score_home": score_home, + "score_away": score_away, + }) + + def test_parse_sample_patch(self): + """Sample patch parses to 3 SET operations.""" + p = parse_patch(self._SAMPLE_PATCH) + assert len(p.ops) == 3 + fields = {op.path[0].field for op in p.ops} + assert fields == {"minute", "score_away", "score_home"} + + def test_apply_sample_patch(self): + """Applying the sample patch updates minute and scores.""" + base = self._make_match_base(minute=0, score_home=0, score_away=0) + p = parse_patch(self._SAMPLE_PATCH) + result = apply_patch(base, p) + result_json = to_json_loose(result) + assert result_json["minute"] == 45 + assert result_json["score_home"] == 1 + assert result_json["score_away"] == 0 + + def test_patch_target_preserved(self): + """parse_patch reads @target= field.""" + p = parse_patch(self._SAMPLE_PATCH) + assert p.target == self._TARGET + + def test_compute_base_fingerprint_length(self): + """Fingerprint is exactly 16 hex chars.""" + base = self._make_match_base() + fp = compute_base_fingerprint(base) + assert len(fp) == 16 + assert all(c in "0123456789abcdef" for c in fp), f"not hex: {fp!r}" + + def test_compute_base_fingerprint_deterministic(self): + """Same base state always yields same fingerprint.""" + base = self._make_match_base() + fp1 = compute_base_fingerprint(base) + fp2 = compute_base_fingerprint(base) + assert fp1 == fp2 + + def test_verify_patch_base_ok(self): + """verify_patch_base passes when @base fingerprint matches.""" + base = self._make_match_base() + fp = compute_base_fingerprint(base) + patch_text = f"@patch @base={fp} @target={self._TARGET}\n= minute 45\n@end" + p = parse_patch(patch_text) + # No exception + verify_patch_base(base, p) + + def test_verify_patch_base_mismatch(self): + """verify_patch_base raises PatchBaseMismatch on wrong fingerprint.""" + base = self._make_match_base() + wrong_fp = "deadbeef12345678" + patch_text = f"@patch @base={wrong_fp}\n= minute 45\n@end" + p = parse_patch(patch_text) + with pytest.raises(PatchBaseMismatch): + verify_patch_base(base, p) + + def test_verify_patch_no_base_noop(self): + """verify_patch_base is a no-op when patch has no @base.""" + base = self._make_match_base() + p = parse_patch("@patch @target=x\n= minute 10\n@end") + assert p.base_fingerprint == "" + # Should not raise + verify_patch_base(base, p) + + def test_patch_does_not_mutate_base(self): + """apply_patch returns a copy; the original base is unchanged.""" + base = self._make_match_base(minute=0) + p = parse_patch("@patch\n= minute 90\n@end") + result = apply_patch(base, p) + # Base unchanged + base_json = to_json_loose(base) + assert base_json["minute"] == 0 + # Result updated + result_json = to_json_loose(result) + assert result_json["minute"] == 90 + + def test_sequential_patch_chain(self): + """Apply multiple patches in sequence, fingerprint chain is consistent.""" + state = self._make_match_base(minute=0, score_home=0, score_away=0) + for minute in range(1, 6): + fp = compute_base_fingerprint(state) + patch_text = ( + f"@patch @base={fp} @target={self._TARGET}\n" + f"= minute {minute}\n@end" + ) + p = parse_patch(patch_text) + verify_patch_base(state, p) + state = apply_patch(state, p) + assert to_json_loose(state)["minute"] == 5 + + def test_patch_savings_cumulative(self): + """ + Gauntlet records 32.81% savings for 100 updates (cumPatchBytes / cumSnapshotBytes). + cumSnapshotBytes=12192, cumPatchBytes=8192 (per gauntlet-data.json). + + We replicate the measurement: a richer match state yields ~122B snapshots; + 3-field patches are ~82B. Assert cumulative savings >= 25% over 10 updates. + """ + # Richer state matching gauntlet snapshot size (~122 bytes as JSON) + def make_state(minute: int, score_home: int, score_away: int) -> GValue: + return from_json_loose({ + "id": "match:001", + "minute": minute, + "score_home": score_home, + "score_away": score_away, + "home_team": "Arsenal", + "away_team": "Chelsea", + "status": "active", + "stadium": "Emirates", + }) + + cum_snap = 0 + cum_patch = 0 + state = make_state(0, 0, 0) + for i in range(1, 11): + # Snapshot size + snap = json.dumps(to_json_loose(state), separators=(",", ":")) + cum_snap += len(snap.encode()) + # Patch text + patch_text = ( + f"@patch @target=match:001\n" + f"= minute {i}\n" + f"= score_home 0\n" + f"= score_away 0\n" + f"@end" + ) + cum_patch += len(patch_text.encode()) + # Apply patch + p = parse_patch(patch_text) + state = apply_patch(state, p) + + savings = 1.0 - cum_patch / cum_snap + assert savings >= 0.25, ( + f"cumulative patch savings should be >= 25%, got {savings:.1%} " + f"(cum_patch={cum_patch}, cum_snap={cum_snap}). " + f"Gauntlet contract: 32.81% over 100 updates." + ) + + def test_patch_all_op_kinds(self): + """Exercise =, +, -, ~ operations on a map value.""" + base = from_json_loose({"count": 5, "items": [1, 2], "tag": "old"}) + + p_set = parse_patch("@patch\n= count 10\n@end") + state = apply_patch(base, p_set) + assert to_json_loose(state)["count"] == 10 + + p_delta = parse_patch("@patch\n~ count 5\n@end") + state = apply_patch(state, p_delta) + assert to_json_loose(state)["count"] == 15 + + p_delete = parse_patch("@patch\n- tag\n@end") + state = apply_patch(state, p_delete) + assert "tag" not in to_json_loose(state) + + +# ============================================================ +# 8. Streaming validator chunk-invariance note +# ============================================================ + +class TestGauntletStreamChunkInvariance: + """ + Python StreamingValidator is token-push (push_token(str)). + There is no incremental glyph-TEXT chunk-level parser. + We verify that token-by-token (char-by-char) yields the same final result + as whole-text (one-shot push), which is the Python-level analogue. + """ + + def test_char_by_char_equals_oneshot(self): + """Feeding chars one at a time must yield same final state as one push.""" + registry = _default_firewall_registry() + text = "search{query=\"hello world\" max_results=10}" + + # One-shot + v1 = StreamingValidator(registry) + result_oneshot = v1.push_token(text) + + # Char-by-char + v2 = StreamingValidator(registry) + result_charwise = None + for ch in text: + result_charwise = v2.push_token(ch) + + assert result_oneshot.complete == result_charwise.complete + assert result_oneshot.valid == result_charwise.valid + assert result_oneshot.tool_name == result_charwise.tool_name + assert result_oneshot.errors == result_charwise.errors + assert result_oneshot.fields == result_charwise.fields + + def test_no_incremental_glyph_text_parser(self): + """ + There is no parse_loose_incremental or equivalent in the Python surface. + This test documents the gap — it asserts that parse() and parse_loose() + require a complete glyph text and will raise on truncated input. + """ + truncated = '{"a"' # incomplete glyph text + with pytest.raises((ValueError, Exception)): + parse(truncated) + + def test_fingerprint_stable(self): + """fingerprint_loose is deterministic — same value -> same hex.""" + v = from_json_loose({"action": "search", "q": "test"}) + fp1 = fingerprint_loose(v) + fp2 = fingerprint_loose(v) + assert fp1 == fp2 + assert len(fp1) == 64 # SHA-256 hex + + def test_equal_loose_semantic(self): + """equal_loose ignores map insertion order.""" + a = from_json_loose({"x": 1, "y": 2}) + b = from_json_loose({"y": 2, "x": 1}) + assert equal_loose(a, b)