Skip to content

Commit d764e7f

Browse files
fix: eliminate repeated missing model object failures
1 parent 5d46ac3 commit d764e7f

13 files changed

Lines changed: 959 additions & 327 deletions

global-template/docgen/CONTRACTS.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,19 @@ index -> modelCore -> modelEnterprise -> plan -> generate -> audit -> publish
7474
5. bounded recovery retries only the unresolved subset;
7575
6. a page becomes `completed` only after Markdown and traceability validation succeeds.
7676

77+
## Model bundle recovery contract
78+
79+
Model synthesis treats provider output as an untrusted transport shape. The orchestrator:
80+
81+
1. extracts requested models from exact keys, normalized key variants, nested wrappers, descriptor arrays, JSON-string payloads, and direct singleton repair objects;
82+
2. salvages every recognized object from a partial bundle without writing partial model state;
83+
3. performs at most one batch repair for unresolved names, then at most one independent request per unresolved model;
84+
4. commits the reconciled model set only after every requested name is resolved;
85+
5. defaults unresolved names to an explicit `UNKNOWN` placeholder with no evidence and records them in `state.stages.<stage>.degradedModels`;
86+
6. supports `execution.missingModelPolicy = "fail"` for environments that prefer a hard gate after bounded recovery.
87+
88+
A completed degraded stage is reusable on `docgen resume`, preventing an unbounded provider retry loop. `docgen status` exposes degraded model names in `summary.degradedModels`.
89+
7790
## Correctness gate
7891

