Files
deepseek-harness/scripts/persistence-schema-model.ts
T
Tianyi Cui a6981c56f6 feat(persistence): add stable catalogs and policy-aware schema review
Traversal ordinals and first-owner paths gave shared persistence types
unstable display names. Keep authored names and source locations as metadata,
use structural digests for identity, and render complete kind/form groups
with bounded array labels. Preserve historical rendering and stored graphs.

Record supported source compatibility promises with their property binding,
policy version, discriminator, preservation rule, and qualified kind set.
The extractor and classifier require explicit opt-ins in both compared
schemas; ordinary or unsupported changes retain strict classification.
Policy-bearing graphs use the format-2 fingerprint domain while unannotated
graphs keep their format-1 fingerprints. Runtime owners activate bindings
only with the corresponding reader behavior; this tooling layer activates
none and changes no package source or Session generation.

Provide a read-only before/after review command that groups shared structural
evidence without replacing authoritative compatibility diagnostics. Match
changed alternatives only through unique literal discriminators and retain
ambiguous additions and removals as independent facts.

At the X baseline, all 61 roots and 472 schemas, digests, and source fields
are unchanged. The inventory remains format 1 and removes only 296 synthetic
names. Existing acknowledgement history still matches every root. All 195
focused tooling tests, Host build/full lint, and bilingual pairing pass.
2026-09-18 19:05:32 +08:00

274 lines
11 KiB
TypeScript

