Skip to content

Commit d69966a

Browse files
feat: add flatten opt-out + fix field names containing >
GenericOptions with noFlatten to disable nested object flattening. Implements spec rule 7.4.6.1.4: fields with > in names excluded from tabular columns, emitted as per-row attachments. Decoder accepts orphan and scalar attachments, no longer splits literal > as path separator. 200K round-trips (both modes, > in keys) zero failures. 12 edge cases.
1 parent 2e082ab commit d69966a

7 files changed

Lines changed: 196 additions & 64 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
11
# Changelog
22

3+
## v2.2.1 (2026-06-23)
4+
5+
### Flatten Opt-Out
6+
7+
- Added `GenericOptions` interface with `noFlatten` option to disable nested object flattening
8+
- `encodeGeneric(data, { noFlatten: true })` produces attachment syntax instead of path columns
9+
- Backward compatible: `encodeGeneric(data)` behavior unchanged (flatten on by default)
10+
- Fixed: field names containing `>` no longer appear as tabular columns (spec rule 7.4.6.1.4)
11+
- Fixed: field names containing `>` no longer eligible for flattening analysis
12+
- Fixed: decoder no longer treats literal `>` in key names as a path separator
13+
- Fixed: decoder accepts orphan attachments (fields excluded from column list)
14+
- Fuzz key generator now includes `>` for adversarial testing; 12 targeted edge case tests
15+
316
## v2.2.0 (2026-06-22)
417

518
### Spec v3.2: Nested Object Flattening

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@blackwell-systems/gcf",
3-
"version": "2.2.0",
3+
"version": "2.2.1",
44
"description": "The AI-native wire format for structured data. 50-92% fewer tokens than JSON. 100% comprehension on every frontier model. Zero dependencies.",
55
"type": "module",
66
"main": "./dist/index.js",

