Skip to content

feat(gh): lossless csv+schema packing for gh --json and gh api output - #3580

Open
rbotte wants to merge 4 commits into
rtk-ai:developfrom
rbotte:feat/gh-json-lossless-pack
Open

rbotte wants to merge 4 commits into
rtk-ai:developfrom
rbotte:feat/gh-json-lossless-pack

Conversation

@rbotte

@rbotte rbotte commented Aug 15, 2026

Copy link
Copy Markdown

What

gh <cmd> --json … and gh api … are currently full passthrough (#196), tracked at 0% savings — exactly the outputs where most bytes are field names repeated on every row. The old run_api comment states why: "Converting JSON to a schema destroys all values and forces Claude to re-fetch." That objection is to lossy conversion.

This PR adds core/jsonpack: a lossless re-encoding that keeps every value and removes only the repetition:

  • Top-level arrays of objects → CSV+schema:

    [3]{id:int,author.login:string,title:string,tag:string?}
    1,alice,First title,x
    2,bob,"Title, with a comma",
    3,carol,Third title,null
    

    [N] declares the row count (truncation is detectable), ? marks nullable columns, an empty cell means the key was absent, bare null means JSON null, lookalike strings ("null", "42" in string columns) stay quoted, mixed-type columns carry JSON literals so 42 and "42" can never blur. Uniform nested objects flatten recursively into dotted columns (commit.author.name).

  • Envelopes (gh api objects) stay valid JSON: dense inner arrays of objects become {"_cols":[…],"_rows":[[…],…]} tables (uniform nested objects flatten into dotted _cols, marked "_flat":1), and the body is minified.

  • --jq / --template (long, short -q/-t, = and attached forms) keep full passthrough — the caller asked for a projection. The registry rewrites bare --json to rtk gh again.

Losslessness is verified at runtime, not assumed

pack() decodes its own output and requires exact value equality with the parsed input before emitting a single byte. Any mismatch — encoder bug, or adversarial data that collides with the notation itself (a real {"_cols":…,"_rows":…} object in the payload) — returns the raw bytes unchanged. Tests include those adversarial cases. This honors Never Block, composes with the existing never_worse token guard, and means the worst case of this feature is the status quo (raw passthrough).

The only normalization is serde_json number parsing (1.101.1), shared by every JSON path in rtk today.

Measured (real data, rtk-ai/rtk)

command before after
gh api repos/rtk-ai/rtk/actions/runs?per_page=12 0% (passthrough) 20% (7.2K tokens)
gh api repos/rtk-ai/rtk/commits?per_page=25 0% 18% (5.2K tokens)
gh pr list --limit 50 --json number,title,state,author,updatedAt,url 0% 17%
gh issue list --limit 30 --json … 0% 4%
gh api repos/rtk-ai/rtk (single object) 0% ~0% (GitHub already minifies; falls back raw)

Real payloads are value-dominated (URLs, ids, timestamps), so the honest lossless ceiling here is well below the synthetic-fixture numbers — the two committed real-capture fixtures (produced by gh, emails sanitized) pin round-trip + ≥10% savings on actual bytes, precisely so synthetic fixtures can't overstate.

Design notes

  • Ported from the lossless compaction stage of headroom-core (Apache-2.0, attribution in the module docs) with every lossy path removed: no retrieval pointers, no row dropping under budget, no stringified-JSON rewriting. A dependency was ruled out — the crate pulls ONNX Runtime, HF tokenizers, tree-sitter grammars and SQLite; the lossless algorithm is ~500 lines with zero new dependencies.
  • Transparency trade-off, stated plainly: this emits a format the model didn't literally ask for. The declaration line is self-describing, envelopes stay valid JSON, and every value is verbatim — but if you prefer keeping --json passthrough and only packing gh api, the gate is one line in gh_cmd::run.
  • Known future work: arrays-of-objects inside cells (e.g. issue labels) still render as JSON cells and pay CSV quote-doubling — nested sub-tables would lift gh issue list beyond 4%; glab api could reuse the packer as-is.

Capture is byte-exact on the packed paths — "raw fallback" means raw bytes

An adversarial pre-submission review (fake gh on PATH, end-to-end through the hook) drove the third commit. The shared line-oriented capture would have made "fall back to raw" untrue for gh api: stdin cut (--input - sending empty bodies), single lines beyond the 10MiB cap dropped entirely (empty stdout, exit 0), binary bodies decoded lossily. The packed paths therefore use their own byte-exact runner: stdin/stderr inherited, strict UTF-8 or verbatim bytes, and outputs beyond 32MiB stream through untouched with bounded memory. Additionally: a file redirect (--json … > f.json / gh api … > f.json) skips the rewrite at the registry (a program will parse that file); pflag fused shorts (-dq 'expr') are detected as projections; the flatten pass has a hard 512-column ceiling (its width cost is quadratic); and serde_json gains float_roundtrip so cited float digits are exactly the digits the API sent.

Tests

  • 44 unit tests in jsonpack (round-trips for commas/quotes/CRLF/unicode, null vs absent vs empty vs lookalikes, extreme numbers, recursive flatten with missing parents, adversarial notation collisions, strict decoder rejections, column-cap bailout, float digit fidelity) + gh_cmd gate tests + registry tests (incl. redirect skip).
  • tests/gh_pack_process_fidelity_test.rs: 6 integration tests driving the real binary against a fake gh on PATH — stdin reaches gh, a >10MiB single-line body survives whole, a body past the pack cap streams verbatim, a gzip body stays byte-exact, tabular output still packs, error bodies pass through with gh's exit code. These cover the plumbing, where unit tests are blind and where all three HIGH bugs lived.
  • Those six are mutation-verified: restoring the previous line-oriented capture makes exactly the four fidelity tests fail with the original symptoms (stdin_bytes 0, 0x8b decoded to U+FFFD, 0 bytes emitted for a 34MB body) while both controls keep passing.
  • Review fuzz: 800k randomized round-trip iterations, zero verify mismatches, zero panics.
  • Full suite: 2645 tests green across unit + integration; cargo fmt --check, clippy --all-targets (deny warnings), scripts/check-test-presence.sh origin/develop all pass.

🤖 Generated with Claude Code

@CLAassistant

CLAassistant commented Aug 16, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@rbotte
rbotte force-pushed the feat/gh-json-lossless-pack branch from cbfe933 to 2fdbb12 Compare August 16, 2026 12:20
rbotte and others added 4 commits August 16, 2026 14:07
gh --json and gh api were passthrough-only (rtk-ai#196): converting JSON to a
bare schema would destroy values, so rtk never touched them and tracked
0% savings on exactly the outputs where field names repeat per row.

This adds core/jsonpack: a lossless re-encoding ported from the
compaction stage of headroom-core (headroomlabs-ai/headroom,
Apache-2.0) with every lossy path removed — no retrieval pointers, no
row dropping, no stringified-JSON rewriting.

- top-level arrays of objects render as a [N]{col:type} declaration
  plus CSV rows; empty cell = absent key, bare null = JSON null,
  lookalike strings stay quoted, mixed columns carry JSON literals
- envelopes (gh api) stay valid JSON: dense inner arrays become
  {"_cols":…,"_rows":…} tables and the body is minified
- pack() decodes its own output and requires value equality with the
  input before emitting; any mismatch (including data that collides
  with the notation) falls back to the raw bytes — Never Block
- --jq/--template (long, short, = and attached forms) keep full
  passthrough; the registry rewrites bare --json to rtk gh again

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Rui Botte <nfsbotte@gmail.com>
Live measurement against real gh outputs showed the level-1 flatten with
headroom's width cap of 6 leaving the heavy payloads untouched: a commit
object has 7 keys, a user ~18, a repository ~45 — so gh api commits/runs
compressed 2-4% while paying CSV quote-doubling on giant json cells.

- flatten recurses (commit.author.name), cap raised 6 → 64; the cap only
  guards adversarial blowup, models read wide tables fine
- envelope tables flatten uniform nested objects into dotted _cols,
  marked "_flat":1 so the decoder knows dots mean nesting there (plain
  tables keep dots literal); our own {_cols,_rows} cells are never
  flattened
- CSV stays preferred; the JSON notation is the fallback when CSV cannot
  shrink (quote-doubling tax on wide json cells)
- two real-capture fixtures (gh pr list, gh api commits; emails+names
  sanitized) pin round-trip and ≥10% savings on bytes gh actually
  produced — synthetic fixtures overstate savings

Measured on rtk-ai/rtk: gh api commits 2% → 18%, gh api actions/runs
4% → 20%, gh pr list --json 17%, vs 0% for all of them before this
feature (passthrough).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Rui Botte <nfsbotte@gmail.com>
An adversarial review with a fake gh on PATH proved three HIGH bugs
sharing one root cause: switching gh api from passthrough to the shared
line-oriented capture made 'fall back to raw' untrue — stdin was cut
(--input - sent empty bodies), single lines beyond the 10MiB cap were
dropped entirely (empty stdout, exit 0), and binary bodies (tarball)
were decoded lossily into mojibake.

- run_gh_packed: byte-exact capture used by both packed paths. stdin
  and stderr stay inherited; strict (non-lossy) UTF-8 or the bytes are
  emitted verbatim; outputs beyond 32MiB flush what is buffered and
  stream the rest through untouched — bounded memory, zero truncation
- registry: a file redirect on a packed path (--json / gh api) skips
  the rewrite — a program will parse that file and expects real JSON
- wants_jq_or_template: detect pflag fused boolean shorts (-dq expr),
  where the projection flag hides inside a cluster
- flatten: hard 512-column ceiling — the collision scan and per-row
  splicing are quadratic in width, and 32k-column adversarial input
  could pin a core for seconds
- serde_json gains float_roundtrip: 124342.85041994731 no longer
  re-emits as …32, so cited digits are the digits the API sent

Also survived review: 800k randomized round-trip fuzz iterations with
zero verify mismatches and zero panics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Rui Botte <nfsbotte@gmail.com>
The three HIGH bugs the review found were invisible to every unit test:
they lived in the plumbing (stdin, buffering, decoding), not in the
encoding. Nothing stopped them from coming back.

Six integration tests drive the real binary against a fake gh on PATH:
stdin reaches gh, a >10MiB single-line body survives whole, a body past
the pack cap streams verbatim, a gzip body stays byte-exact, tabular
output still packs, and an error body passes through with gh's exit
code. Tracking is redirected to a temp DB so runs never touch the
developer's analytics.

Verified by mutation, not by counting greens: restoring the previous
line-oriented capture makes exactly the four fidelity tests fail with
the reviewer's own symptoms (stdin_bytes 0, 0x8b decoded to U+FFFD,
0 bytes emitted for 34MB) while both controls keep passing. That run
also exposed a weak assertion of my own — a 2.7MB payload passed even
against the broken capture, so the fixture now exceeds the 10MiB cap
it is meant to probe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Rui Botte <nfsbotte@gmail.com>
@rbotte
rbotte force-pushed the feat/gh-json-lossless-pack branch from 2fdbb12 to 54cc0bf Compare August 16, 2026 13:07
@rbotte

rbotte commented Aug 31, 2026

Copy link
Copy Markdown
Author

Ready for review whenever it fits your queue — posting status so triage is cheap:

  • CLA signed ✅
  • DCO sign-off on all commits ✅
  • Still merges cleanly on current develop (checked today) ✅
  • Full suite green locally, plus integration tests driving the real binary against a fake gh

The only thing outstanding is the first-time-contributor workflow approval, which needs a maintainer click. Happy to rebase or split the PR if that helps.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants