feat(amb-nostr): FacetDiscovery — derive filterable facets from the data #5

Merged
laoc merged 6 commits from feature/facet-discovery into main 2026-07-31 21:53:04 +00:00
Owner

Removes the need for SearchEndpoint.php's four hardcoded RPI facets (decision 1 in the WordPress thread — redistributable scope).

Nothing is wired into the plugin yet, so no rendered output changes.

The actual problem

Enumerating tag keys is easy. Deciding which are facets is not: AMB flattens nested properties, so creator:id, hasPart:id and learningResourceType:id are indistinguishable by shape. Taking every <x>:id family would put 164 creator values in a filter dropdown.

The rule: a family is a facet when it carries both :id and a :prefLabel:<lang>. A controlled vocabulary labels its concepts; an entity reference doesn't.

Measured, not assumed

Over a 249-event live sample from amb-relay.edufeed.org, the separation is exact:

family prefLabels distinct values facet?
learningResourceType 345 30 yes
audience 471 8 yes
about 321 46 yes
educationalLevel 301 8 yes
conditionsOfAccess 146 2 yes
creator 0 164 no
hasPart 0 124 no
mainEntityOfPage 0 73 no
publisher 0 40 no
isPartOf 0 18 no
license 0 12 no

Zero on every entity family, hundreds on every vocabulary family. No middle ground to tune a threshold against.

What it finds on live data

23 facets — all four hardcoded ones, plus conditionsOfAccess and teaches which the fixed list never had, plus every ext: facet with its German labels. Exclusions asserted programmatically, 8/8.

Review notes

@TestOER — the load-bearing claim is the discriminator, so that's what's worth attacking. Two specific things:

  1. Is prefLabel presence actually the right rule, or does it just happen to hold on this corpus? My sample is 249 of 8619 events and one publisher's data shape. A family that is a genuine facet but unlabelled would be silently dropped — license already is, and I chose to accept that rather than loosen the rule (loosening readmits creator's 164 values). If you can find a family where this misclassifies, that's the finding.

  2. hasLabel is recorded even when the prefLabel attaches to no open concept. So a family with one stray orphan label and otherwise pure entity data would be promoted to a facet. I judged that acceptable because the label still says "vocabulary", but it's the loosest part of the rule.

Also worth knowing given PR #4: my parity guard there was vacuous until I mutation-tested it. I have not mutation-tested this one — the unit tests are ordinary. Treat the coverage claim accordingly.

Counts are sample counts

The return value reports sampleSize alongside every count, because a corpus-wide facet count is not obtainable over Nostr: facet:true in the relay's schema means filterable-by-exact-value only, there's no HTTP surface to reach facet_by, and REQ caps at max_limit: 250. Anything that renders these as corpus totals will be wrong.

amb-nostr 43/43, 115 assertions (PHP 8.3.32 / PHPUnit 10.5.64)
Removes the need for `SearchEndpoint.php`'s four hardcoded RPI facets (decision 1 in the WordPress thread — redistributable scope). Nothing is wired into the plugin yet, so no rendered output changes. ## The actual problem Enumerating tag keys is easy. Deciding which are *facets* is not: AMB flattens nested properties, so `creator:id`, `hasPart:id` and `learningResourceType:id` are indistinguishable by shape. Taking every `<x>:id` family would put **164 creator values** in a filter dropdown. **The rule: a family is a facet when it carries both `:id` and a `:prefLabel:<lang>`.** A controlled vocabulary labels its concepts; an entity reference doesn't. ## Measured, not assumed Over a 249-event live sample from `amb-relay.edufeed.org`, the separation is exact: | family | prefLabels | distinct values | facet? | |---|---|---|---| | learningResourceType | 345 | 30 | yes | | audience | 471 | 8 | yes | | about | 321 | 46 | yes | | educationalLevel | 301 | 8 | yes | | conditionsOfAccess | 146 | 2 | yes | | creator | **0** | 164 | no | | hasPart | **0** | 124 | no | | mainEntityOfPage | **0** | 73 | no | | publisher | **0** | 40 | no | | isPartOf | **0** | 18 | no | | license | **0** | 12 | no | Zero on every entity family, hundreds on every vocabulary family. No middle ground to tune a threshold against. ## What it finds on live data 23 facets — all four hardcoded ones, plus **`conditionsOfAccess`** and **`teaches`** which the fixed list never had, plus every `ext:` facet with its German labels. Exclusions asserted programmatically, 8/8. ## Review notes @TestOER — the load-bearing claim is **the discriminator**, so that's what's worth attacking. Two specific things: 1. **Is `prefLabel` presence actually the right rule, or does it just happen to hold on this corpus?** My sample is 249 of 8619 events and one publisher's data shape. A family that is a genuine facet but unlabelled would be silently dropped — `license` already is, and I chose to accept that rather than loosen the rule (loosening readmits creator's 164 values). If you can find a family where this misclassifies, that's the finding. 2. **`hasLabel` is recorded even when the prefLabel attaches to no open concept.** So a family with one stray orphan label and otherwise pure entity data would be promoted to a facet. I judged that acceptable because the label still says "vocabulary", but it's the loosest part of the rule. Also worth knowing given PR #4: my parity guard there was vacuous until I mutation-tested it. I have **not** mutation-tested this one — the unit tests are ordinary. Treat the coverage claim accordingly. ## Counts are sample counts The return value reports `sampleSize` alongside every `count`, because a corpus-wide facet count is not obtainable over Nostr: `facet:true` in the relay's schema means filterable-by-exact-value only, there's no HTTP surface to reach `facet_by`, and REQ caps at `max_limit: 250`. Anything that renders these as corpus totals will be wrong. ``` amb-nostr 43/43, 115 assertions (PHP 8.3.32 / PHPUnit 10.5.64) ```
SearchEndpoint.php hardcodes four RPI facets, which the redistributable
scope rules out. This derives them at runtime instead.

The hard part is not enumerating tag keys, it is deciding which ones are
facets. AMB flattens nested properties, so `creator:id`, `hasPart:id` and
`learningResourceType:id` are the same shape — but nobody filters by
creator, and taking every `<x>:id` family would put 164 creator values in
a dropdown.

The rule is SKOS-concept shape: a family is a facet when it carries both
`:id` and a `:prefLabel:<lang>`. A controlled vocabulary labels its
concepts; an entity reference does not. Measured on a 249-event live
sample rather than assumed — the separation is exact:

  facets       learningResourceType 345 prefLabels / 30 distinct values
               audience 471/8, about 321/46, educationalLevel 301/8,
               conditionsOfAccess 146/2
  not facets   creator 0 prefLabels / 164 distinct, hasPart 0/124,
               mainEntityOfPage 0/73, publisher 0/40, isPartOf 0/18,
               license 0/12

Zero prefLabels on every entity family, hundreds on every vocabulary one.

Against that live sample it finds 23 facets: all four hardcoded ones, and
also `conditionsOfAccess` and `teaches`, which the fixed list never had.
Entity families are all excluded (asserted, 8/8).

`ext:` keys bypass the discriminator — they are facets by grammar via
ExtTagParser, and a scalar ext facet has no prefLabel by construction.
They are also excluded from the core path so `ext:ekw:method:id` cannot
register a second family literally named "ext:ekw:method".

Counts are SAMPLE counts and the return value says so. Nostr exposes no
facet-count query (facet:true means filterable-by-value only, and there is
no HTTP surface to reach facet_by), and REQ is capped at max_limit 250 —
so a corpus-wide count is not available to ask for.

KNOWN EXCLUSION: `license` is filter-worthy but carries no prefLabel.
Loosening the rule to admit it would readmit creator's 164 values, so it
needs its own label source instead.

Nothing is wired into the plugin yet — no rendered output changes.

amb-nostr 43/43, 115 assertions (PHP 8.3.32 / PHPUnit 10.5.64)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review follow-ups from TestOER on #5. All three were right; the first was the
one they said to land before moving on, and they were right about that too.

**The parity guard was reporting success while doing nothing.** Nothing in the
repo set EDUFEED_APP_PATH, so ExtTagParserParityTest skipped on every plain
`phpunit` run — 22 assertions silently not executing, exit code 0. The guard I
mutation-tested last review has therefore never run for anyone who did not
export a variable by hand.

It now finds the reference reader itself, by walking up from the test directory
looking for a sibling `edufeed-app` or `edufeed/edufeed-app`. Searching upwards
rather than at a fixed depth on purpose: a git worktree sits two levels deeper
than a normal checkout, and a hardcoded `../../..` finds nothing there — which
would put the guard straight back to skipping and reporting OK. It still skips
for a third party who has the plugin and not edufeed-app, since failing there
is noise; AMB_NOSTR_REQUIRE_PARITY=1 turns that skip into a failure. A path
that IS configured but does not resolve now fails outright rather than skipping,
so a typo cannot buy back the vacuous guard.

Verified in all four states rather than just the happy one:

    default (this layout)             49 tests, 121 assertions, 0 skipped
    EDUFEED_APP_PATH=/nonexistent     3 failures  (was: 3 skips)
    node absent, no flag              3 skips     (third-party case, correct)
    node absent, REQUIRE_PARITY=1     3 failures