7992
The deterministic audit validates, at minimum:
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
const WRAPPER_KEYS = new Set([
2+
'artifacts', 'bundle', 'data', 'documents', 'items', 'model', 'models', 'modules',
3+
'objects', 'output', 'outputs', 'payload', 'result', 'results', 'response'
4+
]);
5+
const STRONG_SINGLETON_WRAPPERS = new Set(['artifacts', 'bundle', 'documents', 'model', 'models', 'modules', 'output', 'outputs', 'payload', 'response', 'result', 'results']);
6+
const DESCRIPTOR_KEYS = ['modelName', 'model', 'filename', 'fileName', 'path', 'name', 'key', 'type', 'kind', 'id'];
7+
const PAYLOAD_KEYS = ['value', 'content', 'payload', 'data', 'document', 'object', 'result', 'body', 'model'];
8+
const GENERIC_NAME_TOKENS = new Set(['artifact', 'document', 'model', 'object', 'output', 'result']);
9+
const NON_MODEL_SINGLETON_KEYS = new Set(['code', 'error', 'errors', 'message', 'messages', 'note', 'status', 'success', 'warning', 'warnings']);
10+
const MODEL_SIGNAL_KEYS = new Set(['id', 'name', 'title', 'kind', 'statement', 'summary', 'description', 'classification', 'confidence', 'evidence', 'unknowns', 'items']);
11+
12+
function isPlainObject(value) {
13+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
14+
}
15+
16+
function parseJsonValue(value) {
17+
if (typeof value !== 'string') return value;
18+
const text = value.trim();
19+
if (!text || !['{', '['].includes(text[0])) return value;
20+
try { return JSON.parse(text); } catch { return value; }
21+
}
22+
23+
function words(value) {
24+
return String(value ?? '')
25+
.replace(/\.json$/i, '')
26+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
27+
.toLowerCase()
28+
.split(/[^a-z0-9]+/)
29+
.filter(Boolean);
30+
}
31+
32+
function singular(word) {
33+
if (word.endsWith('ies') && word.length > 4) return `${word.slice(0, -3)}y`;
34+
if (word.endsWith('s') && word.length > 3 && !word.endsWith('ss')) return word.slice(0, -1);
35+
return word;
36+
}
37+
38+
export function canonicalModelName(value) {
39+
const parts = words(value);
40+
while (parts.length && GENERIC_NAME_TOKENS.has(parts[0])) parts.shift();
41+
while (parts.length && GENERIC_NAME_TOKENS.has(parts.at(-1))) parts.pop();
42+
if (parts.length) parts[parts.length - 1] = singular(parts.at(-1));
43+
return parts.join('');
44+
}
45+
46+
function wrapperKey(value) {
47+
return words(value).join('');
48+
}
49+
50+
function matchesExpected(value, expected) {
51+
const candidate = canonicalModelName(value);
52+
return Boolean(candidate) && candidate === canonicalModelName(expected);
53+
}
54+
55+
function asModelObject(value) {
56+
const parsed = parseJsonValue(value);
57+
if (isPlainObject(parsed)) return parsed;
58+
if (Array.isArray(parsed)) return { items: parsed };
59+
return null;
60+
}
61+
62+
function descriptorIdentity(value) {
63+
for (const key of DESCRIPTOR_KEYS) {
64+
const candidate = value?.[key];
65+
if (typeof candidate === 'string' || typeof candidate === 'number') return String(candidate);
66+
}
67+
return null;
68+
}
69+
70+
function descriptorPayload(value) {
71+
for (const key of PAYLOAD_KEYS) {
72+
if (!(key in value)) continue;
73+
const candidate = asModelObject(value[key]);
74+
if (candidate) return candidate;
75+
}
76+
const ignored = new Set([...DESCRIPTOR_KEYS, ...PAYLOAD_KEYS]);
77+
const remainder = Object.fromEntries(Object.entries(value).filter(([key]) => !ignored.has(key)));
78+
return Object.keys(remainder).length ? remainder : null;
79+
}
80+
81+
function directSingletonCandidate(value, expectedNames) {
82+
if (expectedNames.length !== 1) return null;
83+
const root = asModelObject(value);
84+
if (!root) return null;
85+
const keys = Object.keys(root);
86+
if (!keys.length) return root;
87+
if (keys.length === 1 && keys.some((key) => STRONG_SINGLETON_WRAPPERS.has(wrapperKey(key)))) return null;
88+
if (keys.every((key) => NON_MODEL_SINGLETON_KEYS.has(wrapperKey(key)))) return null;
89+
if (keys.some((key) => expectedNames.some((name) => matchesExpected(key, name)))) return null;
90+
const structured = Object.values(root).some((entry) => entry && typeof entry === 'object');
91+
const semantic = keys.some((key) => MODEL_SIGNAL_KEYS.has(wrapperKey(key)));
92+
return structured || semantic ? root : null;
93+
}
94+
95+
export function extractModelObjects(bundle, expectedNames, { maxDepth = 8, maxNodes = 10000 } = {}) {
96+
const expected = [...new Set(expectedNames.map(String))];
97+
const candidates = new Map();
98+
const diagnostics = [];
99+
const seen = new WeakSet();
100+
let nodes = 0;
101+
102+
function offer(name, rawValue, score, origin) {
103+
const value = asModelObject(rawValue);
104+
if (!value) return;
105+
const current = candidates.get(name);
106+
if (!current || score > current.score) {
107+
candidates.set(name, { value, score, origin });
108+
diagnostics.push({ type: 'accepted', model: name, origin, score });
109+
}
110+
}
111+
112+
function visit(rawValue, depth = 0, origin = '$') {
113+
if (depth > maxDepth || nodes >= maxNodes) return;
114+
const value = parseJsonValue(rawValue);
115+
if (Array.isArray(value)) {
116+
nodes++;
117+
for (let index = 0; index < value.length; index++) visit(value[index], depth + 1, `${origin}[${index}]`);
118+
return;
119+
}
120+
if (!isPlainObject(value) || seen.has(value)) return;
121+
seen.add(value); nodes++;
122+
123+
const matchedModelKeys = new Set();
124+
for (const [key, child] of Object.entries(value)) {
125+
for (const name of expected) {
126+
if (matchesExpected(key, name)) { offer(name, child, 1000 - (depth * 10), `${origin}.${key}`); matchedModelKeys.add(key); }
127+
}
128+
}
129+
130+
const identity = descriptorIdentity(value);
131+
if (identity) {
132+
for (const name of expected) {
133+
if (matchesExpected(identity, name)) offer(name, descriptorPayload(value), 800 - (depth * 10), `${origin}<${identity}>`);
134+
}
135+
}
136+
137+
const entries = Object.entries(value).sort(([left], [right]) => {
138+
const leftWrapper = WRAPPER_KEYS.has(wrapperKey(left)) ? 0 : 1;
139+
const rightWrapper = WRAPPER_KEYS.has(wrapperKey(right)) ? 0 : 1;
140+
return leftWrapper - rightWrapper;
141+
});
142+
for (const [key, child] of entries) if (!matchedModelKeys.has(key)) visit(child, depth + 1, `${origin}.${key}`);
143+
}
144+
145+
visit(bundle);
146+
if (!candidates.size) {
147+
const singleton = directSingletonCandidate(bundle, expected);
148+
if (singleton) offer(expected[0], singleton, 100, '$<direct-singleton>');
149+
}
150+
151+
const objects = Object.fromEntries(expected.filter((name) => candidates.has(name)).map((name) => [name, candidates.get(name).value]));
152+
const missing = expected.filter((name) => !Object.hasOwn(objects, name));
153+
if (nodes >= maxNodes) diagnostics.push({ type: 'limit', reason: 'maxNodes', maxNodes });
154+
return { objects, missing, diagnostics, visitedNodes: nodes };
155+
}
156+
157+
export function mergeModelObjects(target, extraction) {
158+
for (const [name, value] of Object.entries(extraction.objects ?? {})) if (!Object.hasOwn(target, name)) target[name] = value;
159+
return target;
160+
}
161+
162+
export function safeModelPlaceholder(name, reason = 'Provider omitted the requested model object after bounded recovery.') {
163+
return {
164+
status: 'degraded',
165+
providerOutputStatus: 'missing',
166+
classification: 'UNKNOWN',
167+
confidence: 0,
168+
evidence: [],
169+
unknowns: [{
170+
id: `${canonicalModelName(name) || 'model'}-provider-output-missing`,
171+
kind: 'provider-output-gap',
172+
name: `${name} model unavailable`,
173+
statement: reason,
174+
classification: 'UNKNOWN',
175+
confidence: 0,
176+
evidence: []
177+
}],
178+
normalizationNotes: [`A deterministic UNKNOWN placeholder was created for ${name}; no repository fact was invented.`]
179+
};
180+
}
181+
182+
export function resolveModelObjects(expectedNames, bundles, { missingPolicy = 'placeholder', placeholderReason } = {}) {
183+
const expected = [...new Set(expectedNames.map(String))];
184+
const objects = {};
185+
const diagnostics = [];
186+
for (const [index, bundle] of bundles.entries()) {
187+
const extraction = extractModelObjects(bundle, expected.filter((name) => !Object.hasOwn(objects, name)));
188+
mergeModelObjects(objects, extraction);
189+
diagnostics.push(...extraction.diagnostics.map((entry) => ({ ...entry, attempt: index + 1 })));
190+
}
191+
const unresolved = expected.filter((name) => !Object.hasOwn(objects, name));
192+
const degraded = [];
193+
if (unresolved.length && missingPolicy !== 'fail') {
194+
for (const name of unresolved) { objects[name] = safeModelPlaceholder(name, placeholderReason); degraded.push(name); }
195+
}
196+
return { objects, missing: expected.filter((name) => !Object.hasOwn(objects, name)), degraded, diagnostics };
197+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
import { fileURLToPath } from 'node:url';
4+
import { ensureDir, fileSha256, now, projectPaths, readJson, writeJson } from './core.mjs';
5+
import { normalizeSemanticDocument } from './semantic.mjs';
6+
7+
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
8+
export const modelPath = (root, name) => path.join(projectPaths(root).model, `${name}.json`);
9+
export const stamp = (file) => fs.existsSync(file) ? { hash: fileSha256(file), mtimeMs: fs.statSync(file).mtimeMs } : null;
10+
export const changed = (file, before) => { const after = stamp(file); return Boolean(after && (!before || after.hash !== before.hash || after.mtimeMs !== before.mtimeMs)); };
11+
12+
export function renderModelPrompt(root, name, vars, repair = false) {
13+
const override = path.join(root, '.docgen', 'prompts', name);
14+
let text = fs.readFileSync(fs.existsSync(override) ? override : path.resolve(moduleDir, '..', 'prompts', name), 'utf8');
15+
for (const [key, value] of Object.entries(vars)) text = text.replaceAll(`{{${key}}}`, String(value));
16+
return repair ? `${text}\n\nRecovery request: write only the requested model object(s). A direct object is accepted for a single requested name.` : text;
17+
}
18+
export function stageCurrent(root, stage, inputHash, outputs) {
19+
const current = readJson(projectPaths(root).state, {}).stages?.[stage];
20+
return current?.status === 'completed' && current.inputHash === inputHash && outputs.every(fs.existsSync);
21+
}
22+
export function readBundle(file) {
23+
const value = readJson(file);
24+
if (!value || typeof value !== 'object') throw new Error(`Invalid model bundle JSON: ${file}`);
25+
return value;
26+
}
27+
export function commitModels(root, stage, names, objects) {
28+
const dir = projectPaths(root).model;
29+
const token = `${stage}-${process.pid}-${Date.now()}`;
30+
const staging = path.join(dir, `.staging-${token}`);
31+
const backup = path.join(dir, `.backup-${token}`);
32+
ensureDir(staging); ensureDir(backup);
33+
const existed = new Set();
34+
try {
35+
for (const name of names) {
36+
const value = structuredClone(objects[name]);
37+
normalizeSemanticDocument(value);
38+
writeJson(path.join(staging, `${name}.json`), { schemaVersion: '2.0', generatedAt: now(), ...value });
39+
}
40+
for (const name of names) {
41+
const final = modelPath(root, name);
42+
if (fs.existsSync(final)) { fs.copyFileSync(final, path.join(backup, `${name}.json`)); existed.add(name); }
43+
}
44+
for (const name of names) fs.copyFileSync(path.join(staging, `${name}.json`), modelPath(root, name));
45+
} catch (error) {
46+
for (const name of names) {
47+
const final = modelPath(root, name); const saved = path.join(backup, `${name}.json`);
48+
if (existed.has(name) && fs.existsSync(saved)) fs.copyFileSync(saved, final); else fs.rmSync(final, { force: true });
49+
}
50+
throw error;
51+
} finally {
52+
fs.rmSync(staging, { recursive: true, force: true });
53+
fs.rmSync(backup, { recursive: true, force: true });
54+
}
55+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
import { compileContext } from './context.mjs';
4+
import { runProvider } from './provider.mjs';
5+
import { ensureDir, loadConfig, now, projectPaths, rel, sha256, updateStage } from './core.mjs';
6+
import { extractModelObjects, mergeModelObjects, safeModelPlaceholder } from './model-bundle.mjs';
7+
import { changed, commitModels, modelPath, readBundle, renderModelPrompt, stageCurrent, stamp } from './model-io.mjs';
8+
9+
export async function synthesizeModels(root, stage, names, query) {
10+
const paths = projectPaths(root); const config = loadConfig(root);
11+
const context = compileContext(root, { stage, query, target: stage, metadata: { expectedModels: names } });
12+
const inputHash = context.payload.inputHash; const outputs = names.map((name) => modelPath(root, name));
13+
if (stageCurrent(root, stage, inputHash, outputs)) return { skipped: true, inputHash };
14+
ensureDir(paths.model); updateStage(root, stage, 'running', { inputHash, contextId: context.payload.id });
15+
const temporary = new Set(); const resolved = {}; const recoveryErrors = [];
16+
let providerCalls = 0; let recovered = false;
17+
async function request(expected, target, repair = false) {
18+
temporary.add(target); const before = stamp(target); let extraction = null;
19+
const inspect = () => {
20+
if (!changed(target, before)) return null;
21+
extraction = extractModelObjects(readBundle(target), expected);
22+
return extraction;
23+
};
24+
providerCalls++;
25+
const provider = await runProvider(root, {
26+
stage,
27+
target: repair ? `${stage}:repair:${expected.join(',')}` : stage,
28+
prompt: renderModelPrompt(root, stage === 'modelCore' ? 'model-core.md' : 'model-enterprise.md', {
29+
CONTEXT_PATH: rel(root, context.file), OUTPUT_PATH: rel(root, target), MODEL_NAMES: JSON.stringify(expected)
30+
}, repair),
31+
acceptArtifacts: () => Boolean(Object.keys(inspect()?.objects ?? {}).length)
32+
});
33+
recovered ||= provider.recovered === true;
34+
extraction ??= inspect();
35+
if (!extraction || !Object.keys(extraction.objects ?? {}).length) throw new Error(`${stage}: provider completed without a fresh recognizable model artifact`);
36+
return extraction;
37+
}
38+
const merge = (result) => mergeModelObjects(resolved, result);
39+
try {
40+
try { merge(await request(names, path.join(paths.model, `${stage}-bundle.json`))); }
41+
catch (error) { recoveryErrors.push(`initial: ${error.message}`); }
42+
let missing = names.filter((name) => !Object.hasOwn(resolved, name));
43+
if (missing.length) {
44+
console.warn(`[docgen] ${stage} REPAIR | unresolved: ${missing.join(', ')}`);
45+
try { merge(await request(missing, path.join(paths.model, `${stage}-repair-${sha256(missing.join('|')).slice(0, 10)}-bundle.json`), true)); }
46+
catch (error) { recoveryErrors.push(`batch: ${error.message}`); }
47+
}
48+
missing = names.filter((name) => !Object.hasOwn(resolved, name));
49+
for (const name of missing) {
50+
console.warn(`[docgen] ${stage} OBJECT REPAIR | ${name}`);
51+
try { merge(await request([name], path.join(paths.model, `${stage}-repair-${sha256(name).slice(0, 10)}-${name}.json`), true)); }
52+
catch (error) { recoveryErrors.push(`${name}: ${error.message}`); }
53+
}
54+
missing = names.filter((name) => !Object.hasOwn(resolved, name));
55+
const policy = String(config.execution?.missingModelPolicy ?? 'placeholder').toLowerCase();
56+
if (missing.length && policy === 'fail') throw new Error(`Model recovery exhausted for: ${missing.join(', ')}`);
57+
const degradedModels = [];
58+
for (const name of missing) {
59+
resolved[name] = safeModelPlaceholder(name, `${stage} provider output omitted ${name} after bounded recovery.`);
60+
degradedModels.push(name);
61+
console.warn(`[docgen] ${stage} DEGRADED | ${name} -> explicit UNKNOWN placeholder`);
62+
}
63+
commitModels(root, stage, names, resolved);
64+
updateStage(root, stage, 'completed', {
65+
inputHash, completedAt: now(), models: names, degradedModels, recoveryErrors, providerCalls,
66+
contextId: context.payload.id, contextTokens: context.payload.estimatedTokens, recovered
67+
});
68+
return { skipped: false, inputHash, degradedModels, providerCalls, recovered };
69+
} catch (error) {
70+
updateStage(root, stage, 'failed', { inputHash, failedAt: now(), error: error.message, recoveryErrors, contextId: context.payload.id });
71+
throw error;
72+
} finally {
73+
for (const file of temporary) fs.rmSync(file, { force: true });
74+
}
75+
}

0 commit comments

Comments
 (0)