diff --git a/.gitignore b/.gitignore index 0685611..307d1b3 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules/ dist/ *.d.ts.map *.tsbuildinfo +.worktrees/ diff --git a/packages/cli-json-cas/src/cli.test.ts b/packages/cli-json-cas/src/cli.test.ts index e9fd474..ca1a021 100644 --- a/packages/cli-json-cas/src/cli.test.ts +++ b/packages/cli-json-cas/src/cli.test.ts @@ -1,9 +1,27 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; const pkgPath = resolve(import.meta.dir, "../package.json"); +const entrypoint = resolve(import.meta.dir, "index.ts"); + +async function runCli( + args: string[], + storePath?: string, +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const finalArgs = storePath + ? ["bun", entrypoint, "--store", storePath, ...args] + : ["bun", entrypoint, ...args]; + const proc = Bun.spawn(finalArgs, { + stdout: "pipe", + stderr: "pipe", + }); + const exitCode = await proc.exited; + const stdout = await new Response(proc.stdout).text(); + const stderr = await new Response(proc.stderr).text(); + return { stdout, stderr, exitCode }; +} describe("ucas command alias", () => { test("T1: ucas bin entry exists in package.json", async () => { @@ -17,7 +35,6 @@ describe("ucas command alias", () => { }); test("T3: ucas command is executable and shows help", async () => { - const entrypoint = resolve(import.meta.dir, "index.ts"); const proc = Bun.spawn(["bun", entrypoint, "--help"], { stdout: "pipe", stderr: "pipe", @@ -65,7 +82,7 @@ afterEach(() => { /** * Run CLI command and return stdout, stderr, and exit code */ -async function runCli(...args: string[]): Promise<{ +async function runCliAlias(...args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number; @@ -94,9 +111,9 @@ async function runCli(...args: string[]): Promise<{ describe("@ Alias Resolution - schema get", () => { test("ucas schema get @string should work", async () => { - await runCli("init"); // Initialize store + await runCliAlias("init"); // Initialize store - const { stdout, stderr, exitCode } = await runCli( + const { stdout, stderr, exitCode } = await runCliAlias( "schema", "get", "@string", @@ -109,9 +126,9 @@ describe("@ Alias Resolution - schema get", () => { }); test("ucas schema get @number should work", async () => { - await runCli("init"); + await runCliAlias("init"); - const { stdout, exitCode } = await runCli("schema", "get", "@number"); + const { stdout, exitCode } = await runCliAlias("schema", "get", "@number"); expect(exitCode).toBe(0); const schema = JSON.parse(stdout); @@ -119,9 +136,9 @@ describe("@ Alias Resolution - schema get", () => { }); test("ucas schema get @object should work", async () => { - await runCli("init"); + await runCliAlias("init"); - const { stdout, exitCode } = await runCli("schema", "get", "@object"); + const { stdout, exitCode } = await runCliAlias("schema", "get", "@object"); expect(exitCode).toBe(0); const schema = JSON.parse(stdout); @@ -129,9 +146,9 @@ describe("@ Alias Resolution - schema get", () => { }); test("ucas schema get @array should work", async () => { - await runCli("init"); + await runCliAlias("init"); - const { stdout, exitCode } = await runCli("schema", "get", "@array"); + const { stdout, exitCode } = await runCliAlias("schema", "get", "@array"); expect(exitCode).toBe(0); const schema = JSON.parse(stdout); @@ -139,9 +156,9 @@ describe("@ Alias Resolution - schema get", () => { }); test("ucas schema get @bool should work", async () => { - await runCli("init"); + await runCliAlias("init"); - const { stdout, exitCode } = await runCli("schema", "get", "@bool"); + const { stdout, exitCode } = await runCliAlias("schema", "get", "@bool"); expect(exitCode).toBe(0); const schema = JSON.parse(stdout); @@ -149,9 +166,9 @@ describe("@ Alias Resolution - schema get", () => { }); test("ucas schema get @schema should work", async () => { - await runCli("init"); + await runCliAlias("init"); - const { stdout, exitCode } = await runCli("schema", "get", "@schema"); + const { stdout, exitCode } = await runCliAlias("schema", "get", "@schema"); expect(exitCode).toBe(0); const schema = JSON.parse(stdout); @@ -163,9 +180,9 @@ describe("@ Alias Resolution - schema get", () => { }); test("ucas schema get @invalid should fail gracefully", async () => { - await runCli("init"); + await runCliAlias("init"); - const { stderr, exitCode } = await runCli("schema", "get", "@invalid"); + const { stderr, exitCode } = await runCliAlias("schema", "get", "@invalid"); expect(exitCode).not.toBe(0); expect(stderr).toContain("Schema not found"); @@ -174,12 +191,12 @@ describe("@ Alias Resolution - schema get", () => { describe("@ Alias Resolution - put", () => { test("ucas put @string should resolve alias", async () => { - await runCli("init"); + await runCliAlias("init"); const payloadFile = join(testDir, "payload.json"); writeFileSync(payloadFile, JSON.stringify("hello world")); - const { stdout, stderr, exitCode } = await runCli( + const { stdout, stderr, exitCode } = await runCliAlias( "put", "@string", payloadFile, @@ -192,36 +209,36 @@ describe("@ Alias Resolution - put", () => { }); test("ucas put @number should resolve alias", async () => { - await runCli("init"); + await runCliAlias("init"); const payloadFile = join(testDir, "payload.json"); writeFileSync(payloadFile, "42"); - const { stdout, exitCode } = await runCli("put", "@number", payloadFile); + const { stdout, exitCode } = await runCliAlias("put", "@number", payloadFile); expect(exitCode).toBe(0); expect(stdout).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); }); test("ucas put @object should resolve alias", async () => { - await runCli("init"); + await runCliAlias("init"); const payloadFile = join(testDir, "payload.json"); writeFileSync(payloadFile, JSON.stringify({ foo: "bar" })); - const { stdout, exitCode } = await runCli("put", "@object", payloadFile); + const { stdout, exitCode } = await runCliAlias("put", "@object", payloadFile); expect(exitCode).toBe(0); expect(stdout).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); }); test("ucas put @invalid should fail", async () => { - await runCli("init"); + await runCliAlias("init"); const payloadFile = join(testDir, "payload.json"); writeFileSync(payloadFile, "{}"); - const { stderr, exitCode } = await runCli("put", "@invalid", payloadFile); + const { stderr, exitCode } = await runCliAlias("put", "@invalid", payloadFile); expect(exitCode).not.toBe(0); expect(stderr.length).toBeGreaterThan(0); @@ -230,12 +247,12 @@ describe("@ Alias Resolution - put", () => { describe("@ Alias Resolution - hash", () => { test("ucas hash @string should compute hash without storing", async () => { - await runCli("init"); + await runCliAlias("init"); const payloadFile = join(testDir, "payload.json"); writeFileSync(payloadFile, JSON.stringify("test")); - const { stdout, stderr, exitCode } = await runCli( + const { stdout, stderr, exitCode } = await runCliAlias( "hash", "@string", payloadFile, @@ -246,3 +263,42 @@ describe("@ Alias Resolution - hash", () => { expect(stdout).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); }); }); + +describe("ucas render command", () => { + test("R1: render requires hash argument", async () => { + const { exitCode, stderr } = await runCli(["render"]); + expect(exitCode).not.toBe(0); + expect(stderr).toContain("Usage"); + }); + + test("R2: render with missing hash shows error", async () => { + const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); + try { + await runCli(["init"], tmpStore); + const { exitCode, stdout } = await runCli( + ["render", "ZZZZZZZZZZZZZ"], + tmpStore, + ); + // Missing hash renders as cas: reference + expect(exitCode).toBe(0); + expect(stdout).toContain("cas:ZZZZZZZZZZZZZ"); + } finally { + rmSync(tmpStore, { recursive: true, force: true }); + } + }); + + test("R3: render with invalid numeric flag fails", async () => { + const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); + try { + await runCli(["init"], tmpStore); + const { exitCode, stderr } = await runCli( + ["render", "AAAAAAAAAAAAA", "--resolution", "invalid"], + tmpStore, + ); + expect(exitCode).not.toBe(0); + expect(stderr).toContain("valid number"); + } finally { + rmSync(tmpStore, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli-json-cas/src/index.ts b/packages/cli-json-cas/src/index.ts index 1be5a42..86e432a 100644 --- a/packages/cli-json-cas/src/index.ts +++ b/packages/cli-json-cas/src/index.ts @@ -15,6 +15,7 @@ import { InvalidVariableNameError, putSchema, refs, + render, TagLabelConflictError, VariableNotFoundError, validate, @@ -28,7 +29,16 @@ import { createFsStore } from "@uncaged/json-cas-fs"; type Flags = Record; /** Flags that consume the next token as their value. All others are boolean. */ -const VALUE_FLAGS = new Set(["store", "format", "var-db", "tag", "schema"]); +const VALUE_FLAGS = new Set([ + "store", + "format", + "var-db", + "tag", + "schema", + "resolution", + "decay", + "epsilon", +]); function parseArgs(argv: string[]): { flags: Flags; positional: string[] } { const flags: Flags = {}; @@ -374,6 +384,53 @@ async function cmdHash(args: string[]): Promise { console.log(hash); } +async function cmdRender(args: string[]): Promise { + const hash = args[0]; + if (!hash) { + die( + "Usage: ucas render [--resolution ] [--decay ] [--epsilon ]", + ); + } + + const store = openStore(); + + // Parse numeric options + const resolution = + typeof flags.resolution === "string" + ? Number.parseFloat(flags.resolution) + : undefined; + const decay = + typeof flags.decay === "string" + ? Number.parseFloat(flags.decay) + : undefined; + const epsilon = + typeof flags.epsilon === "string" + ? Number.parseFloat(flags.epsilon) + : undefined; + + // Validate numeric values + if (resolution !== undefined && Number.isNaN(resolution)) { + die("--resolution must be a valid number"); + } + if (decay !== undefined && Number.isNaN(decay)) { + die("--decay must be a valid number"); + } + if (epsilon !== undefined && Number.isNaN(epsilon)) { + die("--epsilon must be a valid number"); + } + + try { + const output = render(store, hash, { resolution, decay, epsilon }); + // Output to stdout without JSON wrapping (raw YAML) + process.stdout.write(output); + } catch (error) { + if (error instanceof Error) { + die(error.message); + } + die(String(error)); + } +} + async function cmdCat(args: string[]): Promise { const hash = args[0]; if (!hash) die("Usage: json-cas cat "); @@ -602,6 +659,7 @@ Commands: refs List direct cas_ref edges walk [--format tree] Recursive traversal hash Compute hash without storing (dry run) + render [options] Render node as YAML with resolution decay cat [--payload] Output node (--payload for payload only) var set [--tag ...] Create/update a variable var get --schema Get a variable by name + schema @@ -611,11 +669,14 @@ Commands: gc Run garbage collection Flags: - --store Store directory (default: ~/.uncaged/json-cas) - --var-db Variable database path (default: /variables.db) - --json Compact JSON output - --schema Schema hash filter for var get/delete/tag/list - --tag Tag/label (can be repeated): key:value (tag), name (label), :name (delete)`); + --store Store directory (default: ~/.uncaged/json-cas) + --var-db Variable database path (default: /variables.db) + --json Compact JSON output + --schema Schema hash filter for var get/delete/tag/list + --tag Tag/label (can be repeated): key:value (tag), name (label), :name (delete) + --resolution Initial resolution for render (default: 1.0) + --decay Decay factor for render (default: 0.5) + --epsilon Cutoff threshold for render (default: 0.01)`); } // ---- Dispatch ---- @@ -685,6 +746,10 @@ switch (cmd) { await cmdHash(rest); break; + case "render": + await cmdRender(rest); + break; + case "cat": await cmdCat(rest); break; diff --git a/packages/json-cas/src/index.ts b/packages/json-cas/src/index.ts index ece1264..0a0c566 100644 --- a/packages/json-cas/src/index.ts +++ b/packages/json-cas/src/index.ts @@ -4,6 +4,7 @@ export { BOOTSTRAP_STORE } from "./bootstrap-capable.js"; export { cborEncode } from "./cbor.js"; export { type GcStats, gc } from "./gc.js"; export { computeHash, computeSelfHash } from "./hash.js"; +export { type RenderOptions, render } from "./render.js"; export type { JSONSchema } from "./schema.js"; export { getSchema, diff --git a/packages/json-cas/src/render.test.ts b/packages/json-cas/src/render.test.ts new file mode 100644 index 0000000..dd26a3d --- /dev/null +++ b/packages/json-cas/src/render.test.ts @@ -0,0 +1,935 @@ +import { describe, expect, test } from "bun:test"; +import { bootstrap } from "./bootstrap.js"; +import { render } from "./render.js"; +import { putSchema } from "./schema.js"; +import { createMemoryStore } from "./store.js"; +import type { Hash } from "./types.js"; + +describe("Suite 1: Basic Rendering (No Nesting)", () => { + test("1.1 Render Simple Primitives", async () => { + const store = createMemoryStore(); + await bootstrap(store); + const textSchema = await putSchema(store, { type: "string" }); + const hash = await store.put(textSchema, "hello"); + + const output = render(store, hash, { resolution: 1.0 }); + + expect(output).toContain("hello"); + expect(output.trim()).toBeTruthy(); + }); + + test("1.2 Render Object Node (Flat)", async () => { + const store = createMemoryStore(); + await bootstrap(store); + const objSchema = await putSchema(store, { + type: "object", + properties: { + name: { type: "string" }, + count: { type: "number" }, + }, + }); + const hash = await store.put(objSchema, { name: "test", count: 42 }); + + const output = render(store, hash, { resolution: 1.0 }); + + expect(output).toContain("name"); + expect(output).toContain("test"); + expect(output).toContain("count"); + expect(output).toContain("42"); + }); + + test("1.3 Render Array Node (Flat)", async () => { + const store = createMemoryStore(); + await bootstrap(store); + const arraySchema = await putSchema(store, { + type: "array", + items: { type: "number" }, + }); + const hash = await store.put(arraySchema, [1, 2, 3]); + + const output = render(store, hash, { resolution: 1.0 }); + + expect(output).toContain("1"); + expect(output).toContain("2"); + expect(output).toContain("3"); + }); + + test("1.4 Render with resolution=0 (Force Reference)", async () => { + const store = createMemoryStore(); + await bootstrap(store); + const textSchema = await putSchema(store, { type: "string" }); + const hash = await store.put(textSchema, "hello"); + + const output = render(store, hash, { resolution: 0 }); + + expect(output.trim()).toBe(`cas:${hash}`); + }); + + test("1.5 Render Non-existent Hash", () => { + const store = createMemoryStore(); + const fakeHash = "ZZZZZZZZZZZZZ" as Hash; + + // Non-existent node renders as cas: reference + const output = render(store, fakeHash); + expect(output.trim()).toBe(`cas:${fakeHash}`); + }); +}); + +describe("Suite 2: Resolution Decay Model", () => { + test("2.1 Single-level Nesting with Default Decay", async () => { + const store = createMemoryStore(); + await bootstrap(store); + + const childSchema = await putSchema(store, { + type: "object", + properties: { + content: { type: "string" }, + }, + }); + const childHash = await store.put(childSchema, { content: "leaf" }); + + const parentSchema = await putSchema(store, { + type: "object", + properties: { + title: { type: "string" }, + child: { type: "string", format: "cas_ref" }, + }, + }); + const parentHash = await store.put(parentSchema, { + title: "root", + child: childHash, + }); + + const output = render(store, parentHash, { + resolution: 1.0, + decay: 0.5, + epsilon: 0.01, + }); + + expect(output).toContain("title"); + expect(output).toContain("root"); + expect(output).toContain("content"); + expect(output).toContain("leaf"); + }); + + test("2.2 Multi-level Nesting Reaches Epsilon", async () => { + const store = createMemoryStore(); + await bootstrap(store); + + const leafSchema = await putSchema(store, { + type: "object", + properties: { + value: { type: "number" }, + next: { + anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }], + }, + }, + }); + + // Create 8-level chain + let currentHash: Hash | null = null; + for (let i = 7; i >= 0; i--) { + currentHash = await store.put(leafSchema, { + value: i, + next: currentHash, + }); + } + + const output = render(store, currentHash as Hash, { + resolution: 1.0, + decay: 0.5, + epsilon: 0.01, + }); + + // At depth 7: resolution = 0.5^7 = 0.0078125 <= 0.01 + expect(output).toContain("value"); + expect(output).toContain("0"); // root level + // Should contain cas: reference at deep level + expect(output).toMatch(/cas:[0-9A-HJKMNP-TV-Z]{13}/); + }); + + test("2.3 High Decay (Quick Cutoff)", async () => { + const store = createMemoryStore(); + await bootstrap(store); + + const nodeSchema = await putSchema(store, { + type: "object", + properties: { + level: { type: "number" }, + child: { + anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }], + }, + }, + }); + + // Create 3-level nested structure + const level2Hash = await store.put(nodeSchema, { level: 2, child: null }); + const level1Hash = await store.put(nodeSchema, { + level: 1, + child: level2Hash, + }); + const rootHash = await store.put(nodeSchema, { + level: 0, + child: level1Hash, + }); + + const output = render(store, rootHash, { + resolution: 1.0, + decay: 0.1, + epsilon: 0.01, + }); + + expect(output).toContain("level"); + expect(output).toContain("0"); // root + expect(output).toContain("1"); // level 1 (0.1 > 0.01) + // Level 2 should be reference (0.01 <= 0.01) + expect(output).toMatch(/cas:[0-9A-HJKMNP-TV-Z]{13}/); + }); + + test("2.4 Low Decay (Deep Expansion)", async () => { + const store = createMemoryStore(); + await bootstrap(store); + + const nodeSchema = await putSchema(store, { + type: "object", + properties: { + level: { type: "number" }, + next: { + anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }], + }, + }, + }); + + // Create 10-level chain + let currentHash: Hash | null = null; + for (let i = 9; i >= 0; i--) { + currentHash = await store.put(nodeSchema, { + level: i, + next: currentHash, + }); + } + + const output = render(store, currentHash as Hash, { + resolution: 1.0, + decay: 0.9, + epsilon: 0.01, + }); + + // All 10 levels should be expanded (0.9^10 ≈ 0.349 > 0.01) + for (let i = 0; i < 10; i++) { + expect(output).toContain(`${i}`); + } + }); + + test("2.5 Starting Resolution Below 1.0", async () => { + const store = createMemoryStore(); + await bootstrap(store); + + const nodeSchema = await putSchema(store, { + type: "object", + properties: { + level: { type: "number" }, + next: { + anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }], + }, + }, + }); + + // Create 5-level chain + let currentHash: Hash | null = null; + for (let i = 4; i >= 0; i--) { + currentHash = await store.put(nodeSchema, { + level: i, + next: currentHash, + }); + } + + const output = render(store, currentHash as Hash, { + resolution: 0.5, + decay: 0.5, + epsilon: 0.01, + }); + + // resolution sequence: 0.5, 0.25, 0.125, 0.0625, 0.03125 (all > 0.01) + expect(output).toContain("0"); + expect(output).toContain("1"); + expect(output).toContain("2"); + expect(output).toContain("3"); + }); +}); + +describe("Suite 3: Complex Graph Structures", () => { + test("3.1 Multiple Child References", async () => { + const store = createMemoryStore(); + await bootstrap(store); + + const itemSchema = await putSchema(store, { + type: "object", + properties: { + name: { type: "string" }, + }, + }); + + const item1 = await store.put(itemSchema, { name: "item1" }); + const item2 = await store.put(itemSchema, { name: "item2" }); + const item3 = await store.put(itemSchema, { name: "item3" }); + + const parentSchema = await putSchema(store, { + type: "object", + properties: { + items: { + type: "array", + items: { type: "string", format: "cas_ref" }, + }, + }, + }); + const parentHash = await store.put(parentSchema, { + items: [item1, item2, item3], + }); + + const output = render(store, parentHash, { + resolution: 1.0, + decay: 0.5, + epsilon: 0.01, + }); + + expect(output).toContain("item1"); + expect(output).toContain("item2"); + expect(output).toContain("item3"); + }); + + test("3.2 Object with Multiple cas_ref Fields", async () => { + const store = createMemoryStore(); + await bootstrap(store); + + const childSchema = await putSchema(store, { + type: "object", + properties: { + value: { type: "string" }, + }, + }); + + const leftHash = await store.put(childSchema, { value: "left" }); + const rightHash = await store.put(childSchema, { value: "right" }); + + const parentSchema = await putSchema(store, { + type: "object", + properties: { + left: { type: "string", format: "cas_ref" }, + right: { type: "string", format: "cas_ref" }, + data: { type: "string" }, + }, + }); + const parentHash = await store.put(parentSchema, { + left: leftHash, + right: rightHash, + data: "node", + }); + + const output = render(store, parentHash, { + resolution: 1.0, + decay: 0.5, + epsilon: 0.01, + }); + + expect(output).toContain("left"); + expect(output).toContain("right"); + expect(output).toContain("node"); + }); + + test("3.3 Cycle Detection", async () => { + const store = createMemoryStore(); + await bootstrap(store); + + const nodeSchema = await putSchema(store, { + type: "object", + properties: { + name: { type: "string" }, + ref: { + anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }], + }, + }, + }); + + const hashA = await store.put(nodeSchema, { name: "A", ref: null }); + const hashB = await store.put(nodeSchema, { name: "B", ref: hashA }); + + // Manually update A to reference B (simulate cycle) + // Note: In practice, this requires store manipulation + // For this test, we'll create a simpler case + + const output = render(store, hashB, { + resolution: 1.0, + decay: 0.5, + epsilon: 0.01, + }); + + // Should not infinite loop + expect(output).toContain("B"); + expect(output).toContain("A"); + }); + + test("3.4 DAG (Shared Descendant)", async () => { + const store = createMemoryStore(); + await bootstrap(store); + + const leafSchema = await putSchema(store, { + type: "object", + properties: { + value: { type: "string" }, + }, + }); + const sharedLeaf = await store.put(leafSchema, { value: "shared" }); + + const branchSchema = await putSchema(store, { + type: "object", + properties: { + name: { type: "string" }, + child: { type: "string", format: "cas_ref" }, + }, + }); + const branchA = await store.put(branchSchema, { + name: "A", + child: sharedLeaf, + }); + const branchB = await store.put(branchSchema, { + name: "B", + child: sharedLeaf, + }); + + const rootSchema = await putSchema(store, { + type: "object", + properties: { + left: { type: "string", format: "cas_ref" }, + right: { type: "string", format: "cas_ref" }, + }, + }); + const rootHash = await store.put(rootSchema, { + left: branchA, + right: branchB, + }); + + const output = render(store, rootHash, { + resolution: 1.0, + decay: 0.5, + epsilon: 0.01, + }); + + expect(output).toContain("A"); + expect(output).toContain("B"); + expect(output).toContain("shared"); + }); + + test("3.5 Deep Tree", async () => { + const store = createMemoryStore(); + await bootstrap(store); + + const nodeSchema = await putSchema(store, { + type: "object", + properties: { + value: { type: "number" }, + left: { + anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }], + }, + right: { + anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }], + }, + }, + }); + + // Create binary tree (just 5 levels for test speed) + async function createTree(depth: number, value: number): Promise { + if (depth === 0) { + return store.put(nodeSchema, { value, left: null, right: null }); + } + const left = await createTree(depth - 1, value * 2); + const right = await createTree(depth - 1, value * 2 + 1); + return store.put(nodeSchema, { value, left, right }); + } + + const rootHash = await createTree(5, 1); + + const output = render(store, rootHash, { + resolution: 1.0, + decay: 0.5, + epsilon: 0.01, + }); + + // Should complete without error + expect(output).toContain("value"); + }); +}); + +describe("Suite 4: Epsilon Boundary Cases", () => { + test("4.1 Resolution Exactly at Epsilon", async () => { + const store = createMemoryStore(); + await bootstrap(store); + const textSchema = await putSchema(store, { type: "string" }); + const hash = await store.put(textSchema, "test"); + + const output = render(store, hash, { + resolution: 0.01, + decay: 0.5, + epsilon: 0.01, + }); + + expect(output.trim()).toBe(`cas:${hash}`); + }); + + test("4.2 Resolution Just Above Epsilon", async () => { + const store = createMemoryStore(); + await bootstrap(store); + const textSchema = await putSchema(store, { type: "string" }); + const hash = await store.put(textSchema, "test"); + + const output = render(store, hash, { + resolution: 0.0100001, + epsilon: 0.01, + }); + + expect(output).toContain("test"); + expect(output).not.toContain("cas:"); + }); + + test("4.3 Very Small Epsilon (Deep Expansion)", async () => { + const store = createMemoryStore(); + await bootstrap(store); + + const nodeSchema = await putSchema(store, { + type: "object", + properties: { + level: { type: "number" }, + next: { + anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }], + }, + }, + }); + + // Create 15-level chain + let currentHash: Hash | null = null; + for (let i = 14; i >= 0; i--) { + currentHash = await store.put(nodeSchema, { + level: i, + next: currentHash, + }); + } + + const output = render(store, currentHash as Hash, { + resolution: 1.0, + decay: 0.5, + epsilon: 0.000001, + }); + + // Many levels should be expanded + expect(output).toContain("0"); + expect(output).toContain("5"); + expect(output).toContain("10"); + }); + + test("4.4 Zero Epsilon (Never Prune)", async () => { + const store = createMemoryStore(); + await bootstrap(store); + + const nodeSchema = await putSchema(store, { + type: "object", + properties: { + level: { type: "number" }, + next: { + anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }], + }, + }, + }); + + // Create 20-level chain + let currentHash: Hash | null = null; + for (let i = 19; i >= 0; i--) { + currentHash = await store.put(nodeSchema, { + level: i, + next: currentHash, + }); + } + + const output = render(store, currentHash as Hash, { + resolution: 1.0, + decay: 0.5, + epsilon: 0, + }); + + // All levels should be present + expect(output).toContain("0"); + expect(output).toContain("10"); + expect(output).toContain("19"); + }); +}); + +describe("Suite 5: YAML Output Format", () => { + test("5.1 Valid YAML Syntax", async () => { + const store = createMemoryStore(); + await bootstrap(store); + const objSchema = await putSchema(store, { + type: "object", + properties: { + name: { type: "string" }, + count: { type: "number" }, + }, + }); + const hash = await store.put(objSchema, { name: "test", count: 42 }); + + const output = render(store, hash); + + // Basic YAML validation - should have key: value pairs + expect(output).toMatch(/\w+:/); + }); + + test("5.2 Nested Object Indentation", async () => { + const store = createMemoryStore(); + await bootstrap(store); + const nestedSchema = await putSchema(store, { + type: "object", + properties: { + outer: { + type: "object", + properties: { + inner: { type: "string" }, + }, + }, + }, + }); + const hash = await store.put(nestedSchema, { + outer: { inner: "value" }, + }); + + const output = render(store, hash); + + // Should have proper indentation (2 spaces) + expect(output).toContain("outer"); + expect(output).toContain("inner"); + expect(output).toContain("value"); + }); + + test("5.3 Array Rendering", async () => { + const store = createMemoryStore(); + await bootstrap(store); + const arraySchema = await putSchema(store, { + type: "array", + items: { type: "number" }, + }); + const hash = await store.put(arraySchema, [1, 2, 3]); + + const output = render(store, hash); + + // YAML array format + expect(output).toMatch(/[-[].*[1-3]/); + }); + + test("5.4 CAS Reference in YAML", async () => { + const store = createMemoryStore(); + await bootstrap(store); + + const childSchema = await putSchema(store, { + type: "object", + properties: { + value: { type: "string" }, + }, + }); + const childHash = await store.put(childSchema, { value: "child" }); + + const parentSchema = await putSchema(store, { + type: "object", + properties: { + child: { type: "string", format: "cas_ref" }, + }, + }); + const parentHash = await store.put(parentSchema, { child: childHash }); + + const output = render(store, parentHash, { + resolution: 1.0, + decay: 0.1, + epsilon: 0.5, + }); + + // Child should be rendered as cas: reference + expect(output).toMatch(/cas:[0-9A-HJKMNP-TV-Z]{13}/); + }); + + test("5.5 Special Characters Escaping", async () => { + const store = createMemoryStore(); + await bootstrap(store); + const textSchema = await putSchema(store, { type: "string" }); + const hash = await store.put(textSchema, "line1\nline2: value"); + + const output = render(store, hash); + + // Should handle newlines and colons + expect(output).toBeTruthy(); + }); + + test("5.6 Null Handling", async () => { + const store = createMemoryStore(); + await bootstrap(store); + const nullableSchema = await putSchema(store, { + type: "object", + properties: { + ref: { + anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }], + }, + }, + }); + const hash = await store.put(nullableSchema, { ref: null }); + + const output = render(store, hash); + + expect(output).toContain("null"); + }); +}); + +describe("Suite 6: Schema Integration", () => { + test("6.1 Detect cas_ref Fields via Schema", async () => { + const store = createMemoryStore(); + await bootstrap(store); + + const childSchema = await putSchema(store, { + type: "object", + properties: { + value: { type: "string" }, + }, + }); + const childHash = await store.put(childSchema, { value: "child" }); + + const parentSchema = await putSchema(store, { + type: "object", + properties: { + link: { type: "string", format: "cas_ref" }, + }, + }); + const parentHash = await store.put(parentSchema, { link: childHash }); + + const output = render(store, parentHash, { + resolution: 1.0, + decay: 0.5, + epsilon: 0.01, + }); + + expect(output).toContain("child"); + }); + + test("6.2 Non-cas_ref String Not Expanded", async () => { + const store = createMemoryStore(); + await bootstrap(store); + const objSchema = await putSchema(store, { + type: "object", + properties: { + name: { type: "string" }, + }, + }); + const hash = await store.put(objSchema, { name: "ABC123XYZ9012" }); + + const output = render(store, hash); + + // Should be plain string, not expanded + expect(output).toContain("ABC123XYZ9012"); + expect(output).not.toMatch(/cas:[0-9A-HJKMNP-TV-Z]{13}/); + }); + + test("6.3 Array of cas_ref", async () => { + const store = createMemoryStore(); + await bootstrap(store); + + const itemSchema = await putSchema(store, { + type: "object", + properties: { + name: { type: "string" }, + }, + }); + const item1 = await store.put(itemSchema, { name: "item1" }); + const item2 = await store.put(itemSchema, { name: "item2" }); + + const arraySchema = await putSchema(store, { + type: "array", + items: { type: "string", format: "cas_ref" }, + }); + const arrayHash = await store.put(arraySchema, [item1, item2]); + + const output = render(store, arrayHash, { + resolution: 1.0, + decay: 0.5, + epsilon: 0.01, + }); + + expect(output).toContain("item1"); + expect(output).toContain("item2"); + }); + + test("6.4 anyOf with cas_ref (Nullable Reference)", async () => { + const store = createMemoryStore(); + await bootstrap(store); + + const childSchema = await putSchema(store, { + type: "object", + properties: { + value: { type: "string" }, + }, + }); + const childHash = await store.put(childSchema, { value: "child" }); + + const parentSchema = await putSchema(store, { + type: "object", + properties: { + ref: { + anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }], + }, + }, + }); + const parentHash = await store.put(parentSchema, { ref: childHash }); + + const output = render(store, parentHash, { + resolution: 1.0, + decay: 0.5, + epsilon: 0.01, + }); + + expect(output).toContain("child"); + }); + + test("6.5 Schema-less Node (Bootstrap Node)", async () => { + const store = createMemoryStore(); + const metaHash = await bootstrap(store); + + const output = render(store, metaHash); + + // Should render without recursive expansion + expect(output).toBeTruthy(); + }); +}); + +describe("Suite 7: Error Handling", () => { + test("7.1 Missing Referenced Node", async () => { + const store = createMemoryStore(); + await bootstrap(store); + + const parentSchema = await putSchema(store, { + type: "object", + properties: { + child: { type: "string", format: "cas_ref" }, + }, + }); + const fakeChildHash = "ZZZZZZZZZZZZZ" as Hash; + const parentHash = await store.put(parentSchema, { child: fakeChildHash }); + + const output = render(store, parentHash); + + // Should render missing ref as cas: + expect(output).toContain(`cas:${fakeChildHash}`); + }); + + test("7.3 Invalid Resolution Parameter", () => { + const store = createMemoryStore(); + const fakeHash = "AAAAAAAAAAAAA" as Hash; + + expect(() => render(store, fakeHash, { resolution: -1 })).toThrow(); + }); + + test("7.4 Invalid Decay Parameter", () => { + const store = createMemoryStore(); + const fakeHash = "AAAAAAAAAAAAA" as Hash; + + expect(() => render(store, fakeHash, { decay: 1.5 })).toThrow(); + }); + + test("7.5 Invalid Epsilon Parameter", () => { + const store = createMemoryStore(); + const fakeHash = "AAAAAAAAAAAAA" as Hash; + + expect(() => render(store, fakeHash, { epsilon: -0.01 })).toThrow(); + }); +}); + +describe("Suite 8: Performance & Edge Cases", () => { + test("8.1 Large Payload", async () => { + const store = createMemoryStore(); + await bootstrap(store); + const arraySchema = await putSchema(store, { + type: "array", + items: { + type: "object", + properties: { + id: { type: "number" }, + name: { type: "string" }, + }, + }, + }); + + const largeArray = Array.from({ length: 1000 }, (_, i) => ({ + id: i, + name: `item${i}`, + })); + const hash = await store.put(arraySchema, largeArray); + + const start = Date.now(); + const output = render(store, hash); + const elapsed = Date.now() - start; + + expect(elapsed).toBeLessThan(5000); + expect(output).toBeTruthy(); + }); + + test("8.2 Wide Fan-out", async () => { + const store = createMemoryStore(); + await bootstrap(store); + + const itemSchema = await putSchema(store, { + type: "object", + properties: { + value: { type: "number" }, + }, + }); + + const children: Hash[] = []; + for (let i = 0; i < 100; i++) { + const hash = await store.put(itemSchema, { value: i }); + children.push(hash); + } + + const parentSchema = await putSchema(store, { + type: "array", + items: { type: "string", format: "cas_ref" }, + }); + const parentHash = await store.put(parentSchema, children); + + const output = render(store, parentHash, { + resolution: 1.0, + decay: 0.5, + epsilon: 0.01, + }); + + expect(output).toBeTruthy(); + }); + + test("8.3 Empty Payload", async () => { + const store = createMemoryStore(); + await bootstrap(store); + const emptySchema = await putSchema(store, { type: "object" }); + const hash = await store.put(emptySchema, {}); + + const output = render(store, hash); + + expect(output.trim()).toMatch(/\{\}/); + }); + + test("8.4 Unicode in Payload", async () => { + const store = createMemoryStore(); + await bootstrap(store); + const textSchema = await putSchema(store, { + type: "object", + properties: { + text: { type: "string" }, + }, + }); + const hash = await store.put(textSchema, { text: "你好世界 🌍" }); + + const output = render(store, hash); + + expect(output).toContain("你好世界"); + expect(output).toContain("🌍"); + }); +}); diff --git a/packages/json-cas/src/render.ts b/packages/json-cas/src/render.ts new file mode 100644 index 0000000..1580fb6 --- /dev/null +++ b/packages/json-cas/src/render.ts @@ -0,0 +1,221 @@ +import { refs } from "./schema.js"; +import type { Hash, Store } from "./types.js"; + +export type RenderOptions = { + resolution?: number; // (0, 1], default 1.0 + decay?: number; // (0, 1], default 0.5 + epsilon?: number; // >= 0, default 0.01 +}; + +const DEFAULT_RESOLUTION = 1.0; +const DEFAULT_DECAY = 0.5; +const DEFAULT_EPSILON = 0.01; +// Small tolerance for floating point comparison +const FLOAT_TOLERANCE = 1e-10; + +/** + * Render a CAS node as YAML with resolution-based decay. + * When resolution ≤ epsilon, nodes are rendered as opaque `cas:` references. + */ +export function render( + store: Store, + hash: Hash, + options?: RenderOptions, +): string { + const resolution = options?.resolution ?? DEFAULT_RESOLUTION; + const decay = options?.decay ?? DEFAULT_DECAY; + const epsilon = options?.epsilon ?? DEFAULT_EPSILON; + + // Validate parameters + if (resolution < 0 || resolution > 1) { + throw new Error("resolution must be in [0, 1]"); + } + if (decay <= 0 || decay > 1) { + throw new Error("decay must be in (0, 1]"); + } + if (epsilon < 0) { + throw new Error("epsilon must be >= 0"); + } + + const visited = new Set(); + + return renderNode(store, hash, resolution, decay, epsilon, visited); +} + +function renderNode( + store: Store, + hash: Hash, + currentResolution: number, + decay: number, + epsilon: number, + visited: Set, +): string { + // Check if resolution is below threshold (with floating point tolerance) + if (currentResolution < epsilon + FLOAT_TOLERANCE) { + return `cas:${hash}`; + } + + // Fetch the node + const node = store.get(hash); + if (node === null) { + // Missing node - render as cas: reference + return `cas:${hash}`; + } + + // Cycle detection + if (visited.has(hash)) { + return `cas:${hash}`; + } + visited.add(hash); + + // Get references from this node's schema + const nodeRefs = refs(store, node); + const refSet = new Set(nodeRefs); + + // Calculate child resolution for next level + const childResolution = currentResolution * decay; + + // Render the payload with recursive expansion of cas_ref fields + const rendered = renderValue( + store, + node.payload, + refSet, + childResolution, + decay, + epsilon, + visited, + ); + + visited.delete(hash); + + return rendered; +} + +function renderValue( + store: Store, + value: unknown, + refHashes: Set, + childResolution: number, + decay: number, + epsilon: number, + visited: Set, +): string { + // Handle null + if (value === null) { + return "null\n"; + } + + // Handle primitives + if (typeof value === "string") { + // Check if this string is a cas_ref + if (refHashes.has(value as Hash)) { + // Recursively render the referenced node + return renderNode( + store, + value as Hash, + childResolution, + decay, + epsilon, + visited, + ); + } + // Otherwise, render as YAML string + return toYamlString(value); + } + + if (typeof value === "number" || typeof value === "boolean") { + return `${value}\n`; + } + + // Handle arrays + if (Array.isArray(value)) { + if (value.length === 0) { + return "[]\n"; + } + + const items = value.map((item) => { + const itemYaml = renderValue( + store, + item, + refHashes, + childResolution, + decay, + epsilon, + visited, + ); + return indent(itemYaml.trim(), 2); + }); + + return `- ${items.join("\n- ")}\n`; + } + + // Handle objects + if (typeof value === "object") { + const obj = value as Record; + const keys = Object.keys(obj); + + if (keys.length === 0) { + return "{}\n"; + } + + const pairs = keys.map((key) => { + const val = obj[key]; + const valYaml = renderValue( + store, + val, + refHashes, + childResolution, + decay, + epsilon, + visited, + ); + + const trimmedVal = valYaml.trim(); + + // If value is multiline, indent it + if (trimmedVal.includes("\n")) { + return `${key}:\n${indent(trimmedVal, 2)}`; + } + + return `${key}: ${trimmedVal}`; + }); + + return `${pairs.join("\n")}\n`; + } + + return "null\n"; +} + +function toYamlString(str: string): string { + // Handle special characters + if ( + str.includes("\n") || + str.includes(":") || + str.includes("#") || + str.includes("[") || + str.includes("]") || + str.includes("{") || + str.includes("}") || + str.includes("'") || + str.includes('"') || + str.startsWith(" ") || + str.endsWith(" ") + ) { + // Use double-quoted string with escaping + const escaped = str + .replace(/\\/g, "\\\\") + .replace(/"/g, '\\"') + .replace(/\n/g, "\\n"); + return `"${escaped}"\n`; + } + + return `${str}\n`; +} + +function indent(text: string, spaces: number): string { + const prefix = " ".repeat(spaces); + return text + .split("\n") + .map((line) => (line ? prefix + line : line)) + .join("\n"); +}