No CI workflow, deliberately. Forgejo Actions is enabled on the repo but zero
runners are registered, so a workflow would queue forever and look like
coverage that does not exist — the same failure this commit is fixing.

**The facet signal had no robustness margin.** `hasLabel` was a boolean OR over
the whole sample, recorded even when the label attached to nothing, so a single
stray `creator:prefLabel:de` anywhere would promote creator's several hundred
values into a dropdown. Two changes: a label now only counts when it actually
attaches to an open `:id`, and the discriminator is a proportion — at least
half a family's distinct values must carry a label.

Measured, with an in-run control, on 1999 live events. One
`creator:prefLabel:de` injected immediately after a real `creator:id`:

    boolean rule (reverted in-run)   24 facets, creator PRESENT with 266 values
    proportion rule                  23 facets, creator absent

The threshold sits mid-gap, not on a boundary: the lowest real facet is
learningResourceType at 27 of 30 values labelled (0.90) and a single stray on
creator is 1 of 502 (0.002). The facet list is unchanged at 23 on the same
1999 events, so this costs no real facet. My first attempt at that control was
a no-op — the injected label landed on an event with no creator tags at all and
attached to nothing, which made both rules agree for the wrong reason.

**`count` meant different units depending on `source`.** The core path deduped
a value repeated within one event; the ext path did not. Same input gave
`count=1` on an amb facet and `count=2` on an ext one. The ext path now dedupes
per event too, so the documented unit is true on both.

Docblock corrected to TestOER's 8482-event census with the caveat stated
precisely — 38 publishers and zero violations, but one publisher is 91% of the
corpus — plus the measured memory ceiling: discover() holds the whole event set
and exceeds PHP's default 128 MB somewhere between 4000 and 8482 events, so it
takes pages, not corpora.

    amb-nostr 49/49, 121 assertions (PHP 8.3.32, PHPUnit 10.5.64)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Correcting the previous commit before it goes further. The flat
LABELLED_VALUE_RATIO of 0.5 was measured only at corpus scale, and
`discover()` runs per page.

Swept page sizes and measured the worst labelled ratio on real facets:

    page=10   about 0.500      page=100   about 0.571
    page=20   about 0.600      page=250   about 0.545
    page=50   about 0.600      page=1999  about 0.891

`about` lands exactly ON the threshold at page 10 — a threshold on a boundary
value, which is the trap I have written down and walked into anyway. Widening
the sweep to families the rule was *rejecting* rather than admitting showed the
real number is worse: 0.286 at page 10. A flat 0.5 silently deletes a real
facet from a small archive page, which is the common case, not the edge case.

The separation that actually holds is size-dependent, so the rule is now too:
the proportion applies only from 20 distinct values. Measured over the same
1999 events:

    page 10/20/50   no family reaches the gate; all 6 core facets admitted
    page 250/1999   real facets subject to the ratio measure 0.889 and up
    stray creator   1 of 502 = 0.002, rejected

Below the gate a single attached label is the signal, which is exact on live
data: entity families carry zero labels at every page size sampled
(10/20/50/250/1999). Above it there is enough evidence for a proportion, and
0.5 sits mid-gap between 0.002 and 0.889 rather than on either boundary.

Re-verified end to end: 23 facets on 1999 events, creator still absent with an
attached stray injected, and all 6 core facets admitted at every page size.

    amb-nostr 50/50, 122 assertions (PHP 8.3.32, PHPUnit 10.5.64)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
TestOER was right that RATIO_APPLIES_FROM had to go: `license` carries 17
distinct values corpus-wide, so it sits permanently below a 20-value gate at
every page size, and one stray `license:prefLabel:de` would admit it. That is
the family the docblock calls a deliberate exclusion, so the gate reopened
exactly the hole it was added to close.

Their other half does not reproduce here, and I checked before choosing. They
swept 206 strided pages of 8482 events and found the sub-0.5 window empty; over
1999 events in contiguous AND strided pages of 5-250 I find 10 real occurrences
— `about` as low as 2 of 5 labelled (0.400). Strided sampling contributed 3 of
them, so it is not purely contiguity; the samples differ. Since a flat 0.5
deletes `about` on those pages, neither rule is right.

What separates them is the labelled COUNT, measured rather than assumed:

    real facets inside the sub-0.5 window    10, ALL with >= 2 labelled values
    same window with exactly 1 labelled       0
    single-stray attack, by construction      exactly 1 labelled

So: a lone label only counts when it covers at least half the family's distinct
values. No size gate, no family-size special case.

