Skip to content

feat: write a record on one line with encode_line/decode_line - #44

Merged
konard merged 12 commits into
mainfrom
issue-43-a666a4fd6f03
Aug 27, 2026
Merged

feat: write a record on one line with encode_line/decode_line#44
konard merged 12 commits into
mainfrom
issue-43-a666a4fd6f03

Conversation

@konard

@konard konard commented Aug 27, 2026

Copy link
Copy Markdown
Member

Closes #43.

An append-only log wants one record per line: appending is one write, compaction
cuts at a newline, and grep, tail -f and wc -l all treat a line as one
event. encode() spreads a record over many lines and encode_compact() hides
it in base64, so a downstream project invented the private dialect
((:"bytes" 2827) (:"complete" true)) — which the notation's own parser
rejects, and which this codec decodes into three-element arrays.

This adds a readable single-line form and its exact inverse to all four
implementations.

Language API
Rust encode_line(&value) / decode_line(text)
JavaScript encodeLine({ obj }) / decodeLine({ notation })
Python encode_line(obj) / decode_line(notation)
C# Codec.EncodeLine(obj) / Codec.DecodeLine(notation)
(o: (bytes 2827) (complete true) (server (o: (host "127.0.0.1") (port 18878))) (models ("claude-haiku" "claude-opus")))

The ambiguity, and how it is answered

Issue #43 left the pair-vs-one-element-array question to this side and offered
three ways out. This takes option 2, a marker that is part of the notation:

  • an object is (o: (key value) …), an array is (value …);
  • a bare one-line link is always an array, so ("a" 1) is a two-element array
    and (o: (a 1)) is a one-pair object — both spellings survive a round trip;
  • () is the empty array, (o:) the empty object;
  • because the marker answers the question, the empty key round-trips as
    (o: ("" 2)) rather than having to be rejected.

o is an ordinary link id, so a line is plain Links Notation that
links_notation parses. Scalars stay bare (2827, true, null), so types
survive. A string keeps its own characters — only what would break framing is
escaped, and a string holding a newline is written as (base64 "…")
individually, so the rest of the record stays readable.

Behaviour change

The compact-format sniffer was too eager: it claimed any document starting with
a type marker, so the readable line (null 1) was routed to the base64 reader.
It now requires a compact body, so (null 1) decodes as a line. The single
document both forms claim, (null) ((None) in Python), stays the compact
null, so documents written before this change keep decoding.

Tests

  • fixtures/readable-format/cases.json gained a line field on all 43 cases (4 of them new),
    asserted four ways per case in every language (encodes to it, decodes back
    from it, contains no line break, and the plain decode reads it) — so all
    four languages write byte-identical lines.
  • A dedicated suite per language (12–19 tests) covering: no newline for nested
    objects and arrays; the notation's own parser accepts the output;
    decode(encode_line(v)) == decode(encode(v)) for objects, arrays, nesting and
    empty collections; a string with quotes, backslashes, newlines and non-ASCII
    round-trips exactly and stays on one line; the empty key round-trips; a marked
    object holding a non-pair is rejected with a clear error; two lines are not
    one record while a trailing newline is tolerated; and the hand-rolled dialect
    from the issue is the one the parser rejects.
  • An append_only_log example per language writes, counts, greps and reads back
    a log of one record per line; the Rust one runs in CI.

Local runs: Rust tests + clippy --all-targets + fmt --check; 428 JS tests +
eslint + prettier; 395 Python tests + CI-scoped ruff/mypy; 363 C# tests +
dotnet format --verify-no-changes + Release build with /warnaserror.

Changelog fragments for all four packages are included.

Adding .gitkeep for PR creation (default mode).
This file will be removed when the task is complete.

Issue: #43
@konard konard self-assigned this Aug 27, 2026
konard added 10 commits August 27, 2026 11:52
The readable format needed a layout where one record is one line: an
append-only log is appended a record at a time and compacted by cutting at
a newline, and grep, tail -f and wc -l all treat a line as an event. Until
now the only single-line form was the base64 one, which is unreadable.

