Skip to content

Commit c669bc3

Browse files
feat: nested object flattening (spec v3.2)
Encoder automatically flattens fixed-shape nested objects into > path column names. Decoder reconstructs nesting from > paths. 20-48% fewer tokens on deeply nested API data. 100% comprehension on every frontier model. Zero regression on 200K round-trips.
1 parent 84cf0bf commit c669bc3

4 files changed

Lines changed: 252 additions & 11 deletions

File tree

CHANGELOG.md

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

3+
## v2.2.0 (2026-06-22)
4+
5+
### Spec v3.2: Nested Object Flattening
6+
7+
- Encoder automatically flattens fixed-shape nested objects into `>` path column names (e.g., `"customer>name"` instead of `^` + `.customer {}` attachment)
8+
- Decoder reconstructs nested objects from `>` path columns
9+
- 20-48% fewer tokens on deeply nested API data (Jira, Stripe, K8s, calendar events)
10+
- 100% comprehension on every frontier model (validated across 9 models, 7 providers)
11+
- Zero regression on lossless round-trips (200K random + adversarial)
12+
- Falls back to attachment mechanism for: variable-length arrays, objects with different keys across rows, objects with `>` in key names, empty nested objects
13+
314
## v2.1.0 (2026-06-14)
415

516
### Spec v3.1

src/decode_generic.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,11 +258,68 @@ function findClosingBrace(s: string): number {
258258
return -1;
259259
}
260260