src/decode_generic.ts

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,12 @@ function parseTabularBody(lines: string[], start: number, depth: number, fields:
316316
const pathColumnMap = new Map<string, string[]>();
317317
for (const f of fields) {
318318
if (f.includes('>')) {
319-
pathColumnMap.set(f, f.split('>'));
319+
const parts = f.split('>');
320+
// Only treat as a path column if all segments are non-empty.
321+
// A literal key like ">" would split into ["", ""].
322+
if (parts.every(p => p.length > 0)) {
323+
pathColumnMap.set(f, parts);
324+
}
320325
}
321326
}
322327

@@ -416,10 +421,10 @@ function parseTabularBody(lines: string[], start: number, depth: number, fields:
416421
const allAttFields = [...traditionalAttFields, ...inlineAttFields];
417422
const attachmentValues = new Map<string, any>();
418423

419-
if (rowHasID && allAttFields.length > 0) {
424+
if (rowHasID) {
420425
let inlineIdx = 0;
421426

422-
while (i < lines.length && attachmentValues.size < allAttFields.length) {
427+
while (i < lines.length) {
423428
const aLine = lines[i];
424429
let aContent: string | null = null;
425430
if (depth === 0 || aLine.startsWith(ind)) {
@@ -531,12 +536,9 @@ function parseTabularBody(lines: string[], start: number, depth: number, fields:
531536
if (attachmentValues.has(f)) { row[f] = attachmentValues.get(f); continue; }
532537
}
533538

534-
if (!rowHasID || allAttFields.length === 0) {
535-
const attIndent = ind + ' ';
536-
if (i < lines.length && lines[i].startsWith(attIndent)) {
537-
const peek = lines[i].slice(attIndent.length);
538-
if (peek.startsWith('.')) throw new Error(`orphan_attachment: ${peek}`);
539-
}
539+
// Also add any orphan attachment values (fields excluded from column list, e.g. ">" fields).
540+
for (const [k, v] of attachmentValues) {
541+
if (!(k in row)) row[k] = v;
540542
}
541543

542544
// Unflatten path columns into nested objects.
@@ -633,6 +635,14 @@ function parseAttachment(lines: string[], lineIdx: number, rest: string, depth:
633635
return [name, arr, consumed, null];
634636
}
635637

638+
// Scalar: =value (field names containing ">" excluded from tabular columns).
639+
if (afterName.startsWith('=')) {
640+
const valStr = afterName.slice(1);
641+
const parsed = parseScalar(valStr, true);
642+
if (parsed === MISSING) return [name, null, 1, null];
643+
return [name, parsed, 1, null];
644+
}
645+
636646
throw new Error(`invalid attachment form: ${afterName}`);
637647
}
638648

src/generic.ts

Lines changed: 78 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -7,58 +7,67 @@ function indent(depth: number): string {
77
return ' '.repeat(depth);
88
}
99

10-
export function encodeGeneric(data: unknown): string {
10+
/** Options for controlling generic encoding behavior. */
11+
export interface GenericOptions {
12+
/** When true, disables promotion of fixed-shape nested objects to path
13+
* columns (e.g. "customer>name"). Nested objects use attachment syntax
14+
* instead. Set when targeting open-weight models that show lower
15+
* comprehension on flattened encoding. */
16+
noFlatten?: boolean;
17+
}
18+
19+
export function encodeGeneric(data: unknown, opts?: GenericOptions): string {
1120
let out = 'GCF profile=generic\n';
12-
out += encodeRootValue(data);
21+
out += encodeRootValue(data, opts);
1322
return out;
1423
}
1524

16-
function encodeRootValue(v: unknown): string {
25+
function encodeRootValue(v: unknown, opts?: GenericOptions): string {
1726
if (v === null || v === undefined) return '=-\n';
18-
if (Array.isArray(v)) return encodeRootArray(v);
19-
if (typeof v === 'object') return encodeObject(v as Record<string, unknown>, 0);
27+
if (Array.isArray(v)) return encodeRootArray(v, opts);
28+
if (typeof v === 'object') return encodeObject(v as Record<string, unknown>, 0, opts);
2029
return `=${formatScalar(v, 0)}\n`;
2130
}
2231

23-
function encodeObject(obj: Record<string, unknown>, depth: number): string {
32+
function encodeObject(obj: Record<string, unknown>, depth: number, opts?: GenericOptions): string {
2433
const prefix = indent(depth);
2534
let out = '';
2635
for (const key of Object.keys(obj)) {
2736
const value = obj[key];
2837
const fk = formatKey(key);
2938
if (Array.isArray(value)) {
30-
out += encodeNamedArray(fk, value, depth);
39+
out += encodeNamedArray(fk, value, depth, opts);
3140
} else if (typeof value === 'object' && value !== null) {
3241
out += `${prefix}## ${fk}\n`;
33-
out += encodeObject(value as Record<string, unknown>, depth + 1);
42+
out += encodeObject(value as Record<string, unknown>, depth + 1, opts);
3443
} else {
3544
out += `${prefix}${fk}=${formatScalar(value, 0)}\n`;
3645
}
3746
}
3847
return out;
3948
}
4049

41-
function encodeRootArray(arr: unknown[]): string {
50+
function encodeRootArray(arr: unknown[], opts?: GenericOptions): string {
4251
if (arr.length === 0) return '## [0]\n';
4352
if (allPrimitives(arr)) {
4453
const vals = arr.map(v => formatScalar(v, 0x2c));
4554
return `## [${arr.length}]: ${vals.join(',')}\n`;
4655
}
4756
const fields = tabularFields(arr);
48-
if (fields) return encodeTabular('## ', arr, fields, 0);
49-
return encodeExpanded('## ', arr, 0);
57+
if (fields) return encodeTabular('## ', arr, fields, 0, opts);
58+
return encodeExpanded('## ', arr, 0, opts);
5059
}
5160

52-
function encodeNamedArray(name: string, arr: unknown[], depth: number): string {
61+
function encodeNamedArray(name: string, arr: unknown[], depth: number, opts?: GenericOptions): string {
5362
const prefix = indent(depth);
5463
if (arr.length === 0) return `${prefix}## ${name} [0]\n`;
5564
if (allPrimitives(arr)) {
5665
const vals = arr.map(v => formatScalar(v, 0x2c));
5766
return `${prefix}${name}[${arr.length}]: ${vals.join(',')}\n`;
5867
}
5968
const fields = tabularFields(arr);
60-
if (fields) return encodeTabular(`${prefix}## ${name} `, arr, fields, depth);
61-
return encodeExpanded(`${prefix}## ${name} `, arr, depth);
69+
if (fields) return encodeTabular(`${prefix}## ${name} `, arr, fields, depth, opts);
70+
return encodeExpanded(`${prefix}## ${name} `, arr, depth, opts);
6271
}
6372

6473
function tabularFields(arr: unknown[]): string[] | null {
@@ -149,6 +158,8 @@ interface FlatLeaf {
149158
}
150159

151160
function analyzeFlattenable(arr: unknown[], fieldName: string, parentPath: string): FlatLeaf[] | null {
161+
// Field names containing ">" cannot be flattened (would create ambiguous paths).
162+
if (fieldName.includes('>')) return null;
152163
let canonicalShape: Record<string, 'scalar' | 'nested'> | null = null;
153164

154165
for (const item of arr) {
@@ -244,15 +255,27 @@ function resolveKeyChain(item: unknown, keys: string[]): { value: unknown; exist
244255

245256
// ── End flattening helpers ───────────────────────────────────────────────
246257

247-
function encodeTabular(headerPrefix: string, arr: unknown[], fields: string[], depth: number): string {
258+
function encodeTabular(headerPrefix: string, arr: unknown[], fields: string[], depth: number, opts?: GenericOptions): string {
248259
const prefix = indent(depth);
249260

250261
// Phase 0: Analyze fields for flattening.
251262
const flattenMap = new Map<string, FlatLeaf[]>();
263+
if (!opts?.noFlatten) {
264+
for (const f of fields) {
265+
const leaves = analyzeFlattenable(arr, f, '');
266+
if (leaves && leaves.length > 0) {
267+
flattenMap.set(f, leaves);
268+
}
269+
}
270+
}
271+
272+
// Fields whose names contain ">" must not appear as tabular columns
273+
// because the decoder would interpret them as flattened path columns.
274+
// Track them for per-row attachment emission (spec rule 7.4.6.1.4).
275+
const gtFields = new Set<string>();
252276
for (const f of fields) {
253-
const leaves = analyzeFlattenable(arr, f, '');
254-
if (leaves && leaves.length > 0) {
255-
flattenMap.set(f, leaves);
277+
if (!flattenMap.has(f) && f.includes('>')) {
278+
gtFields.add(f);
256279
}
257280
}
258281

@@ -261,6 +284,7 @@ function encodeTabular(headerPrefix: string, arr: unknown[], fields: string[], d
261284
interface FlatColumn { headerName: string; colType: ColType; field: string; keys: string[]; }
262285
const columns: FlatColumn[] = [];
263286
for (const f of fields) {
287+
if (gtFields.has(f)) continue;
264288
const leaves = flattenMap.get(f);
265289
if (leaves) {
266290
for (const leaf of leaves) {
@@ -271,6 +295,11 @@ function encodeTabular(headerPrefix: string, arr: unknown[], fields: string[], d
271295
}
272296
}
273297

298+
// If all fields were excluded (all contain ">"), fall back to expanded.
299+
if (columns.length === 0) {
300+
return encodeExpanded(headerPrefix, arr, depth, opts);
301+
}
302+
274303
// Pre-compute inline schemas and shared array schemas (skip flattened fields).
275304
const inlineSchemas = new Map<string, string[]>();
276305
const sharedArrSchemas = new Map<string, string[]>();
@@ -340,6 +369,15 @@ function encodeTabular(headerPrefix: string, arr: unknown[], fields: string[], d
340369
}
341370
}
342371

372+
// Emit fields with ">" in their names as per-row attachments.
373+
for (const f of fields) {
374+
if (!gtFields.has(f)) continue;
375+
const obj = arr[i] as Record<string, unknown>;
376+
if (!(f in obj)) continue;
377+
rowHasAttachment = true;
378+
attachments.push({ name: f, value: obj[f], inline: false });
379+
}
380+
343381
const row = cells.join('|');
344382
if (rowHasAttachment) {
345383
out += `${prefix}@${i} ${row}\n`;
@@ -361,31 +399,38 @@ function encodeTabular(headerPrefix: string, arr: unknown[], fields: string[], d
361399
// Shared array schema: omit {fields} on subsequent rows.
362400
const sas = sharedArrSchemas.get(att.name);
363401
if (sas && i > 0) {
364-
out += encodeAttachmentArrayShared(prefix, fk, att.value as unknown[], depth + 2, sas);
402+
out += encodeAttachmentArrayShared(prefix, fk, att.value as unknown[], depth + 2, sas, opts);
365403
} else {
366-
out += encodeAttachmentArray(prefix, fk, att.value as unknown[], depth + 2);
404+
out += encodeAttachmentArray(prefix, fk, att.value as unknown[], depth + 2, opts);
367405
}
368-
} else {
406+
} else if (typeof att.value === 'object' && att.value !== null) {
369407
out += `${prefix}.${fk} {}\n`;
370-
out += encodeObject(att.value as Record<string, unknown>, depth + 2);
408+
out += encodeObject(att.value as Record<string, unknown>, depth + 2, opts);
409+
} else {
410+
// Scalar attachment (e.g. field names containing ">").
411+
if (att.value === null || att.value === undefined) {
412+
out += `${prefix}.${fk} =-\n`;
413+
} else {
414+
out += `${prefix}.${fk} =${formatScalar(att.value, 0)}\n`;
415+
}
371416
}
372417
}
373418
}
374419
return out;
375420
}
376421

377-
function encodeAttachmentArray(attPrefix: string, fk: string, arr: unknown[], depth: number): string {
422+
function encodeAttachmentArray(attPrefix: string, fk: string, arr: unknown[], depth: number, opts?: GenericOptions): string {
378423
if (arr.length === 0) return `${attPrefix}.${fk} [0]\n`;
379424
if (allPrimitives(arr)) {
380425
const vals = arr.map(v => formatScalar(v, 0x2c));
381426
return `${attPrefix}.${fk} [${arr.length}]: ${vals.join(',')}\n`;
382427
}
383428
const fields = tabularFields(arr);
384-
if (fields) return encodeTabular(`${attPrefix}.${fk} `, arr, fields, depth);
385-
return encodeExpanded(`${attPrefix}.${fk} `, arr, depth);
429+
if (fields) return encodeTabular(`${attPrefix}.${fk} `, arr, fields, depth, opts);
430+
return encodeExpanded(`${attPrefix}.${fk} `, arr, depth, opts);
386431
}
387432

388-
function encodeAttachmentArrayShared(attPrefix: string, fk: string, arr: unknown[], depth: number, sharedFields: string[]): string {
433+
function encodeAttachmentArrayShared(attPrefix: string, fk: string, arr: unknown[], depth: number, sharedFields: string[], opts?: GenericOptions): string {
389434
if (arr.length === 0) return `${attPrefix}.${fk} [0]\n`;
390435
if (allPrimitives(arr)) {
391436
const vals = arr.map(v => formatScalar(v, 0x2c));
@@ -409,35 +454,35 @@ function encodeAttachmentArrayShared(attPrefix: string, fk: string, arr: unknown
409454
return out;
410455
}
411456
// Fields don't match: fall back to full encoding.
412-
return encodeAttachmentArray(attPrefix, fk, arr, depth);
457+
return encodeAttachmentArray(attPrefix, fk, arr, depth, opts);
413458
}
414459

415-
function encodeExpanded(headerPrefix: string, arr: unknown[], depth: number): string {
460+
function encodeExpanded(headerPrefix: string, arr: unknown[], depth: number, opts?: GenericOptions): string {
416461
const prefix = indent(depth);
417462
let out = `${headerPrefix}[${arr.length}]\n`;
418463
for (let i = 0; i < arr.length; i++) {
419464
const item = arr[i];
420465
if (Array.isArray(item)) {
421-
out += encodeExpandedArrayItem(prefix, i, item, depth);
466+
out += encodeExpandedArrayItem(prefix, i, item, depth, opts);
422467
} else if (typeof item === 'object' && item !== null) {
423468
out += `${prefix}@${i} {}\n`;
424-
out += encodeObject(item as Record<string, unknown>, depth + 1);
469+
out += encodeObject(item as Record<string, unknown>, depth + 1, opts);
425470
} else {
426471
out += `${prefix}@${i} =${formatScalar(item, 0)}\n`;
427472
}
428473
}
429474
return out;
430475
}
431476

432-
function encodeExpandedArrayItem(prefix: string, idx: number, arr: unknown[], depth: number): string {
477+
function encodeExpandedArrayItem(prefix: string, idx: number, arr: unknown[], depth: number, opts?: GenericOptions): string {
433478
if (arr.length === 0) return `${prefix}@${idx} [0]\n`;
434479
if (allPrimitives(arr)) {
435480
const vals = arr.map(v => formatScalar(v, 0x2c));
436481
return `${prefix}@${idx} [${arr.length}]: ${vals.join(',')}\n`;
437482
}
438483
const fields = tabularFields(arr);
439-
if (fields) return encodeTabular(`${prefix}@${idx} `, arr, fields, depth + 1);
440-
return encodeExpanded(`${prefix}@${idx} `, arr, depth + 1);
484+
if (fields) return encodeTabular(`${prefix}@${idx} `, arr, fields, depth + 1, opts);
485+
return encodeExpanded(`${prefix}@${idx} `, arr, depth + 1, opts);
441486
}
442487

443488
function allPrimitives(arr: unknown[]): boolean {

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ export { decode } from './decode.js';
55
export { Session, encodeWithSession } from './session.js';
66
export { encodeDelta, verifyDelta } from './delta.js';
77
// packRoot is Node-only (uses crypto.createHash). Import directly: import { packRoot } from '@blackwell-systems/gcf/dist/packroot.js'
8-
export { encodeGeneric } from './generic.js';
8+
export { encodeGeneric, type GenericOptions } from './generic.js';
99
export { decodeGeneric } from './decode_generic.js';
1010
export { formatScalar, formatKey, parseScalar, needsQuote, quoteString } from './scalar.js';
1111
export { StreamEncoder, type StreamWriter, type StreamOptions } from './stream.js';

0 commit comments

Comments
 (0)