Skip to content

feat(threshold): raise default private_date post-filter to 0.85 - #46

Closed
lBroth wants to merge 1 commit into
mainfrom
feat/date-threshold-default
Closed

feat(threshold): raise default private_date post-filter to 0.85#46
lBroth wants to merge 1 commit into
mainfrom
feat/date-threshold-default

Conversation

@lBroth

@lBroth lBroth commented May 30, 2026

Copy link
Copy Markdown
Owner

Summary

The GLiNER model is over-eager on bare calendar dates without identifying context — a system-prompt line like Today's date is 2026-05-21 or a footer like © 2024 Acme Inc. gets tagged private_date and erodes the token budget on every roundtrip without protecting anyone.

This adds DEFAULT_CATEGORY_THRESHOLDS with private_date: 0.85 and merges it into the post-filter on a key-by-key basis — user-supplied categoryThresholds still win for the labels they spell out. Spans above 0.85 (DOB, birth-date, explicit personal-date PII) keep firing; the long tail of generic dates drops.

Files

  • src/defaults.tsDEFAULT_CATEGORY_THRESHOLDS = { private_date: 0.85 }
  • src/nullpii.ts — merge built-in + user thresholds; user keys win
  • test/nullpii.test.ts — 2 integration tests: weak-date dropped / strong-date kept; user override still works

Test plan

  • npm test — 273 passing (was 271)
  • npm run typecheck / lint / build — clean
  • Bench validation: run packages/eval on a date-heavy corpus to confirm no recall regression on DOB / birth-date / explicit dated PII (model emits these well above 0.9 in eval-loop history). Defer until bench run is convenient.

Rationale

Choice of 0.85 is conservative — sits halfway between the global decode threshold (0.5) and the high-precision recognizer floor (0.95). If bench shows recall regression on legitimate dated PII the right knob is private_date-specific not the global cut.

🤖 Generated with Claude Code

The GLiNER model is over-eager on bare calendar dates without
identifying context — a system-prompt line like
"Today's date is 2026-05-21" or a copyright footer like "© 2024
Acme Inc." gets tagged as `private_date` and erodes the token
budget on every roundtrip without protecting anyone.

Add `DEFAULT_CATEGORY_THRESHOLDS` with `private_date: 0.85` and merge
it into the post-filter on a key-by-key basis — user-supplied
`categoryThresholds` still win for the labels they spell out. Spans
above 0.85 (DOB, birth-date, explicit personal-date PII) keep firing;
the long tail of generic dates drops.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@lBroth

lBroth commented Jul 29, 2026

Copy link
Copy Markdown
Owner Author

Closing this. The problem it targets is real, but a score threshold cannot express it — and the measured cost is a recall regression on a PII class.

It does not affect its own target case

Re-ran the real model against gateway-shaped boilerplate. The dates this PR was written to suppress score near the ceiling, so an 0.85 cut does not reach them:

HTTP  Date: header          0.993
JSON  "created_at"          1.000
"Today's date is 2026-05-21" 1.000
knowledge cutoff line        1.000
markdown release table       1.000
// TODO(2024-06-30)          1.000

On the multi-line system-prompt block from the original gateway report, all five date spans sit between 0.998 and 1.000. The threshold removes none of them. Across a 37-case sweep it drops 3 of 22 boilerplate date spans (13.6%) and 1 of 15 personal date spans (6.7%) — roughly 2× selectivity, and the personal one it removes is a birth date.

Why: the score is not measuring what we need

It tracks span-boundary confidence, not PII relevance. Personal context does not move it:

1-date  neutral   "2024-06-30"                        1.000
1-date  personal  "2024-06-30" (birth context)        1.000   ← identical
2-date  neutral   "2024-06-30, revised 2024-09-01"    0.609   ← dropped
2-date  personal  "2019-06-03, baptised 2019-09-01"   0.574   ← dropped, is a DOB

The [0.5, 0.85) band is dominated by the decoder's run-on/merge errors, which fire blind to content. That is why no cut point separates "date about a person" from "date in a footer".

Measured cost

Re-scored across OOD-7 + the internal bench, against corrected gold:

private_date   ΔFP −453   ΔTP −419      ratio 1.08 : 1
               P 0.830 → 0.856   R 0.898 → 0.862   ΔF1 −0.004

419 true date detections dropped to remove 453 false ones, and the class F1 goes down. Characterising the false positives it does remove shows they are not the target pathology either:

78.6%  boundary fragments        '62'  '63'  '6pm'
11.7%  bare year split off       '1938' from "March 17, 1938"
 4.7%  non-date run-ons          '14-11-07-47-N91-8'  '+7-029-233 1612'
 4.5%  timestamp tails           '58:00.634Z' from "1919-07-14T01:58:00.634Z"
 0.8%  carrying a personal anchor

Zero copyright footers, zero system-prompt date lines — that traffic is not in these corpora, and where it is, it scores ~1.00.

Net on the benchmark this is approximately a no-op (+0.0009 macro on OOD-7 when combined with #45, −0.0055 on the regression cell), so it is not paying for the recall it costs.

Separate defect found while reviewing

DEFAULT_CATEGORY_THRESHOLDS was spread into the per-label map, and perLabel[label] ?? globalThreshold then let the built-in beat an explicit caller threshold in both directions:

new NullPii({ threshold: 0.95 })  + private_date @ 0.90  → kept   (built-in 0.85 won)
new NullPii({ threshold: 0.95 })  + private_email @ 0.90 → dropped (correct)
new NullPii({ threshold: 0.80 })  + private_date @ 0.82  → dropped (built-in 0.85 won)

If any built-in per-category table lands later, precedence has to be by specificity with explicit caller config beating a built-in default: categoryThresholds[label]threshold → built-in table → DEFAULT_POST_FILTER_THRESHOLD.

What would actually work

A negative-context rule rather than a threshold: drop private_date when the span sits inside a frame that marks it as metadata — © / copyright, Date: / Last-Modified headers, "created_at" / "updated_at" JSON keys, knowledge cutoff, Today's date is. Same shape as the existing core:npi-us-context / core:passport-it-context anchors. Those frames do not contain birth dates, so the true-positive cost is near zero by construction — which is exactly the property the threshold lacks.

@lBroth lBroth closed this Jul 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant