Skip to content

Commit c1500f1

Browse files
authored
Merge pull request #326 from SweetSophia/fix/325-shadow-measurement
fix(shadow-eval): expose denominators and preflight fixture freshness
2 parents a80218b + 3e22bfa commit c1500f1

7 files changed

Lines changed: 420 additions & 13 deletions

File tree

.github/workflows/ci.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,11 @@ jobs:
165165
- name: Run hybrid shadow harness tests (no services required)
166166
run: npm run test:hybrid-shadow
167167

168+
- name: Run shadow freshness tests against isolated temporary corpus
169+
run: npm run test:hybrid-shadow-db
170+
env:
171+
SHADOW_TEST_DATABASE_URL: ${{ env.DATABASE_URL }}
172+
168173
- name: Run memory tests
169174
run: npm run test:memory
170175

docs/HYBRID-SHADOW-EVALUATION.md

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# Shadow evaluation: denominators and fixture freshness
2+
3+
The local operator CLI `npm run hybrid:shadow-eval` runs keyword then hybrid
4+
search without serving results. This is measurement tooling, not a serving
5+
acceptance gate. Reports remain private; admin runs can include restricted
6+
rankings. Search may write caches, authorize dispatch, and call embeddings.
7+
8+
## Freshness before evaluation
9+
10+
Every dual-path run performs an exact article lookup **before searching** and
11+
persists `freshness-<uuid>.json`, even if subsequent retrieval fails. It also
12+
includes freshness scope, timestamps, outcome and counts in aggregate JSON and
13+
Markdown. A database lookup failure aborts the run; it is not evidence of absence.
14+
To inspect only freshness, without search or embedding configuration:
15+
16+
```sh
17+
npm run hybrid:shadow-eval -- --preflight-only --scopes unscoped --out <private-dir>
18+
```
19+
20+
Supply `DATABASE_URL` for the authorized evaluation corpus (the extension-less
21+
application identity). Do not use production access without separate approval.
22+
The standalone preflight emits only private JSON, not an empty metric report.
23+
Files are created exclusively with owner-only permissions; directories created
24+
by the harness use mode 0700. Do not publish these files.
25+
26+
Scope is a trusted local-operator choice, **not authentication**. Like the
27+
existing `--scopes admin`, the following option asserts that the operator is
28+
already authorized to inspect all scopes. Never expose these flags through an
29+
untrusted API or hand an unauthorized user an all-corpus database credential.
30+
31+
```sh
32+
# Only an authorized administrator may request broader freshness evidence.
33+
# Retrieval remains unscoped; the preflight may distinguish hidden from missing.
34+
npm run hybrid:shadow-eval -- --preflight-only --scopes unscoped --freshness-admin --out <private-dir>
35+
```
36+
37+
Without `--freshness-admin`, preflight evidence is limited to the evaluation
38+
scope. `--scopes admin` already explicitly selects all scopes for both phases.
39+
There is no automatic privilege escalation after a lookup/search miss.
40+
The exported freshness helper likewise supports only unscoped (`undefined` or
41+
`[]`) and admin (`["*"]`) scopes; named or mixed scopes are rejected.
42+
43+
Judgments include grade-zero entries and are reported per query/slug:
44+
45+
- `visible`: exactly one match in the authorized evidence, visible to evaluation.
46+
- `excluded-by-scope`: one active match, outside evaluation scope; only emitted
47+
with explicit admin evidence.
48+
- `corpus-absent`: no active match; only emitted with admin evidence.
49+
- `unknown/not-visible`: no authorized visible match. Missing and restricted
50+
slugs are indistinguishable; this is **not** a claim of global absence.
51+
- `ambiguous`: more than one match in the authorized evidence, since article
52+
slugs are unique only within a topic. No article is silently selected.
53+
54+
The corpus here is recall-eligible: soft-deleted and recall-quarantined articles
55+
are excluded before aggregation, as are articles whose every provenance source
56+
group contains a revoked lineage or a generation/snapshot mismatch. This matches
57+
the final eligibility checks in both recall paths: no provenance is eligible,
58+
and one entirely valid source group is sufficient. A blocked duplicate cannot
59+
create ambiguity. `corpus-absent` means absent from this eligible corpus, not
60+
physical absence; no quarantine or lineage metadata/reasons are reported.
61+
Unscoped evidence keeps missing, restricted and privacy-ineligible rows
62+
indistinguishable. The single SQL statement observes a snapshot, without recall's
63+
serialization locks; a later revocation can invalidate this preflight.
64+
Draft, reviewed and published statuses are all included, matching the provider;
65+
this is not published-only mode. Hidden duplicates cannot be detected by unscoped evidence: `visible` and
66+
`clear` describe only the inspected scope. The lookup projects only fixture
67+
slugs and aggregate counts, not titles, tags, topic names or article IDs.
68+
69+
`needs-review` warns about any non-visible judgment but does not stop exploratory
70+
measurement or alter the fixture. Before using a report for a decision, review
71+
these outcomes explicitly. A `clear` preflight alone is not decision-grade
72+
retrieval evidence. It checks presence/ambiguity, not semantic relevance, and is
73+
a point-in-time observation: corpus or permission changes after it can invalidate
74+
it. Search misses never establish stale judgments. No scores, judgments or
75+
metric denominators are automatically changed; fixture revisions require review.
76+
77+
## Metric denominators
78+
79+
For each path, `metrics.<path>.denominators` records `evaluated` and `excluded`
80+
query counts separately for `recall`, `ndcg`, and `mrr`. Markdown prints the same
81+
counts. Recall@k and nDCG@k exclude queries with no positive judgments; if every
82+
query is excluded, their values are null (`n/a`). MRR@limit includes every query,
83+
including no-positive queries and misses as zero, so its excluded count is zero.
84+
The MRR cutoff remains the returned limit, not k. Existing metric formulas and
85+
single-credit duplicate-slug ranking semantics are unchanged.
86+
87+
## Isolated verification
88+
89+
```sh
90+
npm run test:hybrid-shadow
91+
SHADOW_TEST_DATABASE_URL=<disposable-app-role-url> npm run test:hybrid-shadow-db
92+
```
93+
94+
The database tests create connection-local temporary Article and provenance tables,
95+
include restricted/deleted/quarantined/duplicate and lineage-revocation fixtures,
96+
and never modify the application corpus.
97+
CI runs it against its disposable database. Production retrieval quality and
98+
serving acceptance remain outside these tests.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"test:hybrid-provider": "node --test scripts/hybrid-provider.test.mjs",
1111
"test:hybrid-stack": "bash scripts/activate-hybrid-retrieval-stack.test.sh",
1212
"hybrid:shadow-eval": "tsx scripts/hybrid-shadow-eval.ts",
13+
"test:hybrid-shadow-db": "node --import tsx --test scripts/hybrid-shadow-freshness.test.ts",
1314
"test:hybrid-shadow": "node --import tsx --test scripts/hybrid-shadow-eval.test.ts",
1415
"test:memory": "node --env-file=.env.test --import tsx --test 'src/__tests__/memory/**/*.test.ts'",
1516
"test:wiki": "node --import tsx --test 'src/__tests__/wiki/**/*.test.ts'",