/**
* Source-independent JSON type graphs and canonical persistence fingerprints.
* Numeric references are local to one graph; names and source locations are metadata.
*/
import { createHash } from 'node:crypto'
/** Recorded reader promise for explicitly attributed source additions. */
export interface SourceCompatibility {
readonly version: 1
readonly policy: 'session-source-attribution'
readonly binding: 'session.user-message.source' | 'session.developer-message.source'
readonly discriminator: 'kind'
readonly unknownKinds: 'preserve'
readonly attributionKinds: readonly string[]
}
/** One JSON property, with absence represented independently of its value type. */
export interface SchemaProperty {
readonly name: string
readonly type: number
readonly optional: boolean
readonly compatibility?: SourceCompatibility
}
/** One positional tuple element. */
export interface SchemaTupleElement {
readonly type: number
readonly optional: boolean
readonly rest: boolean
}
/** A resolved persisted type; references address nodes in the enclosing graph. */
export type SchemaNode =
| { readonly kind: 'primitive'; readonly type: 'null' | 'boolean' | 'number' | 'string' | 'never' }
| { readonly kind: 'literal'; readonly value: string | number | boolean }
| { readonly kind: 'opaque'; readonly reason: 'any' | 'unknown' }
| { readonly kind: 'array'; readonly element: number }
| { readonly kind: 'tuple'; readonly elements: readonly SchemaTupleElement[] }
| { readonly kind: 'object'; readonly properties: readonly SchemaProperty[]; readonly indices: readonly { readonly key: number; readonly value: number }[] }
| { readonly kind: 'union'; readonly types: readonly number[] }
/** A self-contained minimized graph with deterministic root-first node numbering. */
export interface CanonicalSchema {
readonly root: 0
readonly nodes: readonly SchemaNode[]
}
/** One independently tracked persistent record vocabulary. */
export interface PersistenceRoot {
readonly key: string
readonly kind: 'header' | 'envelope' | 'event'
readonly event?: string
readonly surface?: boolean
readonly digest: string
readonly schema: CanonicalSchema
}
/** One reachable structural type and its non-fingerprinted source declaration metadata. */
export interface PersistenceType {
readonly digest: string
readonly schema: CanonicalSchema
readonly names: readonly string[]
readonly sources: readonly string[]
}
/** Complete current-source persistence inventory; version pins normalization too. */
export interface PersistenceSchemaInventory {
readonly formatVersion: 1 | 2
readonly roots: readonly PersistenceRoot[]
readonly types: readonly PersistenceType[]
}
/**
* Visit direct graph edges in their normalized semantic order.
* @param node - resolved graph node.
* @returns referenced node indices, including repeated ordered edges.
*/
export function schemaChildren(node: SchemaNode): number[] {
switch (node.kind) {
case 'array': return [node.element]
case 'tuple': return node.elements.map(element => element.type)
case 'object': return [...node.properties.map(property => property.type), ...node.indices.flatMap(index => [index.key, index.value])]
case 'union': return [...node.types]
case 'primitive':
case 'literal':
case 'opaque': return []
default: return assertNever(node)
}
}
function mapNode(node: SchemaNode, ref: (id: number) => number): SchemaNode {
switch (node.kind) {
case 'array': return { kind: 'array', element: ref(node.element) }
case 'tuple': return { kind: 'tuple', elements: node.elements.map(element => ({ type: ref(element.type), optional: element.optional, rest: element.rest })) }
case 'object': return {
kind: 'object',
properties: [...node.properties].sort((left, right) => compare(left.name, right.name))
.map(property => ({ name: property.name, type: ref(property.type), optional: property.optional,
...(property.compatibility === undefined ? {} : { compatibility: {
version: property.compatibility.version, policy: property.compatibility.policy, binding: property.compatibility.binding,
discriminator: property.compatibility.discriminator, unknownKinds: property.compatibility.unknownKinds,
attributionKinds: [...new Set(property.compatibility.attributionKinds)].sort(compare),
} }),
})),
indices: node.indices.map(index => ({ key: ref(index.key), value: ref(index.value) }))
.sort((left, right) => left.key - right.key || left.value - right.value),
}
case 'union': return { kind: 'union', types: [...new Set(node.types.map(ref))].sort((left, right) => left - right) }
case 'primitive': return { kind: 'primitive', type: node.type }
case 'literal': return { kind: 'literal', value: node.value }
case 'opaque': return { kind: 'opaque', reason: node.reason }
default: return assertNever(node)
}
}
function partition(nodes: readonly SchemaNode[]): number[] {
let colors = nodes.map(() => 0)
for (;;) {
const signatures = nodes.map((node, index) => JSON.stringify([colors[index], mapNode(node, id => colors[id] as number)]))
const ordered = [...new Set(signatures)].sort(compare)
const ids = new Map(ordered.map((value, index) => [value, index]))
const next = signatures.map(value => ids.get(value) as number)
const unchanged = new Set(next).size === new Set(colors).size
colors = next
if (unchanged) return colors
}
}
/**
* Minimize bisimilar recursive nodes and number the reachable graph deterministically.
* @param input - resolved nodes; property and union order may be arbitrary.
* @param root - index of the requested root.
* @returns canonical graph excluding unreachable nodes and duplicate structures.
*/
export function canonicalizeSchema(input: readonly SchemaNode[], root: number): CanonicalSchema {
input = normalizeUnions(input)
const selected: number[] = []
const positions = new Map<number, number>()
const select = (id: number): void => {
if (!Number.isInteger(id) || id < 0 || id >= input.length) throw new Error(`persistence schema: missing node ${String(id)}`)
if (positions.has(id)) return
positions.set(id, selected.length)
selected.push(id)
for (const child of schemaChildren(input[id] as SchemaNode)) select(child)
}
select(root)
let nodes = selected.map(id => mapNode(input[id] as SchemaNode, child => positions.get(child) as number))
let rootIndex = 0
for (;;) {
const colors = partition(nodes)
const representatives = new Map<number, number>()
colors.forEach((color, index) => { if (!representatives.has(color)) representatives.set(color, index) })
const aliases = new Map<number, number>()
nodes.forEach((node, index) => {
if (node.kind !== 'union') return
const members = [...new Set(node.types.map(child => colors[child] as number))]
if (members.length === 1) aliases.set(index, representatives.get(members[0] as number) as number)
})
if (aliases.size > 0) {
const resolve = (id: number): number => {
const seen = new Set<number>()
while (aliases.has(id)) {
if (seen.has(id)) throw new Error('persistence schema: union cycle has no material type')
seen.add(id)
id = aliases.get(id) as number
}
return id
}
rootIndex = resolve(rootIndex)
nodes = nodes.map((node, index) => aliases.has(index) ? nodes[resolve(index)] as SchemaNode : mapNode(node, resolve))
continue
}
const emitted = new Map<number, number>()
const result: SchemaNode[] = []
const visit = (id: number): number => {
const color = colors[id] as number
const existing = emitted.get(color)
if (existing !== undefined) return existing
const position = result.length
emitted.set(color, position)
result.push({ kind: 'primitive', type: 'never' })
const representative = representatives.get(color) as number
const colored = mapNode(nodes[representative] as SchemaNode, child => colors[child] as number)
result[position] = mapNode(colored, child => visit(representatives.get(child) as number))
return position
}
visit(rootIndex)
return { root: 0, nodes: result }
}
}
function normalizeUnions(input: readonly SchemaNode[]): SchemaNode[] {
const result = [...input]
const flattened = (id: number, visiting: Set<number>): number[] => {
const node = input[id]
if (node === undefined) throw new Error(`persistence schema: missing node ${String(id)}`)
if (node.kind !== 'union') return [id]
if (visiting.has(id)) throw new Error('persistence schema: union cycle has no material type')
const next = new Set(visiting).add(id)
return node.types.flatMap(child => flattened(child, next))
}
for (const [id, node] of input.entries()) {
if (node.kind !== 'union') continue
let members = [...new Set(flattened(id, new Set()))]
const primitives = new Set(members.flatMap((child) => {
const value = input[child] as SchemaNode
return value.kind === 'primitive' ? [value.type] : []
}))
const literals = members.flatMap((child) => {
const value = input[child] as SchemaNode
return value.kind === 'literal' ? [value.value] : []
})
if (!primitives.has('boolean') && literals.includes(true) && literals.includes(false)) {
primitives.add('boolean')
members.push(result.length)
result.push({ kind: 'primitive', type: 'boolean' })
}
members = members.filter((child) => {
const value = result[child] as SchemaNode
if (value.kind === 'primitive') return value.type !== 'never'
return value.kind !== 'literal' || !primitives.has(typeof value.value as 'string' | 'number' | 'boolean')
})
result[id] = members.length === 0 ? { kind: 'primitive', type: 'never' } : { kind: 'union', types: members }
}
return result
}
/**
* Compute the versioned SHA-256 fingerprint of a canonical graph.
* @param schema - canonical resolved persisted type.
* @returns lowercase hexadecimal digest.
*/
export function schemaDigest(schema: CanonicalSchema): string {
const version = schemaHasCompatibility(schema) ? 2 : 1
return createHash('sha256').update(`dsh-persistence-schema-v${String(version)}\n`).update(JSON.stringify(schema)).digest('hex')
}
/**
* Identify graphs that require the policy-aware fingerprint domain.
* @param schema - resolved persisted type.
* @returns whether any reachable property records compatibility metadata.
*/
export function schemaHasCompatibility(schema: CanonicalSchema): boolean {
return schema.nodes.some(node => node.kind === 'object' && node.properties.some(property => property.compatibility !== undefined))
}
/**
* Recognize the complete recursive JSON value language without relying on type names.
* @param schema - canonical resolved type.
* @returns whether the type permits arbitrary JSON values.
*/
export function isArbitraryJsonSchema(schema: CanonicalSchema): boolean {
return schemaDigest(schema) === JSON_VALUE_DIGEST
}
const JSON_VALUE_DIGEST = schemaDigest(canonicalizeSchema([
{ kind: 'union', types: [1, 2, 3, 4, 5, 6] },
{ kind: 'primitive', type: 'null' },
{ kind: 'primitive', type: 'boolean' },
{ kind: 'primitive', type: 'number' },
{ kind: 'primitive', type: 'string' },
{ kind: 'array', element: 0 },
{ kind: 'object', properties: [], indices: [{ key: 4, value: 0 }] },
], 0))
function compare(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0
}
function assertNever(value: never): never {
throw new Error(`persistence schema: unsupported node ${JSON.stringify(value)}`)
}