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";
4648import type { MemoryProvider } from "@/lib/memory/provider" ;
4749import type { MemoryResult } from "@/lib/memory/types" ;
4850import { HYBRID_MAX_WINDOW } from "@/lib/memory/hybrid-ranking" ;
51+ import { checkFixtureFreshness , type FixtureFreshness } from "./hybrid-shadow-freshness" ;
4952
5053interface 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