scripts/hybrid-shadow-eval.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,43 @@ test("rapid report writes preserve aggregate JSON, JSONL and honest secret-free
191191
} finally { await rm(dir, { recursive: true, force: true }); }
192192
});
193193

194+
test("explicit per-path denominators cover mixed, all-excluded and normal judgments", async () => {
195+
const dir = await mkdtemp(path.join(tmpdir(), "denominator-test-"));
196+
try {
197+
for (const positives of [0, 1, 3]) {
198+
const set: QuerySet = { version: 1, queries: Array.from({ length: 3 }, (_, i): QuerySet["queries"][number] => ({
199+
id: `query-${i}`, query: "test query", relevance: i < positives ? { relevant: 3 } : i === 1 ? {} : { relevant: 0 },
200+
})) };
201+
const keyword = await rankings(set, [[row("relevant")], [], [row("zero"), row("relevant")]], "keyword");
202+
const hybrid = await rankings(set, [[], [], []]);
203+
const report = buildReport(set, keyword, hybrid, parseArgs(["--k", "1"]), {});
204+
for (const p of ["keyword", "hybrid"]) assert.deepEqual(report.metrics[p].denominators, {
205+
recall: { evaluated: positives, excluded: 3 - positives },
206+
ndcg: { evaluated: positives, excluded: 3 - positives },
207+
mrr: { evaluated: 3, excluded: 0 },
208+
});
209+
assert.equal(report.metrics.keyword.recall, positives ? 1 / positives : null);
210+
assert.equal(report.metrics.keyword.ndcg, positives ? 1 / positives : null);
211+
assert.equal(report.metrics.keyword.mrr, positives === 3 ? 0.5 : positives ? 1 / 3 : 0);
212+
assert.equal(report.metrics.hybrid.recall, positives ? 0 : null);
213+
assert.equal(report.metrics.hybrid.mrr, 0);
214+
const files = await writeReport(report, dir);
215+
assert.deepEqual(JSON.parse(await readFile(files.aggregatePath, "utf8")).metrics, report.metrics);
216+
const md = await readFile(files.summaryPath, "utf8");
217+
for (const p of ["keyword", "hybrid"]) assert.ok(md.includes(`| ${p} | ${positives} | ${3 - positives} | ${positives} | ${3 - positives} | 3 | 0 |`));
218+
assert.match(md, /MRR all-query denominator/);
219+
assert.match(md, /Not checked; not decision-grade/);
220+
}
221+
} finally { await rm(dir, { recursive: true, force: true }); }
222+
});
223+
224+
test("preflight flags require explicit admin evidence opt-in", () => {
225+
assert.equal(parseArgs([]).freshnessAdmin, undefined);
226+
assert.equal(parseArgs(["--preflight-only"]).preflightOnly, true);
227+
assert.equal(parseArgs(["--freshness-admin"]).freshnessAdmin, true);
228+
assert.equal(parseArgs(["--freshness-admin"]).scopes, undefined);
229+
});
230+
194231
test("pure import has no provider/Prisma initialization; CLI rejects arguments before runtime setup", () => {
195232
const script = path.resolve(import.meta.dirname, "hybrid-shadow-eval.ts");
196233
const env = { ...process.env, DATABASE_URL: "", NOOSPHERE_HYBRID_QUERY_PROFILE_ID: "" };

scripts/hybrid-shadow-eval.ts

Lines changed: 48 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@
1414
* No article edits or corpus-vector writes are requested by this harness.
1515
* Reports can contain restricted article titles; keep them private.
1616
*
17-
* Usage (always dual-path; unreachable hybrid services may cause fallback):
17+
* Usage (dual-path evaluation by default; --preflight-only performs only the
18+
* freshness lookup and writes no metric reports — see docs/HYBRID-SHADOW-EVALUATION.md):
1819
* npm run hybrid:shadow-eval -- --limit 5 --k 5 --out <dir>
20+
* npm run hybrid:shadow-eval -- --preflight-only --out <dir>
1921
*
2022
* Full dual-path run: must execute inside the compose network so the pinned
2123
* provider endpoint (host.docker.internal:8741) resolves, with the app-role
@@ -46,6 +48,7 @@ import { pathToFileURL } from "node:url";
4648
import type { MemoryProvider } from "@/lib/memory/provider";
4749
import type { MemoryResult } from "@/lib/memory/types";
4850
import { HYBRID_MAX_WINDOW } from "@/lib/memory/hybrid-ranking";
51+
import { checkFixtureFreshness, type FixtureFreshness } from "./hybrid-shadow-freshness";
4952

5053
interface GradedQuery {
5154
id: string;
@@ -76,9 +79,11 @@ function requireEnv(name: string): string {
7679
return value;
7780
}
7881

79-
export function parseArgs(argv: string[]): { limit: number; k: number; outDir: string; scopes: string[] | undefined } {
80-
const opts = { limit: 10, k: 5, outDir: "hybrid-shadow-reports", scopes: undefined as string[] | undefined };
82+
export function parseArgs(argv: string[]): { limit: number; k: number; outDir: string; scopes: string[] | undefined; preflightOnly?: boolean; freshnessAdmin?: boolean } {
83+
const opts: ReturnType<typeof parseArgs> = { limit: 10, k: 5, outDir: "hybrid-shadow-reports", scopes: undefined as string[] | undefined };
8184
for (let i = 0; i < argv.length; i += 1) {
85+
if (argv[i] === "--preflight-only") { opts.preflightOnly = true; continue; }
86+
if (argv[i] === "--freshness-admin") { opts.freshnessAdmin = true; continue; }
8287
if (!["--limit", "--k", "--out", "--scopes"].includes(argv[i])) throw new Error(`unknown argument: ${argv[i]}`);
8388
if (!argv[i + 1]?.trim() || argv[i + 1].startsWith("--")) throw new Error(`missing value for ${argv[i]}`);
8489
if (argv[i] === "--limit") opts.limit = Number(argv[++i]);
@@ -207,8 +212,9 @@ export function buildReport(
207212
hybridRankings: Ranking[],
208213
opts: ReturnType<typeof parseArgs>,
209214
environment: Readonly<Record<string, string | undefined>>,
215+
freshness: FixtureFreshness | null = null,
210216
) {
211-
const metrics: Record<string, { path: string; recall: number | null; ndcg: number | null; mrr: number | null; latencyP50: number; fallbacks: number; fallbackUnknown: number }> = {};
217+
const metrics: Record<string, { path: string; recall: number | null; ndcg: number | null; mrr: number | null; latencyP50: number; fallbacks: number; fallbackUnknown: number; denominators: { recall: { evaluated: number; excluded: number }; ndcg: { evaluated: number; excluded: number }; mrr: { evaluated: number; excluded: number } } }> = {};
212218
for (const [path, rankings] of [["keyword", keywordRankings], ["hybrid", hybridRankings]] as const) {
213219
let recallSum = 0, recallN = 0, ndcgSum = 0, ndcgN = 0, mrrSum = 0, fallbacks = 0, fallbackUnknown = 0;
214220
const latencies: number[] = [];
@@ -235,6 +241,11 @@ export function buildReport(
235241
latencyP50: latencies[Math.floor(latencies.length / 2)] ?? 0,
236242
fallbacks,
237243
fallbackUnknown,
244+
denominators: {
245+
recall: { evaluated: recallN, excluded: rankings.length - recallN },
246+
ndcg: { evaluated: ndcgN, excluded: rankings.length - ndcgN },
247+
mrr: { evaluated: rankings.length, excluded: 0 },
248+
},
238249
};
239250
}
240251

@@ -245,6 +256,7 @@ export function buildReport(
245256
limit: opts.limit,
246257
k: opts.k,
247258
relevanceTiers: RELEVANCE_TIERS,
259+
freshness,
248260
observation: {
249261
scope: opts.scopes?.includes("*") ? "admin (all scopes, all statuses)" : "unscoped (unrestricted articles, all statuses)",
250262
redis: environment.REDIS_URL ? "configured" : "not configured",
@@ -282,6 +294,20 @@ export async function writeReport(report: ReturnType<typeof buildReport>, outDir
282294
const m = report.metrics[p];
283295
md.push(`| ${p} | ${m.recall?.toFixed(3) ?? "n/a"} | ${m.ndcg?.toFixed(3) ?? "n/a"} | ${m.mrr?.toFixed(3) ?? "n/a"} | ${m.latencyP50} | ${m.fallbacks} | ${m.fallbackUnknown} |`);
284296
}
297+
md.push("", "| path | recall evaluated | recall excluded | nDCG evaluated | nDCG excluded | MRR all-query denominator | MRR excluded |",
298+
"| --- | --- | --- | --- | --- | --- | --- |");
299+
for (const p of ["keyword", "hybrid"]) {
300+
const d = report.metrics[p].denominators;
301+
md.push(`| ${p} | ${d.recall.evaluated} | ${d.recall.excluded} | ${d.ndcg.evaluated} | ${d.ndcg.excluded} | ${d.mrr.evaluated} | ${d.mrr.excluded} |`);
302+
}
303+
md.push("", "## Fixture freshness");
304+
if (report.freshness) {
305+
const f = report.freshness;
306+
md.push("", `Evaluation scope: ${f.evaluationScope}; evidence scope: ${f.evidenceScope}.`,
307+
`Started: ${f.startedAt}; checked: ${f.checkedAt}; outcome: ${f.outcome}.`, "", f.limitation,
308+
"", ...Object.entries(f.counts).map(([status, count]) => `- ${status}: ${count}`),
309+
"", "Per-judgment outcomes are in the private aggregate JSON.");
310+
} else md.push("", "Not checked; not decision-grade evidence.");
285311
md.push(``, `Full per-query rankings: \`${path.basename(jsonl)}\``);
286312
md.push(`Aggregate report: \`${path.basename(aggregatePath)}\``);
287313
await writeFile(summaryPath, md.join("\n") + "\n", { flag: "wx", mode: 0o600 });
@@ -293,19 +319,28 @@ async function main(): Promise<void> {
293319
const fixturePath = path.resolve(import.meta.dirname, "../src/__tests__/fixtures/hybrid-shadow-queries.json");
294320
const querySet = loadQuerySet(await readFile(fixturePath, "utf8"));
295321
const databaseUrl = requireEnv("DATABASE_URL");
296-
const baseEnv = {
322+
const baseEnv = opts.preflightOnly ? process.env : {
297323
...process.env,
298324
NOOSPHERE_HYBRID_QUERY_PROFILE_ID: requireEnv("NOOSPHERE_HYBRID_QUERY_PROFILE_ID"),
299325
NOOSPHERE_HYBRID_CACHE_HMAC_ACTIVE_VERSION: requireEnv("NOOSPHERE_HYBRID_CACHE_HMAC_ACTIVE_VERSION"),
300326
NOOSPHERE_HYBRID_CACHE_HMAC_KEYS_B64: requireEnv("NOOSPHERE_HYBRID_CACHE_HMAC_KEYS_B64"),
301327
};
302-
// Pure imports above never initialize Prisma, Redis, or a provider.
303-
const [{ PrismaClient }, { PrismaPg }, { Pool }, { createNoosphereProvider }, { closeRedisClient }] = await Promise.all([
304-
import("@prisma/client"), import("@prisma/adapter-pg"), import("pg"),
305-
import("@/lib/memory/noosphere"), import("@/lib/cache/redis"),
306-
]);
328+
// Preflight-only never imports Prisma, Redis, or the search provider.
329+
const { Pool } = await import("pg");
307330
const pool = new Pool({ connectionString: databaseUrl, max: 2 });
308331
try {
332+
const freshness = await checkFixtureFreshness(pool, querySet, opts.scopes, opts.freshnessAdmin);
333+
// Persist preflight even if later retrieval fails. No search/cache/embedding
334+
// work is requested by preflight-only. Do not treat it as a metric report.
335+
await mkdir(opts.outDir, { recursive: true, mode: 0o700 });
336+
const freshnessPath = path.join(opts.outDir, `freshness-${randomUUID()}.json`);
337+
await writeFile(freshnessPath, JSON.stringify({ querySetVersion: querySet.version, freshness }, null, 2) + "\n", { flag: "wx", mode: 0o600 });
338+
process.stdout.write(`freshness: ${freshness.outcome}; evidence: ${freshnessPath}\n`);
339+
if (opts.preflightOnly) return;
340+
const [{ PrismaClient }, { PrismaPg }, { createNoosphereProvider }, { closeRedisClient }] = await Promise.all([
341+
import("@prisma/client"), import("@prisma/adapter-pg"),
342+
import("@/lib/memory/noosphere"), import("@/lib/cache/redis"),
343+
]);
309344
const prisma = new PrismaClient({ adapter: new PrismaPg(pool) });
310345
try {
311346
const keywordProvider = createNoosphereProvider({ prisma, allowedScopes: opts.scopes,
@@ -315,13 +350,13 @@ async function main(): Promise<void> {
315350
process.stdout.write(`shadow eval: ${querySet.queries.length} queries, limit ${opts.limit}, k ${opts.k}, scopes ${opts.scopes ? "admin (all scopes, all statuses)" : "unscoped (unrestricted articles, all statuses)"}\n`);
316351
const keywordRankings = await runPath("keyword", keywordProvider, querySet, opts.limit);
317352
const hybridRankings = await runPath("hybrid", hybridProvider, querySet, opts.limit);
318-
const files = await writeReport(buildReport(querySet, keywordRankings, hybridRankings, opts, baseEnv), opts.outDir);
353+
const files = await writeReport(buildReport(querySet, keywordRankings, hybridRankings, opts, baseEnv, freshness), opts.outDir);
319354
process.stdout.write(`\nreport: ${files.jsonl}\naggregate: ${files.aggregatePath}\nsummary: ${files.summaryPath}\n`);
320355
} finally {
321-
await prisma.$disconnect();
356+
try { await prisma.$disconnect(); } finally { await closeRedisClient(); }
322357
}
323358
} finally {
324-
try { await pool.end(); } finally { await closeRedisClient(); }
359+
await pool.end();
325360
}
326361
}
327362

0 commit comments

Comments
 (0)