On one line a link cannot say what it is by how its rows break, so an
object names itself with the 'o' link id the notation already has and
writes its pairs as their own links: (o: (bytes 2827) (complete true)).
A bare link is then always an array, which keeps () an empty array as in
the indented form, and every value -- the empty key included -- round
trips.

Compact-format sniffing is tightened accordingly: a link that opens
another link straight away is the readable form, and the compact null is
only the whole link (null), so a line such as (null 1) is read as the
array it is.
@konard konard changed the title [WIP] No readable single-line encoder: encode() is multi-line and encode_compact() is base64, so append-only logs get a private dialect feat: write a record on one line with encode_line/decode_line Aug 27, 2026
Comment thread js/src/readable.js Fixed
@konard
konard marked this pull request as ready for review August 27, 2026 12:25
@konard

konard commented Aug 27, 2026

Copy link
Copy Markdown
Member Author

Working session summary

Done. PR #44 is ready for review: #44

What shipped — a readable single-line encoder and its exact inverse in all four implementations (encode_line/decode_line, encodeLine/decodeLine, Codec.EncodeLine/Codec.DecodeLine):

(o: (bytes 2827) (complete true) (server (o: (host "127.0.0.1") (port 18878))) (models ("claude-haiku" "claude-opus")))

The ambiguity issue #43 delegated is answered with its option 2 — a marker that is part of the notation. o is an ordinary link id, so a line is plain Links Notation the notation's own parser accepts:

  • object (o: (key value) …), array (value …); a bare one-line link is always an array, so ("a" 1) and (o: (a 1)) are distinct and both round-trip;
  • () empty array, (o:) empty object;
  • the empty key round-trips as (o: ("" 2)) rather than being rejected;
  • scalars stay bare so types survive; a string keeps its own characters, and only a string holding a newline is written as (base64 "…") — individually, so the rest of the record stays readable.

One behaviour change worth flagging: the compact-format sniffer claimed any document starting with a type marker, so the readable line (null 1) was routed to the base64 reader. It now requires a compact body. The single document both forms claim, (null) ((None) in Python), stays the compact null so older documents keep decoding — that collision is documented in the tests and READMEs.

Verification: shared fixtures gained a line field on all 43 cases (4 of them new), asserted four ways per case in each language, so all four write byte-identical lines; plus a dedicated suite per language covering every test the issue suggested. An append_only_log example per language writes, counts, greps and reads back a log; the Rust one runs in CI. Changelog fragments for all four packages are included — versions are bumped by the release automation from those fragments, not by hand, matching the precedent in PR #42.

