Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
"verify:grader-boundary-controls": "node scripts/verify-grader-boundary.mjs --self-test",
"verify:forbidden-knowledge": "node scripts/verify-forbidden-knowledge.mjs",
"verify:forbidden-knowledge-controls": "node scripts/verify-forbidden-knowledge.mjs --self-test",
"verify:report-generation-injections": "node scripts/verify-report-generation-injections.mjs",
"verify:grader-boundary-runtime": "node scripts/verify-grader-boundary-runtime.mjs",
"verify:integrity-mutations": "node scripts/verify-integrity-mutations.mjs && MADAR_MUTATION_HARNESS_E2E=1 npx vitest run tests/unit/mutation-harness-self.test.ts",
"verify:integrity-receipts": "node scripts/verify-integrity-receipts.mjs",
Expand Down
10 changes: 8 additions & 2 deletions scripts/lib/forbidden-knowledge-manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"manifest_version": "1.0.0",
"issue": 660,
"slice": "B",
"slice": "B+C",
"title": "Production independence from qualification-repository knowledge",
"purpose": [
"Madar's retrieval and claim behaviour must be driven by generic structural evidence,",
Expand Down Expand Up @@ -54,7 +54,13 @@
{ "id": "report-generation/symbol-dispatch-db-sync", "repository": "govalidate-report-generation", "class": "symbol", "value": "dispatchDbSync", "why": "Qualification-target symbol in a fixed promotion table." },
{ "id": "report-generation/symbol-broadcast-run-started", "repository": "govalidate-report-generation", "class": "symbol", "value": "broadcastRunStarted", "why": "Qualification-target symbol in a fixed promotion table." },
{ "id": "report-generation/symbol-broadcast-run-failed", "repository": "govalidate-report-generation", "class": "symbol", "value": "broadcastRunFailed", "why": "Qualification-target symbol in a fixed promotion table." },
{ "id": "report-generation/symbol-score-metrics", "repository": "govalidate-report-generation", "class": "symbol", "value": "scoreMetrics", "why": "Qualification-target symbol in a fixed promotion table." }
{ "id": "report-generation/symbol-score-metrics", "repository": "govalidate-report-generation", "class": "symbol", "value": "scoreMetrics", "why": "Qualification-target symbol in a fixed promotion table." },
{ "id": "report-generation/symbol-prompt-wants-report-generation-core", "repository": "govalidate-report-generation", "class": "symbol", "value": "promptWantsReportGenerationCore", "why": "Name of the retired report-generation task-phrase classifier. It was duplicated in prompt-pack.ts and retrieve/slicing.ts and gated every report-only behaviour removed in #660 Slice C." },
{ "id": "report-generation/symbol-semantic-generation-core-anchor-value", "repository": "govalidate-report-generation", "class": "symbol", "value": "semanticGenerationCoreAnchorValue", "why": "Name of the retired name-driven anchor score table that paid a symbol for resembling a report workflow stage." },
{ "id": "report-generation/phrase-generation-core-heuristic-anchor", "repository": "govalidate-report-generation", "class": "phrase", "value": "generation core heuristic", "why": "Anchor reason emitted only by the retired forced report-anchor selection." },
{ "id": "report-generation/phrase-fixed-report-workflow-instruction", "repository": "govalidate-report-generation", "class": "phrase", "value": "planner, research, assembly, scoring, rendering, and persistence evidence", "why": "The retired fixed report workflow instruction, manufactured from prompt vocabulary with no evidence behind it." },
{ "id": "report-generation/phrase-final-report-score-entry", "repository": "govalidate-report-generation", "class": "phrase", "value": "final(?:-| )report", "why": "A distinctive +10 entry of the retired anchor score table." },
{ "id": "report-generation/phrase-idea-report-gate-entry", "repository": "govalidate-report-generation", "class": "phrase", "value": "idea\\s+report", "why": "The qualification task name, matched by the retired reportGenerationShaped gate classifier." }
],
"corpus_symbol_import": {
"source": "docs/qualification/corpus.json",
Expand Down
234 changes: 234 additions & 0 deletions scripts/verify-report-generation-injections.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
#!/usr/bin/env node
// #660 Slice C — focused falsifiability for the retired report-generation class.
//
// Four injections, no more. Each one restores ONE retired rule into production
// source from a digest-checked byte snapshot, requires a NAMED control to fail,
// and restores the exact bytes and file mode in `finally`. Unrelated failures
// earn no credit: an injection passes only when the control it names fails.
//
// This is deliberately NOT a general mutation framework. It is a fixed list of
// four edits with a fixed expected victim each.
import { execFileSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { readFileSync, statSync, writeFileSync, chmodSync } from 'node:fs'
import { resolve } from 'node:path'

const PROMPT_PACK = 'src/infrastructure/prompt-pack.ts'
const SLICING = 'src/runtime/retrieve/slicing.ts'
const GATE = 'src/runtime/retrieval-gate.ts'

const CONTROL_FILE = 'tests/unit/report-generation-independence.test.ts'

/** The retired task-phrase classifier, restored verbatim where an injection needs it. */
const RETIRED_CLASSIFIER = `function promptWantsReportGenerationCore(prompt) {
return /\\b(?:report(?:\\s+generation)?|generated\\s+report|validation\\s+report|final\\s+report|assembly|assemble|synthesis|renderer|render|planner|research|metrics?|scor(?:e|ing)|quality(?:\\s|-)?gate)\\b/i.test(prompt)
}
`

const INJECTIONS = [
{
id: 'C1',
title: 'the fixed report workflow instruction',
marker: 'SLICE_C_FIXED_REPORT_INSTRUCTION_REINTRODUCED',
file: PROMPT_PACK,
control: { file: CONTROL_FILE, name: 'A: report vocabulary alone produces no fixed workflow' },
apply(source) {
// Put the retired fixed instruction back, unconditionally, so the
// vocabulary-only control must observe it.
const anchor = ` const instructions = [
'Treat HTTP/controller entrypoints as trigger context, not the full answer.',
]`
if (!source.includes(anchor)) throw new Error('C1 anchor missing')
return source.replace(
` ...answerContractInstructions(input.retrieval),`,
` ...answerContractInstructions(input.retrieval),
'Follow planner, research, assembly, scoring, rendering, and persistence evidence before concluding the flow.',`,
)
},
},
{
id: 'C2',
title: 'a report-generation task-phrase classifier',
marker: 'SLICE_C_TASK_PHRASE_CLASSIFIER_REINTRODUCED',
file: SLICING,
control: { file: CONTROL_FILE, name: 'B: the same repository and task intent yield the same structure' },
apply(source) {
// Restore the classifier AND one behaviour it used to gate: a deeper
// backward policy for report-shaped prompts only.
const anchor = ` if (!hasMethodAnchor && !pipelinePrompt) {
return base
}`
if (!source.includes(anchor)) throw new Error('C2 anchor missing')
return source
.replace('function promptWantsRuntimePipeline(', `${RETIRED_CLASSIFIER}\nfunction promptWantsRuntimePipeline(`)
.replace(anchor, `${anchor}

if (broadRuntimeGeneration && pipelinePrompt && promptWantsReportGenerationCore(options.prompt)) {
return {
...base,
directions: ['backward', 'forward'],
backward_relations: new Set(['calls', 'enqueues_job', 'controller_route', 'route_handler', 'method']),
forward_relations: new Set(RUNTIME_FLOW_RELATIONS),
helper_relations: new Set(['injects', 'depends_on', 'module_provides']),
backward_depth: Math.max(base.backward_depth, 3),
forward_depth: Math.max(base.forward_depth, 4),
runtime_flow_only: true,
}
}`)
},
},
{
id: 'C3',
title: 'a name-driven anchor score-table entry',
marker: 'SLICE_C_NAME_DRIVEN_SCORE_TABLE_REINTRODUCED',
file: SLICING,
control: { file: CONTROL_FILE, name: 'C: identical structure named after report stages earns no extra structural treatment' },
apply(source) {
// One score-table entry: a symbol is paid for being NAMED like a report
// stage. The same-name/different-semantics control must notice.
const anchor = ` if (/\\b(?:service|provider|repository|queue|worker|orchestrator)\\b/.test(lower)) value += 1`
if (!source.includes(anchor)) throw new Error('C3 anchor missing')
return source.replace(anchor, `${anchor}
if (/\\b(?:planner|assembly|assemble|renderer|synthesis|scoring|research)\\b/.test(lower)) value += 11`)
},
},
{
id: 'C4',
title: 'the reportGenerationShaped gate variant',
marker: 'SLICE_C_REPORT_GATE_VARIANT_REINTRODUCED',
file: GATE,
control: { file: CONTROL_FILE, name: 'A: report vocabulary alone produces no fixed workflow' },
apply(source) {
const anchor = ` const strongRuntimeShaped = backendRuntimeShaped`
if (!source.includes(anchor)) throw new Error('C4 anchor missing')
return source.replace(anchor, ` const reportGenerationShaped = /\\b(?:idea\\s+report|report\\s+generation|final\\s+report|planner|research|metrics?|scor(?:e|ing)|quality(?:\\s|-)?gate|renderer|synthesis|assemble|assembly)\\b/i.test(lower)
const strongRuntimeShaped = backendRuntimeShaped || reportGenerationShaped`)
},
},
]

/** Thrown when the pre-injection baseline already fails, so the injection is void. */
class SkipInjection extends Error {}

function digest(text) {
return createHash('sha256').update(text).digest('hex')
}

function untrackedSources() {
return execFileSync('git', ['status', '--porcelain', '--untracked-files=all', '--', 'src', 'tests', 'scripts'], { encoding: 'utf8' })
.split('\n')
.filter((line) => line.startsWith('??'))
.map((line) => line.slice(3).trim())
.filter((file) => file.length > 0)
}

function runControl(control) {
try {
execFileSync('npx', ['vitest', 'run', control.file, '-t', control.name], {
stdio: 'pipe',
encoding: 'utf8',
env: { ...process.env, CI: '1' },
})
return { failed: false }
} catch (error) {
const output = `${error.stdout ?? ''}${error.stderr ?? ''}`
return { failed: true, output }
}
}

function main() {
const results = []
let allPassed = true

// Fingerprint the three production files BEFORE any injection. Comparing to
// git HEAD would be wrong: on a working branch these files legitimately differ
// from HEAD, so only a self-taken snapshot can prove the harness left nothing
// behind.
const startFingerprint = new Map(
[PROMPT_PACK, SLICING, GATE].map((file) => [file, digest(readFileSync(resolve(file), 'utf8'))]),
)
// Untracked files that already exist are the branch's own new files, not this
// harness's residue. Only files that appear DURING the run count.
const startUntracked = new Set(untrackedSources())

for (const injection of INJECTIONS) {
const path = resolve(injection.file)
const original = readFileSync(path, 'utf8')
const originalDigest = digest(original)
const originalMode = statSync(path).mode

let outcome
try {
// BEFORE anything is mutated, the named control must be GREEN. Without
// this baseline a control that was already red would hand every injection
// free credit: the post-injection failure would prove nothing, because the
// failure was not caused by the injection.
const before = runControl(injection.control)
if (before.failed) {
outcome = { ok: false, detail: 'the named control was ALREADY FAILING before injection — its later failure would prove nothing' }
throw new SkipInjection()
}

const injected = injection.apply(original)
if (injected === original) throw new Error(`${injection.id}: injection changed nothing`)
writeFileSync(path, injected)

// The injected path must actually be REACHED, not merely present.
const after = runControl(injection.control)
if (!after.failed) {
outcome = { ok: false, detail: 'the named control still PASSED with the rule restored — it cannot catch its own mutation' }
} else {
// Credit only for the named control failing, not for any failure.
const named = after.output.includes(injection.control.name)
outcome = named
? { ok: true, detail: `${injection.marker}: named control was green before injection and failed after, as required` }
: { ok: false, detail: 'a failure occurred but it was not the named control' }
}
} catch (error) {
if (!(error instanceof SkipInjection)) {
outcome = { ok: false, detail: `injection error: ${error.message}` }
}
} finally {
writeFileSync(path, original)
chmodSync(path, originalMode)
const restored = readFileSync(path, 'utf8')
if (digest(restored) !== originalDigest) {
console.error(`FATAL ${injection.id}: ${injection.file} was NOT restored to its original bytes`)
process.exit(2)
}
}

allPassed = allPassed && outcome.ok
results.push({ injection, outcome })
console.log(`${outcome.ok ? 'PASS' : 'FAIL'} ${injection.id} ${injection.title}`)
console.log(` ${outcome.detail}`)
}

// Final fingerprint: every touched file is byte-identical to its start state.
console.log('')
console.log('Worktree fingerprint for the three production files after restoration:')
let drifted = false
for (const [file, expected] of startFingerprint) {
const actual = digest(readFileSync(resolve(file), 'utf8'))
const same = actual === expected
drifted = drifted || !same
console.log(` ${same ? 'restored' : 'DRIFTED '} ${file} sha256 ${actual.slice(0, 16)}`)
}
if (drifted) {
console.error('FATAL: a production file did not return to its pre-injection bytes.')
process.exit(2)
}

// No temporary source or test residue may survive the run.
const leaked = untrackedSources().filter((file) => !startUntracked.has(file))
console.log(leaked.length === 0
? ' no new untracked residue under src/, tests/ or scripts/'
: ` UNTRACKED RESIDUE: ${leaked.join(' ')}`)
if (leaked.length > 0) process.exit(2)

console.log('')
console.log(`${results.filter((entry) => entry.outcome.ok).length}/${INJECTIONS.length} focused injections behaved as required.`)
process.exit(allPassed ? 0 : 1)
}

main()
47 changes: 8 additions & 39 deletions src/infrastructure/prompt-pack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,20 +54,13 @@ function answerContractInstructions(retrieval: RetrieveResult): string[] {
]

const requiredElements = new Set(answerContract.required_elements)
const phaseLabels = [
['planner_phase', 'planner'],
['research_phase', 'research'],
['assembly_phase', 'assembly'],
['scoring_phase', 'scoring'],
['report_builder_phase', 'rendering'],
] as const satisfies ReadonlyArray<readonly [string, string]>
const selectedPhaseLabels: string[] = phaseLabels.flatMap(([key, label]) => requiredElements.has(key) ? [label] : [])
if (selectedPhaseLabels.length > 0 || requiredElements.has('persistence_or_artifact_storage')) {
const segments = [...selectedPhaseLabels]
if (requiredElements.has('persistence_or_artifact_storage')) {
segments.push('persistence')
}
instructions.push(`Follow ${segments.join(', ')} evidence before concluding the flow.`)
// Every instruction below is keyed on a typed `required_elements` entry that
// `runtimeGenerationContractPhaseElements` derives from execution-slice
// evidence (phase coverage, an `enqueues_job` boundary, slice status). The
// question text is not an input here, so prompt vocabulary cannot manufacture
// a workflow that the repository evidence does not show.
if (requiredElements.has('persistence_or_artifact_storage')) {
instructions.push('Follow persistence evidence before concluding the flow.')
} else if (requiredElements.has('main_pipeline_phases')) {
instructions.push('Cover the main runtime pipeline phases instead of stopping at the entrypoint.')
}
Expand All @@ -91,30 +84,6 @@ function answerContractInstructions(retrieval: RetrieveResult): string[] {
return instructions
}

function promptWantsReportGenerationCore(prompt: string): boolean {
return /\b(?:report(?:\s+generation)?|generated\s+report|validation\s+report|final\s+report|assembly|assemble|synthesis|renderer|render|planner|research|metrics?|scor(?:e|ing)|quality(?:\s|-)?gate)\b/i.test(prompt)
}

function generationCoreInstructions(question: string, retrieval: RetrieveResult): string[] {
const contractInstructions = answerContractInstructions(retrieval)
if (contractInstructions.length > 0) {
return contractInstructions
}

if (
retrieval.retrieval_gate?.signals.generation_intent !== 'runtime_generation'
|| retrieval.retrieval_gate?.signals.target_domain_hint !== 'backend_runtime'
|| !promptWantsReportGenerationCore(question)
) {
return []
}

return [
'Treat HTTP/controller entrypoints as trigger context, not the full answer, when downstream generation-core evidence is present.',
'Follow planner, research, assembly, scoring, rendering, and persistence evidence before concluding the flow.',
]
}

export function buildMadarPromptPack(input: BuildMadarPromptPackInput): ComparePromptPack {
const explainPayloadCore = buildAnswerReadyPackSchema(
buildExplainPackPayloadCore(compactRetrieveResult(input.retrieval), input.retrieval),
Expand All @@ -126,7 +95,7 @@ export function buildMadarPromptPack(input: BuildMadarPromptPackInput): CompareP
instructions: [
'Answer the question using only the provided graph-guided retrieval output.',
'If the retrieval does not contain the answer, say so.',
...generationCoreInstructions(input.question, input.retrieval),
...answerContractInstructions(input.retrieval),
],
stable_prefix_title: 'Retrieved graph context',
stable_sections: [
Expand Down
Loading
Loading