Verified over 1999 live events:

    full sample            23 facets, license absent, creator absent
    single-stray attack    license, creator, isPartOf, publisher, hasPart
                           ALL rejected (injected after a real <family>:id so
                           it genuinely attaches)
    page sweep 5..250      all 6 core facets admitted at every size, none dropped

A family with one distinct value is admitted by any rule (1/1 = 1.0); pinned by
a test so it stays a known quantity rather than a surprise.

Also replaces testSmallFamilyIsAFacetOnASingleLabelledValue, which encoded a
shape the corpus does not produce (7 values, 1 labelled) and was my evidence for
the gate. It is replaced by the shape the corpus does produce: 5 values, 2
labelled.

    amb-nostr 51/51, 123 assertions (PHP 8.3.32, PHPUnit 10.5.64)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three rules on (labelled, distinct) failed in a row, and TestOER named
why: the classes collide in that feature space. A real `about` facet on a
small page is 1 labelled / 4 distinct; `isBasedOn` under a single stray
label is 1 labelled / 4 distinct. Identical input, opposite answers
required. Every threshold — boolean, ratio 0.5, size gate, minimum count
— just picks a side of that collision, so each one either deleted a real
facet (56 occurrences) or admitted an entity (license at any page size).

AMB already declares the answer. `<family>:type` carries the schema.org
class, censused over 4999 live events:

  Concept              about, learningResourceType, educationalLevel,
                       audience, conditionsOfAccess, teaches
  Person/Organization  creator, publisher, mainEntityOfPage:provider
  WebContent           mainEntityOfPage
  LearningResource     hasPart;   Course/LearningResource  isPartOf
  MediaObject          caption, encoding
  (undeclared)         license, creator:affiliation

Type alone is NOT enough, for the same reason issue #6 exists: the
declaration is missing on some events and the gaps CLUSTER BY PUBLISHER —
25 publishers never emit `learningResourceType:type`. So the signals are
complementary rather than alternative:

  1. a declared non-Concept type REJECTS   (cannot be faked by a tag)
  2. an attached label ADMITS              (permissive, never deletes)

Step 2 can go back to permissive precisely because step 1 removed the
entity families first; it now only judges the undeclared residue. That is
what every previous version got wrong — one test was being asked to do
both jobs.

Verified at EVERY offset, page sizes 5-250, 39520 pages: zero real facets
dropped, zero entities admitted. Stray-label attack rejected on every
family that declares a type. Controls: disabling step 1 readmits
creator/publisher under attack, so the rejection is load-bearing.

HONEST LIMIT: my own corpus (4999 events, newest-first) does NOT
reproduce TestOER's 56 drops under the previous rule, so this sweep
under-covers theirs and the clean result is weaker evidence than the page
count suggests. The collision pair is therefore pinned directly as a unit
test instead of relying on the sweep.

KNOWN RESIDUE, pinned not hidden: an undeclared family is admitted by one
attached label — only `license` (17 distinct) and `creator:affiliation`
(6). Tightening it would delete learningResourceType on the 25 publishers
above.

amb-nostr 54/54, 127 assertions, 0 skipped

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both are documentation, no behaviour change. 55/55, 129 assertions.

1. THE RESIDUE IS FIVE FAMILIES, NOT TWO. Step 1 rejects a family that
   declares a non-Concept type ON THIS PAGE — the guarantee is per page and
   the declaration is not always present. `hasPart` and `isPartOf` declare
   one on 98%+ of events but not all, so on a page drawn from the events
   that omit it they are stray-reachable exactly like an undeclared family.
   TestOER found it; reproduced on my own independent slice with identical
   counts (hasPart 2 events, isPartOf 3). Pinned by a new test with a
   control showing one `:type` on the page kills the attack.

2. "STEP 2 CAN NEVER DELETE A REAL FACET" IS FALSE. It deletes 4450
   page-instances over a 67,384-page sweep — every drop in the sweep, none
   from step 1. That is issue #6, not a regression: the previous rule
   dropped the same 4450 plus the 56 real facets this one admits. Claim is
   now "never deletes a real facet THAT CARRIES A LABEL", with the number
   in the comment so the next reader cannot trust the stronger version.

Also reframed issue #6 on the user-visible unit. Page-instances and
unlabelled-event counts rank the families differently and both are right:
lrt has more unlabelled events (95 vs 75 on my slice), conditionsOfAccess
loses more pages (2431 vs 1967), because a page only loses the facet when
EVERY facet-carrying event on it is unlabelled — clustering dominates. A
vanished dropdown is a page-level event, so the page unit is the one that
matters and conditionsOfAccess leads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
laoc merged commit 81d3dba64a into main 2026-07-31 21:53:04 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
edufeed/wp-plugin-amb-nostr!5
No description provided.