@@ -259,6 +259,162 @@ def _items():
259259 return out
260260
261261
262+
263+ def _rougeL_prf (ref , gen ):
264+ """(precision, recall, fmeasure) for rougeL. rouge_score's convention is
265+ score(target, prediction): precision is w.r.t. the PREDICTION (gen), recall
266+ w.r.t. the TARGET (ref) -- identical arg order to the existing _rougeL()."""
267+ s = _SCORER .score (ref , gen )["rougeL" ]
268+ return s .precision , s .recall , s .fmeasure
269+
270+
271+ def field_prf (gen_text , ref_md ):
272+ """Decompose the headline rField into RECALL and PRECISION per field.
273+
274+ Same reference-anchored fields and same length normalization as
275+ field_scores() (equal-weight macro: a 3-word banner counts as much as a
276+ 500-word section), and the *same alignment rule* (each ref field takes the
277+ gen chunk that maximizes rougeL F). For that aligned pair we report:
278+
279+ recall = LCS / len(ref field) -- of the reference field, how much the
280+ extractor reproduced. This is the recall the old rField hid.
281+ precision = LCS / len(gen chunk) -- of the gen chunk aligned to this
282+ field, how much is actually in the reference; i.e. did the
283+ extractor pad the field with tokens the reference lacks.
284+ THIS is the in-field hallucination signal (rec. #1).
285+ f1 = rougeL F of the aligned pair. By construction this equals the
286+ old field_scores() macro, so f1_macro == existing rField --
287+ the new information is the recall/precision split, not F1.
288+
289+ Returns (recall_macro, precision_macro, f1_macro, per_field) where per_field
290+ rows are (name, recall, precision, f1, ref_len, gen_len).
291+
292+ NOTE (validated on the corpus): aligned precision conflates two defects --
293+ true hallucination AND mis-segmentation (gen merged two ref sections into
294+ one chunk, so half its tokens don't match the aligned field even though they
295+ ARE elsewhere in the reference). Poster `42` scores aligned precision 0.68
296+ yet fabrication_soft() 0.003: its low precision is segmentation, not
297+ invention. Use aligned precision to localize which field degraded, and the
298+ corpus fabrication number below to say whether content was actually invented.
299+ """
300+ r_title , r_banner , r_secs = _parse_md (ref_md )
301+ chunks = [_alpha (c ) for c in _gen_chunks (gen_text )] or ["" ]
302+ fields = [("title" , r_title ), ("authors+affiliations" , r_banner )]
303+ fields += [(f"S:{ h [:34 ]} " , b ) for h , b in r_secs ]
304+ per = []
305+ for name , ref in fields :
306+ ra = _alpha (ref )
307+ if not ra .strip ():
308+ continue
309+ best = max (chunks , key = lambda c : _rougeL_prf (ra , c )[2 ])
310+ p , r , f = _rougeL_prf (ra , best )
311+ per .append ((name , round (r , 3 ), round (p , 3 ), round (f , 3 ),
312+ len (ra .split ()), len (best .split ())))
313+ r_macro = statistics .fmean (x [1 ] for x in per ) if per else float ("nan" )
314+ p_macro = statistics .fmean (x [2 ] for x in per ) if per else float ("nan" )
315+ f_macro = statistics .fmean (x [3 ] for x in per ) if per else float ("nan" )
316+ return r_macro , p_macro , f_macro , per
317+
318+
319+ # ---- corpus FABRICATION number (folds keys_check / fidelity_check) -----------
320+ # Two components, deliberately kept as distinct units because they mean
321+ # different things and a single blended float would hide the categorical one:
322+ #
323+ # SOFT token-level fabrication rate = fraction of generated alpha tokens that
324+ # are absent from the reference vocabulary. This is exactly "gen adds
325+ # tokens the reference lacks", generalized from the aligned pair to the
326+ # whole poster, so it is immune to mis-segmentation (a token counts as
327+ # supported no matter which section it landed in). Micro-averaged over
328+ # the corpus (sum unsupported / sum tokens) it is one number per
329+ # extractor. Measured 0.033 pdfplumber vs 0.316 LightOnOCR.
330+ #
331+ # HARD invented exact identifiers (ORCID / DOI / email present in the output,
332+ # absent from the poster) -- fidelity_check.py's charge. One wrong
333+ # ORCID silently misattributes work, so this is categorical, not a rate.
334+ # Measured 1 pdfplumber vs 10 LightOnOCR.
335+ #
336+ # keys_check.py's "lost lookup keys" is the mirror image (RECALL: keys dropped so
337+ # no ORCID/ROR query fires) and belongs to the recall side already surfaced by
338+ # recall_macro, not to fabrication -- lost != invented -- so it is reported as a
339+ # companion, never folded into the fabrication scalar.
340+ #
341+ # Single headline: report "soft=NN.N% hard=N ids". Flag an extractor/poster as
342+ # FABRICATING iff hard > 0 (any misattribution) OR soft > FAB_SOFT_TAU.
343+
344+ FAB_SOFT_TAU = 0.10
345+
346+ _ID_PATTERNS = {
347+ "orcid" : re .compile (r"\b\d{4}-\d{4}-\d{4}-\d{3}[\dX]\b" ),
348+ "doi" : re .compile (r"\b10\.\d{4,9}/[-._;()/:A-Za-z0-9]+\b" ),
349+ "email" : re .compile (r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b" ),
350+ }
351+
352+
353+ def _find_ids (text , kind ):
354+ t = _norm (text )
355+ if kind == "email" :
356+ t = t .replace ("\\ " , "" )
357+ return {m .group (0 ).rstrip (".,;)" ).lower ()
358+ for m in _ID_PATTERNS [kind ].finditer (t )}
359+
360+
361+ def invented_ids (gen_text , ref_md ):
362+ """{kind: [strings]} present in gen, absent from ref (hard fabrication)."""
363+ out = {}
364+ for kind in _ID_PATTERNS :
365+ extra = _find_ids (gen_text , kind ) - _find_ids (ref_md , kind )
366+ if extra :
367+ out [kind ] = sorted (extra )
368+ return out
369+
370+
371+ def fabrication_soft (gen_text , ref_md ):
372+ """(rate, n_unsupported, n_total): generated alpha tokens absent from the
373+ reference vocabulary. The soft-hallucination volume for one poster."""
374+ voc = set (_alpha (ref_md ).split ())
375+ toks = _alpha (gen_text ).split ()
376+ if not toks :
377+ return 0.0 , 0 , 0
378+ unsup = sum (1 for t in toks if t not in voc )
379+ return unsup / len (toks ), unsup , len (toks )
380+
381+
382+ def corpus_fabrication (gen_provider , label = "gen" ):
383+ """Fold both signals into ONE corpus number for an extractor.
384+
385+ gen_provider(pid, pdf, raw, ann) -> gen text (or "" / None to skip a poster,
386+ e.g. an image-only poster or a missing VLM .md). Returns a dict with the
387+ micro soft rate, the hard invented-id count, and a boolean `fabricating`.
388+ Reuse the harness for pdfplumber AND for LightOnOCR by swapping the provider
389+ (see main()). Micro (token-weighted), not macro, so long posters dominate
390+ proportionally to how much text they actually contribute.
391+ """
392+ unsup = tot = hard = 0
393+ per = []
394+ for pid , pdf , raw , ann in _items ():
395+ ref_md = open (raw , encoding = "utf-8" ).read ()
396+ gen = gen_provider (pid , pdf , raw , ann )
397+ if not gen :
398+ continue
399+ rate , u , t = fabrication_soft (gen , ref_md )
400+ inv = invented_ids (gen , ref_md )
401+ h = sum (len (v ) for v in inv .values ())
402+ unsup += u
403+ tot += t
404+ hard += h
405+ per .append ({"id" : pid , "soft" : round (rate , 3 ),
406+ "invented" : inv , "n_tok" : t })
407+ soft = unsup / tot if tot else float ("nan" )
408+ return {"label" : label , "soft" : round (soft , 4 ), "hard_ids" : hard ,
409+ "n_tok" : tot ,
410+ "fabricating" : hard > 0 or (tot and soft > FAB_SOFT_TAU ),
411+ "per_poster" : per }
412+
413+
414+ # ============================================================================
415+ # Superset driver: only ADDS keys/columns; every pre-existing key, column, and
416+ # CLI flag keeps its old meaning.
417+ # ============================================================================
262418def run (params = None ):
263419 _apply (params )
264420 rows = []
@@ -274,9 +430,19 @@ def run(params=None):
274430 row ["w" ] = round (len (_words (gen ) & _words (ref_md )) /
275431 max (len (_words (ref_md )), 1 ), 3 )
276432 row ["r_global" ] = round (_rougeL (_alpha (ref_md ), _alpha (gen )), 3 )
277- macro , fields = field_scores (gen , ref_md )
433+ macro , fields = field_scores (gen , ref_md ) # unchanged rField
278434 row ["r_field" ] = round (macro , 3 )
279435 row ["fields" ] = fields
436+ # NEW: recall/precision split + F1 (== r_field by construction)
437+ rM , pM , fM , prf = field_prf (gen , ref_md )
438+ row ["recall_field" ] = round (rM , 3 )
439+ row ["prec_field" ] = round (pM , 3 )
440+ row ["f1_field" ] = round (fM , 3 )
441+ row ["fields_prf" ] = prf
442+ # NEW: per-poster fabrication signals
443+ srate , su , st = fabrication_soft (gen , ref_md )
444+ row ["fab_soft" ] = round (srate , 3 )
445+ row ["inv_ids" ] = invented_ids (gen , ref_md )
280446 row ["affil" ] = affil_metric (gen , ref_md , annotation )
281447 except Exception as ex : # noqa: BLE001
282448 row ["error" ] = f"{ type (ex ).__name__ } : { ex } " [:160 ]
@@ -286,6 +452,7 @@ def run(params=None):
286452
287453def _fmt (rows ):
288454 print (f" { 'poster' :40s} { 'w' :>5} { 'rGlob' :>6} { 'rField' :>7} "
455+ f"{ 'rec' :>5} { 'prec' :>5} { 'fab' :>5} "
289456 f"{ 'scheme' :>8} { 'refOK' :>6} { 'genOK' :>6} { 'status' :>10} " )
290457 for r in rows :
291458 if "error" in r :
@@ -295,17 +462,25 @@ def _fmt(rows):
295462 w = f"{ r ['w' ]:.2f} " if "w" in r else " -"
296463 rg = f"{ r ['r_global' ]:.3f} " if "r_global" in r else " - "
297464 rf = f"{ r ['r_field' ]:.3f} " if "r_field" in r else " - "
465+ rc_f = f"{ r ['recall_field' ]:.2f} " if "recall_field" in r else " -"
466+ pc_f = f"{ r ['prec_field' ]:.2f} " if "prec_field" in r else " -"
467+ fb = f"{ r ['fab_soft' ]:.2f} " if "fab_soft" in r else " -"
298468 rc = a .get ("ref_correct" )
299469 gc = a .get ("gen_correct" )
300- print (f" { r ['id' ]:40s} { w :>5} { rg :>6} { rf :>7} { a .get ('scheme' ,'?' ):>8} "
470+ print (f" { r ['id' ]:40s} { w :>5} { rg :>6} { rf :>7} { rc_f :>5} { pc_f :>5} "
471+ f"{ fb :>5} { a .get ('scheme' ,'?' ):>8} "
301472 f"{ (f'{ rc :.2f} ' if rc is not None else '-' ):>6} "
302- f"{ (f'{ gc :.2f} ' if gc is not None else '-' ):>6} { a .get ('status' ,'?' ):>10} " )
473+ f"{ (f'{ gc :.2f} ' if gc is not None else '-' ):>6} "
474+ f"{ a .get ('status' ,'?' ):>10} " )
303475 core = [r for r in rows if "oos" not in r ["id" ] and "w" in r ]
304- print (f" { '-' * 94 } " )
476+ print (f" { '-' * 110 } " )
305477 if core :
306478 print (f" corpus avg w={ statistics .fmean (r ['w' ] for r in core ):.3f} "
307479 f"rGlobal={ statistics .fmean (r ['r_global' ] for r in core ):.3f} "
308- f"rField={ statistics .fmean (r ['r_field' ] for r in core ):.3f} " )
480+ f"rField={ statistics .fmean (r ['r_field' ] for r in core ):.3f} "
481+ f"recall={ statistics .fmean (r ['recall_field' ] for r in core ):.3f} "
482+ f"prec={ statistics .fmean (r ['prec_field' ] for r in core ):.3f} "
483+ f"F1={ statistics .fmean (r ['f1_field' ] for r in core ):.3f} " )
309484 passes = sum (1 for r in rows if r .get ("affil" , {}).get ("status" )
310485 in ("PASS" , "ref-ok(img)" , "single(n/a)" , "n/a(<2 authors)" ))
311486 numbered = [r for r in rows if r .get ("affil" , {}).get ("scheme" ) == "numbered" ]
@@ -317,12 +492,44 @@ def _fmt(rows):
317492 f"{ passes } /{ len (rows )} of all 21 acceptable" )
318493
319494
495+ def _fab_summary (vlm_out = None ):
496+ """Single corpus fabrication number, pdfplumber vs (optional) LightOnOCR.
497+ vlm_out: dir of scrubbed <id>.md (e.g. calibration/vlm/out). For a true
498+ apples-to-apples charge, feed SCRUBBED VLM text (vlm_scrub.scrub) here."""
499+ def ctl (pid , pdf , raw , ann ):
500+ return E .extract_text_with_pdfplumber (pdf ) if pdf else ""
501+ # VLM text is scrubbed before scoring fabrication, so the charge is against
502+ # the text we actually ship, not raw markup (raw overstates soft ~+5pts).
503+ try :
504+ sys .path .insert (0 , os .path .join (
505+ os .path .dirname (os .path .abspath (__file__ )), "vlm" ))
506+ from vlm_scrub import scrub as _scrub
507+ except Exception :
508+ def _scrub (t ):
509+ return t
510+ print ("\n FABRICATION (corpus, folds fidelity_check invented-ids + soft rate;"
511+ f" flag if hard>0 or soft>{ FAB_SOFT_TAU } ):" )
512+ for prov , name in ([(ctl , "pdfplumber" )] +
513+ ([(lambda pid , pdf , raw , ann ,
514+ _d = vlm_out : (_scrub (open (os .path .join (_d , f"{ pid } .md" ),
515+ encoding = "utf-8" ).read ())
516+ if os .path .exists (os .path .join (_d , f"{ pid } .md" ))
517+ else "" ), "LightOnOCR" )]
518+ if vlm_out and os .path .isdir (vlm_out ) else [])):
519+ f = corpus_fabrication (prov , name )
520+ print (f" { name :12s} soft={ f ['soft' ]* 100 :5.1f} % hard={ f ['hard_ids' ]:2d} ids "
521+ f"({ f ['n_tok' ]} tok) -> { 'FABRICATING' if f ['fabricating' ] else 'clean' } " )
522+
523+
320524def main ():
321525 ap = argparse .ArgumentParser ()
322526 ap .add_argument ("--set" , nargs = "*" , default = [], metavar = "K=V" )
323527 ap .add_argument ("--sweep" , nargs = 2 , metavar = ("CONST" , "V1,V2,.." ))
324528 ap .add_argument ("--save" , metavar = "PATH" )
325529 ap .add_argument ("--details" , action = "store_true" , help = "print per-field rougeL" )
530+ ap .add_argument ("--fab" , nargs = "?" , const = "vlm/out" , default = None ,
531+ metavar = "VLM_OUT_DIR" ,
532+ help = "print corpus fabrication (optionally vs a VLM out dir)" )
326533 args = ap .parse_args ()
327534 base = _snapshot ()
328535
@@ -336,6 +543,8 @@ def main():
336543 numbered = [r for r in rows if r .get ("affil" , {}).get ("scheme" ) == "numbered" ]
337544 npass = sum (1 for r in numbered if r .get ("affil" , {}).get ("status" ) == "PASS" )
338545 print (f" { const } ={ v :>7} rField={ statistics .fmean (r ['r_field' ] for r in core ):.3f} "
546+ f" prec={ statistics .fmean (r ['prec_field' ] for r in core ):.3f} "
547+ f" fab={ statistics .fmean (r ['fab_soft' ] for r in core ):.3f} "
339548 f" rGlobal={ statistics .fmean (r ['r_global' ] for r in core ):.3f} "
340549 f" affilPASS={ npass } /{ len (numbered )} " )
341550 return
@@ -344,14 +553,18 @@ def main():
344553 print (f"constants: {{**{ base } , **{ params or {}} }}\n " )
345554 rows = run (params )
346555 _fmt (rows )
556+ if args .fab is not None :
557+ vlm = args .fab if os .path .isabs (args .fab ) else os .path .join (REPO , "calibration" , args .fab )
558+ _fab_summary (vlm )
347559 if args .details :
348- print ("\n per-field rougeL :" )
560+ print ("\n per-field recall / precision / f1 :" )
349561 for r in rows :
350- if "fields " not in r :
562+ if "fields_prf " not in r :
351563 continue
352564 print (f" { r ['id' ]} " )
353- for name , sc , ln in r ["fields" ]:
354- print (f" { sc :.3f} ({ ln :4d} w) { name } " )
565+ for name , rc , pc , f1 , rl , gl in r ["fields_prf" ]:
566+ print (f" r={ rc :.3f} p={ pc :.3f} f1={ f1 :.3f} "
567+ f"(ref { rl :4d} w / gen { gl :4d} w) { name } " )
355568 if args .save :
356569 os .makedirs (os .path .dirname (args .save ) or "." , exist_ok = True )
357570 with open (args .save , "w" , encoding = "utf-8" ) as fh :
0 commit comments