All 10 CI runs on 5029620 are green (Rust, JS, Python, C#, Security, Parity ×3, Shared Scripts, Link Checker), the branch is up to date with main, and the working tree is clean.

I skipped one thing deliberately: a dev/log/issues/43/pulls/44/ entry. The only precedent (issues/41/pulls/42/) is an evidence dump for a CI-forensics issue, not a per-PR convention; the design rationale here lives in the READMEs, code docs and PR description instead. Say the word if you want that folder created anyway.


This summary was automatically extracted from the AI working session output.

@konard

konard commented Aug 27, 2026

Copy link
Copy Markdown
Member Author

🤖 Solution Draft Log

This log file contains the complete execution trace of the AI solution draft process.

💰 Cost: $15.630100

📊 Context and tokens usage:

Claude Opus 5: (4 sub-sessions)

  1. 116.2K / 1M (12%) input tokens, 40.2K / 128K (31%) output tokens
  2. 116.6K / 1M (12%) input tokens, 34.6K / 128K (27%) output tokens
  3. 116.8K / 1M (12%) input tokens, 28.7K / 128K (22%) output tokens
  4. 63.5K / 1M (6%) input tokens, 13.7K / 128K (11%) output tokens

Total: (7.7K new + 350.0K cache writes + 16.9M cache reads) input tokens, 146.7K output tokens, $15.630100 cost

🤖 Models used:

  • Tool: Anthropic Claude Code
  • Requested: opus (claude-opus-5)
  • Thinking level: medium (~15999 tokens)
  • Model: Claude Opus 5 (claude-opus-5)

📎 Log file uploaded as Gist (5671KB)


Now working session is ended, feel free to review and add any feedback on the solution draft.

@konard

konard commented Aug 27, 2026

Copy link
Copy Markdown
Member Author

🔄 Auto-restart 1/5

Reason: CI failures detected

Starting new session to address the issues.


Auto-restart-until-mergeable mode is active. This run will stop after 5 restart iterations in total.

@konard
konard marked this pull request as draft August 27, 2026 12:28
CodeQL flagged `js/polynomial-redos` on `decodeLine`: the trailing trim
`/[\n\r]+$/` restarts inside a run of newlines, so a record followed by
many of them costs quadratic time — 20 s for 100k newlines, measured.

Trim by scanning from both ends instead, which is what the Python, Rust
and C# siblings already do with their built-in character trims. Each
language gains the regression test: the padded record is still refused
for holding more than one line, and returns well inside two seconds.
@konard
konard marked this pull request as ready for review August 27, 2026 12:38
@konard

konard commented Aug 27, 2026

Copy link
Copy Markdown
Member Author

Working session summary

CI is green. #44

Root cause of the CodeQL failure (alert js/polynomial-redos, high severity, js/src/readable.js:160): decodeLine trimmed the framing newlines with

text.replace(/^[\n\r]+/, '').replace(/[\n\r]+$/, '')

The end-anchored [\n\r]+$ restarts inside a run of newlines, so a record followed by many of them costs quadratic time on attacker-supplied input. Measured on this machine with the old expression: 970 ms at 20k newlines, 5.4 s at 50k, 20.1 s at 100k.

Fix (commit 83dd087):

  • Replaced the regex trim with a trimLineBreaks helper that scans from both ends — one linear pass. This is what the Python (str.strip("\n\r")), Rust (trim_matches) and C# (Trim('\n','\r')) siblings already do, so behaviour is unchanged and the languages stay aligned.
  • Added the regression test in all four languages (a long run of line breaks is rejected without a slowdown): a record padded with 200,000 newlines plus a trailing x is still rejected for holding more than one line, and returns well under two seconds. Under the old JS code that input would have taken tens of seconds.

Verification: JS 429/429 tests pass plus npm run check (eslint, prettier, jscpd); Python single-line suite 20/20 with ruff clean on the touched file; Rust cargo test --test single_line_format 13/13 with cargo fmt --check and clippy -D warnings clean; C# 13/13 with dotnet format --verify-no-changes clean. On CI the CodeQL check now passes and the PR has 0 open code-scanning alerts; all other checks pass, main was already merged, working tree is clean, and the PR is marked ready for review.


This summary was automatically extracted from the AI working session output.

@konard

konard commented Aug 27, 2026

Copy link
Copy Markdown
Member Author

🔄 Auto-restart-until-mergeable Log 1/5

This log file contains the complete execution trace of the AI solution draft process.

💰 Cost: $1.902202

📊 Context and tokens usage:

  • 59.6K / 1M (6%) input tokens, 12.8K / 128K (10%) output tokens

Total: (100 new + 45.4K cache writes + 2.3M cache reads) input tokens, 12.8K output tokens, $1.902202 cost

🤖 Models used:

  • Tool: Anthropic Claude Code
  • Requested: opus (claude-opus-5)
  • Model: Claude Opus 5 (claude-opus-5)

📎 Log file uploaded as Gist (6788KB)


Now working session is ended, feel free to review and add any feedback on the solution draft.

@konard
konard merged commit 72872b7 into main Aug 27, 2026
53 checks passed
@konard

konard commented Aug 27, 2026

Copy link
Copy Markdown
Member Author

🎉 Auto-merged

This pull request has been automatically merged by hive-mind.

  • All CI checks have passed

Auto-merged by hive-mind with --auto-merge flag

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.

No readable single-line encoder: encode() is multi-line and encode_compact() is base64, so append-only logs get a private dialect

2 participants