261+
function unflattenPaths(
262+
pathColumns: Map<string, string[]>,
263+
flatValues: Map<string, any>,
264+
flatAbsent: Set<string>,
265+
): Record<string, any> {
266+
// Group by top-level parent.
267+
const groups = new Map<string, string[]>();
268+
const groupOrder: string[] = [];
269+
for (const [fieldName, paths] of pathColumns) {
270+
if (paths.length === 0) continue;
271+
const top = paths[0];
272+
if (!groups.has(top)) {
273+
groups.set(top, []);
274+
groupOrder.push(top);
275+
}
276+
groups.get(top)!.push(fieldName);
277+
}
278+
279+
const result: Record<string, any> = {};
280+
281+
for (const top of groupOrder) {
282+
const fieldNames = groups.get(top)!;
283+
const allAbsent = fieldNames.every(f => flatAbsent.has(f));
284+
const allNull = fieldNames.every(f => {
285+
if (flatAbsent.has(f)) return false;
286+
const val = flatValues.get(f);
287+
return val === null;
288+
});
289+
290+
if (allAbsent) continue;
291+
if (allNull) { result[top] = null; continue; }
292+
293+
for (const fieldName of fieldNames) {
294+
if (flatAbsent.has(fieldName)) continue;
295+
const paths = pathColumns.get(fieldName)!;
296+
const val = flatValues.has(fieldName) ? flatValues.get(fieldName) : null;
297+
298+
let current = result;
299+
for (let k = 0; k < paths.length - 1; k++) {
300+
if (!(paths[k] in current)) current[paths[k]] = {};
301+
current = current[paths[k]];
302+
}
303+
current[paths[paths.length - 1]] = val;
304+
}
305+
}
306+
307+
return result;
308+
}
309+
261310
function parseTabularBody(lines: string[], start: number, depth: number, fields: string[], expectedCount: number): [any[], number] {
262311
const ind = ' '.repeat(depth);
263312
const rows: any[] = [];
264313
let i = start;
265314

315+
// Detect path columns: fields containing ">".
316+
const pathColumnMap = new Map<string, string[]>();
317+
for (const f of fields) {
318+
if (f.includes('>')) {
319+
pathColumnMap.set(f, f.split('>'));
320+
}
321+
}
322+
266323
// Track inline schemas and shared array schemas.
267324
const inlineSchemas = new Map<string, string[]>();
268325
const sharedArraySchemas = new Map<string, string[]>();
@@ -303,9 +360,24 @@ function parseTabularBody(lines: string[], start: number, depth: number, fields:
303360
const inlineAttOrder: string[] = [];
304361
const missingFields = new Set<string>();
305362

363+
// Collect path column values for unflattening.
364+
const flatValues = new Map<string, any>();
365+
const flatAbsent = new Set<string>();
366+
306367
for (let j = 0; j < fields.length; j++) {
307368
const cellVal = vals[j];
308369

370+
// Path columns: store values for later unflattening.
371+
if (pathColumnMap.has(fields[j])) {
372+
const parsed = parseScalar(cellVal, true);
373+
if (parsed === MISSING) {
374+
flatAbsent.add(fields[j]);
375+
} else {
376+
flatValues.set(fields[j], parsed);
377+
}
378+
continue;
379+
}
380+
309381
// Check for ^{fields} inline schema declaration.
310382
if (cellVal.startsWith('^{') && cellVal.endsWith('}')) {
311383
const schemaStr = cellVal.slice(1);
@@ -467,6 +539,14 @@ function parseTabularBody(lines: string[], start: number, depth: number, fields:
467539
}
468540
}
469541

542+
// Unflatten path columns into nested objects.
543+
if (pathColumnMap.size > 0) {
544+
const nested = unflattenPaths(pathColumnMap, flatValues, flatAbsent);
545+
for (const [k, v] of Object.entries(nested)) {
546+
row[k] = v;
547+
}
548+
}
549+
470550
rows.push(row);
471551
if (expectedCount >= 0 && rows.length >= expectedCount) break;
472552
}

src/generic.ts

Lines changed: 157 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -141,36 +141,188 @@ function sharedArraySchema(arr: unknown[], fieldName: string): string[] | null {
141141
return canonicalFields;
142142
}
143143

144+
// ── Nested object flattening (v3.2) ──────────────────────────────────────
145+
146+
interface FlatLeaf {
147+
path: string; // ">" separated path (e.g. "customer>name")
148+
keys: string[]; // key chain to traverse from row object
149+
}
150+
151+
function analyzeFlattenable(arr: unknown[], fieldName: string, parentPath: string): FlatLeaf[] | null {
152+
let canonicalShape: Record<string, 'scalar' | 'nested'> | null = null;
153+
154+
for (const item of arr) {
155+
const obj = item as Record<string, unknown>;
156+
if (!(fieldName in obj) || obj[fieldName] === null || obj[fieldName] === undefined) continue;
157+
const v = obj[fieldName];
158+
if (typeof v !== 'object' || Array.isArray(v)) return null;
159+
160+
const keys = Object.keys(v as Record<string, unknown>);
161+
162+
if (!canonicalShape) {
163+
canonicalShape = {};
164+
for (const k of keys) {
165+
if (k.includes('>')) return null;
166+
const val = (v as Record<string, unknown>)[k];
167+
if (val !== null && val !== undefined && typeof val === 'object' && !Array.isArray(val)) {
168+
canonicalShape[k] = 'nested';
169+
} else if (Array.isArray(val)) {
170+
return null;
171+
} else {
172+
canonicalShape[k] = 'scalar';
173+
}
174+
}
175+
} else {
176+
if (keys.length !== Object.keys(canonicalShape).length) return null;
177+
for (const k of keys) {
178+
if (!(k in canonicalShape)) return null;
179+
const val = (v as Record<string, unknown>)[k];
180+
const expected = canonicalShape[k];
181+
if (expected === 'scalar') {
182+
if (val !== null && val !== undefined && typeof val === 'object') return null;
183+
} else if (expected === 'nested') {
184+
if (val !== null && val !== undefined) {
185+
if (typeof val !== 'object' || Array.isArray(val)) return null;
186+
}
187+
}
188+
}
189+
}
190+
}
191+
192+
if (!canonicalShape) return null;
193+
194+
const currentPath = parentPath ? parentPath + '>' + fieldName : fieldName;
195+
const parentKeys = parentPath ? [...parentPath.split('>'), fieldName] : [fieldName];
196+
197+
const leaves: FlatLeaf[] = [];
198+
for (const k of Object.keys(canonicalShape)) {
199+
if (canonicalShape[k] === 'scalar') {
200+
leaves.push({ path: currentPath + '>' + k, keys: [...parentKeys, k] });
201+
} else {
202+
const subArr = arr.map(item => {
203+
const obj = item as Record<string, unknown>;
204+
if (!(fieldName in obj) || obj[fieldName] === null || obj[fieldName] === undefined) return {};
205+
return obj[fieldName];
206+
});
207+
const subLeaves = analyzeFlattenable(subArr as unknown[], k, currentPath);
208+
if (!subLeaves || subLeaves.length === 0) return null;
209+
leaves.push(...subLeaves);
210+
}
211+
}
212+
213+
// Guard: reject if any row has non-null object with all-null leaves.
214+
if (leaves.length > 0) {
215+
for (const item of arr) {
216+
const obj = item as Record<string, unknown>;
217+
if (!(fieldName in obj) || obj[fieldName] === null || obj[fieldName] === undefined) continue;
218+
const allNull = leaves.every(leaf => {
219+
const val = resolveKeyChain(item, leaf.keys);
220+
return val.exists && val.value === null;
221+
});
222+
if (allNull) return null;
223+
}
224+
}
225+
226+
return leaves;
227+
}
228+
229+
function resolveKeyChain(item: unknown, keys: string[]): { value: unknown; exists: boolean } {
230+
if (keys.length === 0) return { value: undefined, exists: false };
231+
const obj = item as Record<string, unknown>;
232+
if (typeof obj !== 'object' || obj === null) return { value: undefined, exists: false };
233+
if (!(keys[0] in obj)) return { value: undefined, exists: false };
234+
let current: unknown = obj[keys[0]];
235+
if (current === null || current === undefined) return { value: current, exists: true };
236+
for (let i = 1; i < keys.length; i++) {
237+
if (typeof current !== 'object' || current === null) return { value: undefined, exists: false };
238+
const c = current as Record<string, unknown>;
239+
if (!(keys[i] in c)) return { value: undefined, exists: false };
240+
current = c[keys[i]];
241+
}
242+
return { value: current, exists: true };
243+
}
244+
245+
// ── End flattening helpers ───────────────────────────────────────────────
246+
144247
function encodeTabular(headerPrefix: string, arr: unknown[], fields: string[], depth: number): string {
145248
const prefix = indent(depth);
146249

147-
// Pre-compute inline schemas and shared array schemas.
250+
// Phase 0: Analyze fields for flattening.
251+
const flattenMap = new Map<string, FlatLeaf[]>();
252+
for (const f of fields) {
253+
const leaves = analyzeFlattenable(arr, f, '');
254+
if (leaves && leaves.length > 0) {
255+
flattenMap.set(f, leaves);
256+
}
257+
}
258+
259+
// Build expanded column list.
260+
type ColType = 'flat' | 'original';
261+
interface FlatColumn { headerName: string; colType: ColType; field: string; keys: string[]; }
262+
const columns: FlatColumn[] = [];
263+
for (const f of fields) {
264+
const leaves = flattenMap.get(f);
265+
if (leaves) {
266+
for (const leaf of leaves) {
267+
columns.push({ headerName: formatKey(leaf.path), colType: 'flat', field: f, keys: leaf.keys });
268+
}
269+
} else {
270+
columns.push({ headerName: formatKey(f), colType: 'original', field: f, keys: [] });
271+
}
272+
}
273+
274+
// Pre-compute inline schemas and shared array schemas (skip flattened fields).
148275
const inlineSchemas = new Map<string, string[]>();
149276
const sharedArrSchemas = new Map<string, string[]>();
150277
for (const f of fields) {
278+
if (flattenMap.has(f)) continue;
151279
const ifs = inlineSchemaFields(arr, f);
152280
if (ifs) inlineSchemas.set(f, ifs);
153281
const sas = sharedArraySchema(arr, f);
154282
if (sas) sharedArrSchemas.set(f, sas);
155283
}
156284

157-
const fmtFields = fields.map(f => formatKey(f));
158-
let out = `${headerPrefix}[${arr.length}]{${fmtFields.join(',')}}\n`;
285+
const headerFields = columns.map(c => c.headerName);
286+
let out = `${headerPrefix}[${arr.length}]{${headerFields.join(',')}}\n`;
159287

160288
for (let i = 0; i < arr.length; i++) {
161289
const obj = arr[i] as Record<string, unknown>;
162290
const cells: string[] = [];
163291
const attachments: { name: string; value: unknown; inline: boolean; inlineFields?: string[] }[] = [];
164292
let rowHasAttachment = false;
165293

166-
for (const f of fields) {
294+
for (const col of columns) {
295+
if (col.colType === 'flat') {
296+
// Resolve value via key chain.
297+
if (!(col.keys[0] in obj)) {
298+
cells.push('~');
299+
} else {
300+
// Check if top-level field is null.
301+
const topVal = obj[col.keys[0]];
302+
if (topVal === null || topVal === undefined) {
303+
cells.push(topVal === null ? '-' : '~');
304+
} else {
305+
const resolved = resolveKeyChain(obj, col.keys);
306+
if (!resolved.exists) {
307+
cells.push('~');
308+
} else if (resolved.value === null || resolved.value === undefined) {
309+
cells.push('-');
310+
} else {
311+
cells.push(formatScalar(resolved.value, 0x7c));
312+
}
313+
}
314+
}
315+
continue;
316+
}
317+
318+
// Original (non-flattened) field.
319+
const f = col.field;
167320
if (!(f in obj)) { cells.push('~'); continue; }
168321
const v = obj[f];
169322
if (v === null || v === undefined) { cells.push('-'); continue; }
170323
if (typeof v === 'object') {
171324
const ifs = inlineSchemas.get(f);
172325
if (ifs && !Array.isArray(v)) {
173-
// Inline schema: first row declares, subsequent use bare ^.
174326
if (i === 0) {
175327
const fmtIF = ifs.map(k => formatKey(k));
176328
cells.push(`^{${fmtIF.join(',')}}`);

tests/generic.test.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,10 @@ describe('encodeGeneric', () => {
5353
const output = encodeGeneric(data);
5454
expect(output).toContain('GCF profile=generic');
5555
expect(output).toContain('project=alpha');
56-
// Tabular array with nested field: uses ^ and .field {} attachment.
57-
expect(output).toContain('## tasks [2]{id,title,assignee}');
58-
expect(output).toContain('@0 1|Setup|^');
59-
expect(output).toContain('.assignee {}');
60-
expect(output).toContain('name=Alice');
61-
expect(output).toContain('name=Bob');
56+
// Tabular array with flattened nested field (v3.2).
57+
expect(output).toContain('## tasks [2]{id,title,"assignee>name"}');
58+
expect(output).toContain('1|Setup|Alice');
59+
expect(output).toContain('2|Build|Bob');
6260
});
6361

6462
it('handles null and undefined values', () => {

0 commit comments

Comments
 (0)