stilltrue 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -3
- package/dist/cli.js +36 -3
- package/dist/config.d.ts +3 -2
- package/dist/history.d.ts +11 -0
- package/dist/history.js +37 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/report.d.ts +2 -0
- package/dist/report.js +120 -0
- package/dist/verify.d.ts +100 -0
- package/dist/verify.js +96 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ no server, no dashboard, no SaaS.
|
|
|
11
11
|
|---|---|---|
|
|
12
12
|
| `npx stilltrue drift` | Curated facts vs their live authoritative sources — fails the build on rot | **v0.1 focus** |
|
|
13
13
|
| `npx stilltrue golden` | Deterministic regression evals for answers | later (may wrap promptfoo) |
|
|
14
|
-
| `verify` (library) |
|
|
14
|
+
| `verify` (library) | Staged acceptance pipeline for AI output — judge answers against sources in the request path | **shipped 0.3.0** |
|
|
15
15
|
|
|
16
16
|
**Why drift first:** "drift detection" in the market means statistical ML
|
|
17
17
|
feature drift; website-change monitors alert humans, not CI. Nobody ships
|
|
@@ -69,10 +69,37 @@ jobs:
|
|
|
69
69
|
deterministic either way). `expect` is your curated file. Rot fails the run
|
|
70
70
|
with one message per vanished fact; unreachable sources only warn.
|
|
71
71
|
|
|
72
|
+
## Verify — staged acceptance for AI output
|
|
73
|
+
|
|
74
|
+
An ordered, short-circuiting pipeline: each stage is a pluggable judge — a
|
|
75
|
+
Zod parse, a deterministic validator, or an LLM. Rejection is structured
|
|
76
|
+
(`{ stage, messages[] }`, one issue per entry) and designed to be fed back to
|
|
77
|
+
the generator for a retry. Stages that *throw* are infrastructure failures,
|
|
78
|
+
not verdicts: `failPolicy: 'open'` (default) warns and continues — a judge
|
|
79
|
+
outage must never block your app.
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
import { runVerify, zodStage, generateVerified, formatRejection } from 'stilltrue';
|
|
83
|
+
|
|
84
|
+
const stages = [
|
|
85
|
+
zodStage('parse_schema', AnswerSchema),
|
|
86
|
+
{ name: 'grounding', timeoutMs: 10_000, check: llmJudge }, // may return a revision
|
|
87
|
+
{ name: 'chase-sim', tier: 'ci', check: expensiveSimulation }, // skipped inline
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
// Request path:
|
|
91
|
+
const result = await runVerify(stages, draft);
|
|
92
|
+
publish(result.value); // draft, parsed draft, or the judge's rewrite
|
|
93
|
+
|
|
94
|
+
// Generator loop (one automatic retry, then fall back — don't loop forever):
|
|
95
|
+
const gen = await generateVerified({ generate: (feedback) => ask(model, prompt, feedback && formatRejection(feedback)), stages });
|
|
96
|
+
```
|
|
97
|
+
|
|
72
98
|
## Status
|
|
73
99
|
|
|
74
|
-
v0.
|
|
75
|
-
`
|
|
100
|
+
v0.3 — `drift` and `report` running in production CI (AskGwinnett); `verify`
|
|
101
|
+
shipped as a library. `golden` remains open: wrap promptfoo or defer to it
|
|
102
|
+
(see the brief's competitive research).
|
|
76
103
|
|
|
77
104
|
## License
|
|
78
105
|
|
package/dist/cli.js
CHANGED
|
@@ -6,9 +6,12 @@
|
|
|
6
6
|
* outage must not block CI), 1 = rot detected or usage error.
|
|
7
7
|
*/
|
|
8
8
|
import { writeFile } from 'node:fs/promises';
|
|
9
|
+
import { resolve } from 'node:path';
|
|
9
10
|
import { parseArgs } from 'node:util';
|
|
10
11
|
import { findConfigPath, loadConfig } from "./loadConfig.js";
|
|
11
12
|
import { runDrift } from "./runner.js";
|
|
13
|
+
import { DEFAULT_HISTORY_PATH, loadHistory, recordRun } from "./history.js";
|
|
14
|
+
import { renderReport } from "./report.js";
|
|
12
15
|
const GH = !!process.env.GITHUB_ACTIONS;
|
|
13
16
|
function printResult(r) {
|
|
14
17
|
const icon = { pass: '✓', rot: '✗', error: '⚠' }[r.outcome];
|
|
@@ -31,6 +34,8 @@ async function drift(argv) {
|
|
|
31
34
|
config: { type: 'string' },
|
|
32
35
|
only: { type: 'string' },
|
|
33
36
|
json: { type: 'string' },
|
|
37
|
+
record: { type: 'boolean' },
|
|
38
|
+
history: { type: 'string' },
|
|
34
39
|
},
|
|
35
40
|
});
|
|
36
41
|
const configPath = findConfigPath(process.cwd(), values.config);
|
|
@@ -53,21 +58,49 @@ async function drift(argv) {
|
|
|
53
58
|
await writeFile(values.json, JSON.stringify(results, null, 2));
|
|
54
59
|
console.log(`results written to ${values.json}`);
|
|
55
60
|
}
|
|
61
|
+
if (values.record) {
|
|
62
|
+
const historyPath = resolve(values.history ?? DEFAULT_HISTORY_PATH);
|
|
63
|
+
await recordRun(results, historyPath);
|
|
64
|
+
console.log(`run recorded to ${historyPath}`);
|
|
65
|
+
}
|
|
56
66
|
return rotted > 0 ? 1 : 0;
|
|
57
67
|
}
|
|
68
|
+
async function report(argv) {
|
|
69
|
+
const { values } = parseArgs({
|
|
70
|
+
args: argv,
|
|
71
|
+
options: {
|
|
72
|
+
history: { type: 'string' },
|
|
73
|
+
out: { type: 'string' },
|
|
74
|
+
title: { type: 'string' },
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
const historyPath = resolve(values.history ?? DEFAULT_HISTORY_PATH);
|
|
78
|
+
const history = await loadHistory(historyPath);
|
|
79
|
+
if (!history.length) {
|
|
80
|
+
console.error(`no recorded runs at ${historyPath} — run \`stilltrue drift --record\` first (use --history to point elsewhere)`);
|
|
81
|
+
return 1;
|
|
82
|
+
}
|
|
83
|
+
const out = resolve(values.out ?? 'stilltrue-report.html');
|
|
84
|
+
await writeFile(out, renderReport(history, values.title));
|
|
85
|
+
console.log(`report written to ${out} (${history.length} runs)`);
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
58
88
|
const [command, ...rest] = process.argv.slice(2);
|
|
59
89
|
try {
|
|
60
90
|
switch (command) {
|
|
61
91
|
case 'drift':
|
|
62
92
|
process.exit(await drift(rest));
|
|
63
93
|
break;
|
|
64
|
-
case 'golden':
|
|
65
94
|
case 'report':
|
|
66
|
-
|
|
95
|
+
process.exit(await report(rest));
|
|
96
|
+
break;
|
|
97
|
+
case 'golden':
|
|
98
|
+
console.error('stilltrue golden: not implemented yet — drift shipped first (docs/BRIEF.md)');
|
|
67
99
|
process.exit(1);
|
|
68
100
|
break;
|
|
69
101
|
default:
|
|
70
|
-
console.log('usage: stilltrue drift [--config <path>] [--only <names>] [--json <path>]'
|
|
102
|
+
console.log('usage: stilltrue drift [--config <path>] [--only <names>] [--json <path>] [--record] [--history <path>]\n' +
|
|
103
|
+
' stilltrue report [--history <path>] [--out <path>] [--title <text>]');
|
|
71
104
|
process.exit(command ? 1 : 0);
|
|
72
105
|
}
|
|
73
106
|
}
|
package/dist/config.d.ts
CHANGED
|
@@ -56,9 +56,10 @@ export interface DriftCheck<E = unknown, A = unknown> {
|
|
|
56
56
|
}
|
|
57
57
|
export interface StilltrueConfig {
|
|
58
58
|
drift?: DriftCheck<any, any>[];
|
|
59
|
-
/**
|
|
59
|
+
/** Named verify pipelines (see src/verify.ts) — usable from library code. */
|
|
60
|
+
verify?: Record<string, import('./verify.ts').VerifyStage<any>[]>;
|
|
61
|
+
/** golden is a later phase — see docs/BRIEF.md build plan. */
|
|
60
62
|
golden?: unknown;
|
|
61
|
-
verify?: unknown;
|
|
62
63
|
}
|
|
63
64
|
/** Identity function for typed configs: `export default defineStilltrue({...})`. */
|
|
64
65
|
export declare function defineStilltrue(config: StilltrueConfig): StilltrueConfig;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { DriftResult } from './config.ts';
|
|
2
|
+
/** One recorded `stilltrue drift` run. */
|
|
3
|
+
export interface RunRecord {
|
|
4
|
+
at: string;
|
|
5
|
+
results: DriftResult[];
|
|
6
|
+
}
|
|
7
|
+
export declare const DEFAULT_HISTORY_PATH = ".stilltrue/history.jsonl";
|
|
8
|
+
/** Append a run to the JSONL history file (created on first record). */
|
|
9
|
+
export declare function recordRun(results: DriftResult[], historyPath: string): Promise<RunRecord>;
|
|
10
|
+
/** Load history oldest-first. Missing file → []. Corrupt lines are skipped. */
|
|
11
|
+
export declare function loadHistory(historyPath: string): Promise<RunRecord[]>;
|
package/dist/history.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { mkdir, readFile, appendFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
export const DEFAULT_HISTORY_PATH = '.stilltrue/history.jsonl';
|
|
4
|
+
/** Append a run to the JSONL history file (created on first record). */
|
|
5
|
+
export async function recordRun(results, historyPath) {
|
|
6
|
+
const record = { at: new Date().toISOString(), results };
|
|
7
|
+
await mkdir(dirname(historyPath), { recursive: true });
|
|
8
|
+
await appendFile(historyPath, JSON.stringify(record) + '\n');
|
|
9
|
+
return record;
|
|
10
|
+
}
|
|
11
|
+
/** Load history oldest-first. Missing file → []. Corrupt lines are skipped. */
|
|
12
|
+
export async function loadHistory(historyPath) {
|
|
13
|
+
let raw;
|
|
14
|
+
try {
|
|
15
|
+
raw = await readFile(historyPath, 'utf8');
|
|
16
|
+
}
|
|
17
|
+
catch (err) {
|
|
18
|
+
if (err.code === 'ENOENT')
|
|
19
|
+
return [];
|
|
20
|
+
throw err;
|
|
21
|
+
}
|
|
22
|
+
const records = [];
|
|
23
|
+
for (const line of raw.split('\n')) {
|
|
24
|
+
if (!line.trim())
|
|
25
|
+
continue;
|
|
26
|
+
try {
|
|
27
|
+
const parsed = JSON.parse(line);
|
|
28
|
+
if (parsed && typeof parsed.at === 'string' && Array.isArray(parsed.results)) {
|
|
29
|
+
records.push(parsed);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// A truncated line (interrupted CI write) shouldn't sink the report.
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return records;
|
|
37
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -2,3 +2,4 @@ export { defineStilltrue, type StilltrueConfig, type DriftCheck, type DriftResul
|
|
|
2
2
|
export { corpus, json, surname, htmlToText, type CorpusOptions } from './helpers.ts';
|
|
3
3
|
export { runDrift, runDriftCheck, type RunDriftOptions } from './runner.ts';
|
|
4
4
|
export { resolveCompare } from './compare.ts';
|
|
5
|
+
export { runVerify, zodStage, formatRejection, generateVerified, type VerifyStage, type VerifyResult, type StageOutcome, type StageContext, type RunVerifyOptions, type GenerateVerifiedOptions, } from './verify.ts';
|
package/dist/index.js
CHANGED
|
@@ -2,3 +2,4 @@ export { defineStilltrue, } from "./config.js";
|
|
|
2
2
|
export { corpus, json, surname, htmlToText } from "./helpers.js";
|
|
3
3
|
export { runDrift, runDriftCheck } from "./runner.js";
|
|
4
4
|
export { resolveCompare } from "./compare.js";
|
|
5
|
+
export { runVerify, zodStage, formatRejection, generateVerified, } from "./verify.js";
|
package/dist/report.d.ts
ADDED
package/dist/report.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
const STATUS = {
|
|
2
|
+
pass: { color: '#0ca30c', glyph: '✓', ink: '#ffffff', label: 'pass' },
|
|
3
|
+
rot: { color: '#d03b3b', glyph: '✗', ink: '#ffffff', label: 'rot' },
|
|
4
|
+
error: { color: '#fab219', glyph: '⚠', ink: '#1a1a19', label: 'error' },
|
|
5
|
+
};
|
|
6
|
+
const MAX_COLUMNS = 60;
|
|
7
|
+
const MAX_LOG = 50;
|
|
8
|
+
function esc(s) {
|
|
9
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
10
|
+
}
|
|
11
|
+
function day(iso) {
|
|
12
|
+
return iso.slice(5, 10); // MM-DD
|
|
13
|
+
}
|
|
14
|
+
export function renderReport(history, title = 'stilltrue drift report') {
|
|
15
|
+
const runs = history.slice(-MAX_COLUMNS);
|
|
16
|
+
const latest = runs[runs.length - 1];
|
|
17
|
+
// Stable row order: latest run's order first, then any check seen earlier.
|
|
18
|
+
const names = [];
|
|
19
|
+
for (const record of [...runs].reverse()) {
|
|
20
|
+
for (const r of record.results)
|
|
21
|
+
if (!names.includes(r.name))
|
|
22
|
+
names.push(r.name);
|
|
23
|
+
}
|
|
24
|
+
const counts = { pass: 0, rot: 0, error: 0 };
|
|
25
|
+
for (const r of latest?.results ?? [])
|
|
26
|
+
counts[r.outcome]++;
|
|
27
|
+
const tiles = ['pass', 'rot', 'error']
|
|
28
|
+
.map((o) => `<div class="tile">
|
|
29
|
+
<div class="tile-num" style="color:${counts[o] && o !== 'pass' ? STATUS[o].color : 'var(--ink)'}">${counts[o]}</div>
|
|
30
|
+
<div class="tile-label">${STATUS[o].glyph} ${STATUS[o].label}</div>
|
|
31
|
+
</div>`)
|
|
32
|
+
.join('');
|
|
33
|
+
const headerCells = runs
|
|
34
|
+
.map((rec, i) => {
|
|
35
|
+
const sparse = runs.length <= 10 || i % Math.ceil(runs.length / 10) === 0 || i === runs.length - 1;
|
|
36
|
+
return `<th title="${esc(rec.at)}">${sparse ? day(rec.at) : ''}</th>`;
|
|
37
|
+
})
|
|
38
|
+
.join('');
|
|
39
|
+
const rows = names
|
|
40
|
+
.map((name) => {
|
|
41
|
+
const cells = runs
|
|
42
|
+
.map((rec) => {
|
|
43
|
+
const r = rec.results.find((x) => x.name === name);
|
|
44
|
+
if (!r)
|
|
45
|
+
return '<td><span class="cell none" title="not run"></span></td>';
|
|
46
|
+
const s = STATUS[r.outcome];
|
|
47
|
+
const detail = [r.outcome, ...r.messages, ...r.warnings.map((w) => `warning: ${w}`)].join('\n');
|
|
48
|
+
return `<td><span class="cell" style="background:${s.color};color:${s.ink}" title="${esc(rec.at)}\n${esc(detail)}">${s.glyph}</span></td>`;
|
|
49
|
+
})
|
|
50
|
+
.join('');
|
|
51
|
+
return `<tr><th class="check-name">${esc(name)}</th>${cells}</tr>`;
|
|
52
|
+
})
|
|
53
|
+
.join('');
|
|
54
|
+
const events = [...runs]
|
|
55
|
+
.reverse()
|
|
56
|
+
.flatMap((rec) => rec.results
|
|
57
|
+
.filter((r) => r.outcome !== 'pass' || r.warnings.length)
|
|
58
|
+
.map((r) => ({ at: rec.at, ...r })))
|
|
59
|
+
.slice(0, MAX_LOG);
|
|
60
|
+
const log = events.length
|
|
61
|
+
? events
|
|
62
|
+
.map((e) => {
|
|
63
|
+
const s = STATUS[e.outcome];
|
|
64
|
+
const items = [...e.messages, ...e.warnings.map((w) => `warning: ${w}`)]
|
|
65
|
+
.map((m) => `<li>${esc(m)}</li>`)
|
|
66
|
+
.join('');
|
|
67
|
+
return `<details ${e.outcome === 'rot' ? 'open' : ''}>
|
|
68
|
+
<summary><span class="badge" style="background:${s.color};color:${s.ink}">${s.glyph} ${s.label}</span>
|
|
69
|
+
<strong>${esc(e.name)}</strong> <span class="muted">${esc(e.at)}</span></summary>
|
|
70
|
+
<ul>${items}</ul>
|
|
71
|
+
</details>`;
|
|
72
|
+
})
|
|
73
|
+
.join('')
|
|
74
|
+
: '<p class="muted">Nothing has rotted, errored, or warned in recorded history.</p>';
|
|
75
|
+
return `<!doctype html>
|
|
76
|
+
<meta charset="utf-8">
|
|
77
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
78
|
+
<title>${esc(title)}</title>
|
|
79
|
+
<style>
|
|
80
|
+
:root { --bg:#ffffff; --ink:#1a1a19; --ink-2:#5f5e5a; --line:#e4e2dd; --surface:#f6f5f2; }
|
|
81
|
+
@media (prefers-color-scheme: dark) {
|
|
82
|
+
:root { --bg:#1a1a19; --ink:#f2f1ee; --ink-2:#a5a39d; --line:#3a3936; --surface:#242422; }
|
|
83
|
+
}
|
|
84
|
+
* { box-sizing: border-box; }
|
|
85
|
+
body { margin:0; padding:2rem 1.5rem; background:var(--bg); color:var(--ink);
|
|
86
|
+
font:15px/1.5 ui-sans-serif, system-ui, sans-serif; max-width:64rem; margin-inline:auto; }
|
|
87
|
+
h1 { font-size:1.3rem; margin:0 0 .25rem; }
|
|
88
|
+
h2 { font-size:1rem; margin:2rem 0 .75rem; }
|
|
89
|
+
.muted { color:var(--ink-2); font-size:.85rem; }
|
|
90
|
+
.tiles { display:flex; gap:.75rem; margin:1.25rem 0; flex-wrap:wrap; }
|
|
91
|
+
.tile { background:var(--surface); border:1px solid var(--line); border-radius:8px;
|
|
92
|
+
padding:.75rem 1.25rem; min-width:6.5rem; }
|
|
93
|
+
.tile-num { font-size:1.6rem; font-weight:600; font-variant-numeric:tabular-nums; }
|
|
94
|
+
.tile-label { color:var(--ink-2); font-size:.85rem; }
|
|
95
|
+
.grid-wrap { overflow-x:auto; border:1px solid var(--line); border-radius:8px; padding:.75rem; }
|
|
96
|
+
table { border-collapse:separate; border-spacing:2px; }
|
|
97
|
+
th { font-weight:500; color:var(--ink-2); font-size:.7rem; text-align:left; white-space:nowrap; }
|
|
98
|
+
thead th { vertical-align:bottom; padding-bottom:.25rem; }
|
|
99
|
+
.check-name { font-size:.85rem; color:var(--ink); padding-right:.75rem; position:sticky; left:0; background:var(--bg); }
|
|
100
|
+
.cell { display:inline-flex; width:20px; height:20px; border-radius:4px;
|
|
101
|
+
align-items:center; justify-content:center; font-size:12px; }
|
|
102
|
+
.cell.none { background:var(--surface); border:1px dashed var(--line); }
|
|
103
|
+
.badge { display:inline-block; border-radius:4px; padding:0 .45rem; font-size:.8rem; margin-right:.5rem; }
|
|
104
|
+
details { border-left:2px solid var(--line); padding:.35rem .75rem; margin:.4rem 0; }
|
|
105
|
+
summary { cursor:pointer; }
|
|
106
|
+
details ul { margin:.4rem 0 .2rem; padding-left:1.2rem; }
|
|
107
|
+
details li { font-size:.9rem; }
|
|
108
|
+
</style>
|
|
109
|
+
<h1>${esc(title)}</h1>
|
|
110
|
+
<p class="muted">${runs.length} recorded run${runs.length === 1 ? '' : 's'}${latest ? `, latest ${esc(latest.at)}` : ''} — pass / rot (facts changed) / error (source unreachable)</p>
|
|
111
|
+
<div class="tiles">${tiles}</div>
|
|
112
|
+
<h2>Timeline</h2>
|
|
113
|
+
<div class="grid-wrap"><table>
|
|
114
|
+
<thead><tr><th></th>${headerCells}</tr></thead>
|
|
115
|
+
<tbody>${rows}</tbody>
|
|
116
|
+
</table></div>
|
|
117
|
+
<h2>Rot, errors & warnings</h2>
|
|
118
|
+
${log}
|
|
119
|
+
`;
|
|
120
|
+
}
|
package/dist/verify.d.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `verify` — an ordered, short-circuiting acceptance pipeline for AI (or any
|
|
3
|
+
* generator) output. Modeled on the P9 level-rejection contract, generalized
|
|
4
|
+
* with the review.js production doctrine:
|
|
5
|
+
*
|
|
6
|
+
* - Stages run in a fixed order and stop at the first rejection; later
|
|
7
|
+
* stages may assume earlier ones passed. (P9)
|
|
8
|
+
* - Rejection is structured and machine-readable — `{ stage, messages[] }`,
|
|
9
|
+
* one issue per entry — designed to be fed back to the generator. (P9)
|
|
10
|
+
* - The judge is pluggable: a Zod parse, a deterministic validator, or an
|
|
11
|
+
* LLM. The pipeline neither knows nor cares. (P9 + review.js)
|
|
12
|
+
* - A stage that THROWS is an infrastructure failure, not a verdict.
|
|
13
|
+
* `failPolicy: 'open'` (default) warns and continues — a judge outage must
|
|
14
|
+
* never block the app; `'closed'` treats it as rejection. (review.js)
|
|
15
|
+
* - A rejecting stage may supply a `revision` — a corrected value ready to
|
|
16
|
+
* use (review.js's judge rewrite). The pipeline stops there; the result
|
|
17
|
+
* carries the revision and is marked `revised`.
|
|
18
|
+
* - Tiers: `inline` stages are cheap and run everywhere; `ci` stages are
|
|
19
|
+
* expensive (simulations, live fetches) and run only when asked. (P9)
|
|
20
|
+
*/
|
|
21
|
+
import type { ZodType } from 'zod';
|
|
22
|
+
export type StageOutcome<T> =
|
|
23
|
+
/** Pass. `value` optionally replaces the input for later stages (e.g. a parse). */
|
|
24
|
+
{
|
|
25
|
+
ok: true;
|
|
26
|
+
value?: T;
|
|
27
|
+
}
|
|
28
|
+
/** Reject: one issue per message. `revision` is an optional corrected value. */
|
|
29
|
+
| {
|
|
30
|
+
ok: false;
|
|
31
|
+
messages: string[];
|
|
32
|
+
revision?: T;
|
|
33
|
+
};
|
|
34
|
+
export interface StageContext {
|
|
35
|
+
warn: (message: string) => void;
|
|
36
|
+
/** Which tier this run was invoked with. */
|
|
37
|
+
tier: 'inline' | 'ci';
|
|
38
|
+
}
|
|
39
|
+
export interface VerifyStage<T = unknown> {
|
|
40
|
+
name: string;
|
|
41
|
+
check: (input: T, ctx: StageContext) => StageOutcome<T> | Promise<StageOutcome<T>>;
|
|
42
|
+
/**
|
|
43
|
+
* What a THROWN stage means. 'open' (default): infrastructure failure —
|
|
44
|
+
* warn and continue with the input unchanged. 'closed': reject.
|
|
45
|
+
*/
|
|
46
|
+
failPolicy?: 'open' | 'closed';
|
|
47
|
+
/** Abort the stage after this long; the timeout follows failPolicy. */
|
|
48
|
+
timeoutMs?: number;
|
|
49
|
+
/** 'inline' (default) runs always; 'ci' runs only with `{ tier: 'ci' }`. */
|
|
50
|
+
tier?: 'inline' | 'ci';
|
|
51
|
+
}
|
|
52
|
+
export interface VerifyResult<T> {
|
|
53
|
+
ok: boolean;
|
|
54
|
+
/** First failing stage; absent on success. */
|
|
55
|
+
stage?: string;
|
|
56
|
+
/** The failing stage's messages only — not cumulative (P9 §2). */
|
|
57
|
+
messages: string[];
|
|
58
|
+
/** Final value: the (possibly transformed) input, or the revision. */
|
|
59
|
+
value: T;
|
|
60
|
+
/** True when a rejecting stage supplied a corrected value. */
|
|
61
|
+
revised: boolean;
|
|
62
|
+
/** Fail-open infrastructure failures and stage warnings. */
|
|
63
|
+
warnings: string[];
|
|
64
|
+
stagesRun: string[];
|
|
65
|
+
}
|
|
66
|
+
export interface RunVerifyOptions {
|
|
67
|
+
/** 'inline' (default) skips `tier: 'ci'` stages; 'ci' runs everything. */
|
|
68
|
+
tier?: 'inline' | 'ci';
|
|
69
|
+
}
|
|
70
|
+
export declare function runVerify<T>(stages: VerifyStage<T>[], input: T, options?: RunVerifyOptions): Promise<VerifyResult<T>>;
|
|
71
|
+
/**
|
|
72
|
+
* Stage helper: a P9-style `parse_schema` gate from a Zod schema. On success
|
|
73
|
+
* the parsed (typed, defaulted, stripped) value replaces the input for later
|
|
74
|
+
* stages; on failure, one message per Zod issue with its path.
|
|
75
|
+
*/
|
|
76
|
+
export declare function zodStage<T>(name: string, schema: ZodType<T>): VerifyStage<any>;
|
|
77
|
+
/**
|
|
78
|
+
* Format a rejection as generator feedback — stage id + messages verbatim as
|
|
79
|
+
* bullets (P9 §3: preserve strings so distances and ids stay fixable).
|
|
80
|
+
*/
|
|
81
|
+
export declare function formatRejection(result: VerifyResult<any>): string;
|
|
82
|
+
export interface GenerateVerifiedOptions<T> {
|
|
83
|
+
/** Produce a candidate. On retries, `feedback` is the previous rejection. */
|
|
84
|
+
generate: (feedback?: VerifyResult<T>) => T | Promise<T>;
|
|
85
|
+
stages: VerifyStage<T>[];
|
|
86
|
+
/**
|
|
87
|
+
* Total attempts, default 2 (P9 §3: one automatic retry; more wastes
|
|
88
|
+
* tokens — after the cap, fall back or ask a human, don't loop).
|
|
89
|
+
*/
|
|
90
|
+
attempts?: number;
|
|
91
|
+
tier?: 'inline' | 'ci';
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The generate → verify → feed-back-and-retry loop as a first-class helper.
|
|
95
|
+
* Returns the first passing result, or the LAST rejection after the cap —
|
|
96
|
+
* never silently discards errors (P9 §3.4).
|
|
97
|
+
*/
|
|
98
|
+
export declare function generateVerified<T>(options: GenerateVerifiedOptions<T>): Promise<VerifyResult<T> & {
|
|
99
|
+
attempts: number;
|
|
100
|
+
}>;
|
package/dist/verify.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
async function runStage(stage, input, ctx) {
|
|
2
|
+
const run = Promise.resolve(stage.check(input, ctx));
|
|
3
|
+
if (!stage.timeoutMs)
|
|
4
|
+
return run;
|
|
5
|
+
let timer;
|
|
6
|
+
const timeout = new Promise((_, reject) => {
|
|
7
|
+
timer = setTimeout(() => reject(new Error(`stage timeout after ${stage.timeoutMs}ms`)), stage.timeoutMs);
|
|
8
|
+
});
|
|
9
|
+
try {
|
|
10
|
+
return await Promise.race([run, timeout]);
|
|
11
|
+
}
|
|
12
|
+
finally {
|
|
13
|
+
clearTimeout(timer);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export async function runVerify(stages, input, options = {}) {
|
|
17
|
+
const tier = options.tier ?? 'inline';
|
|
18
|
+
const warnings = [];
|
|
19
|
+
const stagesRun = [];
|
|
20
|
+
const ctx = { warn: (m) => warnings.push(m), tier };
|
|
21
|
+
let value = input;
|
|
22
|
+
for (const stage of stages) {
|
|
23
|
+
if ((stage.tier ?? 'inline') === 'ci' && tier !== 'ci')
|
|
24
|
+
continue;
|
|
25
|
+
stagesRun.push(stage.name);
|
|
26
|
+
let outcome;
|
|
27
|
+
try {
|
|
28
|
+
outcome = await runStage(stage, value, ctx);
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
const message = `${stage.name}: ${err instanceof Error ? err.message : String(err)}`;
|
|
32
|
+
if ((stage.failPolicy ?? 'open') === 'open') {
|
|
33
|
+
warnings.push(`fail-open: ${message}`);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
return { ok: false, stage: stage.name, messages: [message], value, revised: false, warnings, stagesRun };
|
|
37
|
+
}
|
|
38
|
+
if (!outcome.ok) {
|
|
39
|
+
const revised = outcome.revision !== undefined;
|
|
40
|
+
return {
|
|
41
|
+
ok: false,
|
|
42
|
+
stage: stage.name,
|
|
43
|
+
messages: outcome.messages,
|
|
44
|
+
value: revised ? outcome.revision : value,
|
|
45
|
+
revised,
|
|
46
|
+
warnings,
|
|
47
|
+
stagesRun,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
if (outcome.value !== undefined)
|
|
51
|
+
value = outcome.value;
|
|
52
|
+
}
|
|
53
|
+
return { ok: true, messages: [], value, revised: false, warnings, stagesRun };
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Stage helper: a P9-style `parse_schema` gate from a Zod schema. On success
|
|
57
|
+
* the parsed (typed, defaulted, stripped) value replaces the input for later
|
|
58
|
+
* stages; on failure, one message per Zod issue with its path.
|
|
59
|
+
*/
|
|
60
|
+
export function zodStage(name, schema) {
|
|
61
|
+
return {
|
|
62
|
+
name,
|
|
63
|
+
check: (input) => {
|
|
64
|
+
const parsed = schema.safeParse(input);
|
|
65
|
+
if (parsed.success)
|
|
66
|
+
return { ok: true, value: parsed.data };
|
|
67
|
+
return {
|
|
68
|
+
ok: false,
|
|
69
|
+
messages: parsed.error.issues.map((i) => `${i.path.length ? i.path.join('.') : '(root)'}: ${i.message}`),
|
|
70
|
+
};
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Format a rejection as generator feedback — stage id + messages verbatim as
|
|
76
|
+
* bullets (P9 §3: preserve strings so distances and ids stay fixable).
|
|
77
|
+
*/
|
|
78
|
+
export function formatRejection(result) {
|
|
79
|
+
return `Rejected at stage "${result.stage}":\n${result.messages.map((m) => `- ${m}`).join('\n')}`;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The generate → verify → feed-back-and-retry loop as a first-class helper.
|
|
83
|
+
* Returns the first passing result, or the LAST rejection after the cap —
|
|
84
|
+
* never silently discards errors (P9 §3.4).
|
|
85
|
+
*/
|
|
86
|
+
export async function generateVerified(options) {
|
|
87
|
+
const attempts = Math.max(1, options.attempts ?? 2);
|
|
88
|
+
let last;
|
|
89
|
+
for (let i = 0; i < attempts; i++) {
|
|
90
|
+
const candidate = await options.generate(last);
|
|
91
|
+
last = await runVerify(options.stages, candidate, { tier: options.tier });
|
|
92
|
+
if (last.ok)
|
|
93
|
+
return { ...last, attempts: i + 1 };
|
|
94
|
+
}
|
|
95
|
+
return { ...last, attempts };
|
|
96
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "stilltrue",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Your tests check your code. stilltrue checks your facts. CI checks for AI-in-the-loop apps: drift (curated facts vs live sources), golden (regression evals), verify (judge answers against sources).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"drift",
|