feat(amb-nostr): ExtTagParser for the NIP-AMB ext: grammar, plus a test harness #4
Loading…
Reference in a new issue
No description provided.
Delete branch "feature/ext-tag-parser"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
First prerequisite for the server-rendered archive/facet/detail routes. Pure PHP, no WordPress, no network — needed identically under either architecture option, so it is decision-independent and safe to land before that call is made.
What
ExtTagParserimplements the normative left-anchored grammar fromnips/AMB.md:<ns>and<facet>contain no colon, so keys are parsed with fixed arity and a key outside the grammar is dropped rather than guessed at. Rejection is per key — a legacyext:ekw:konfi:themen:idvanishes while its conforming siblings on the same event still parse. That mirrors the relay-side reader (nostrlibparseExtKey).Behaviour is deliberately identical to the reference JS reader in edufeed-app (
src/lib/helpers/educational/parseExtensionTags.js), mixed facets included.How it was verified
Not just "tests pass" — parity against the reference reader on live data. Both parsers were run over the same 100-event sample from
amb-relay.edufeed.org(kind 30142,#l=ekw; 32 carry ext tags, 82 facet instances, 2 namespaces) and their canonicalised output is byte-identical.That sample also proves the drop is a drop and not a mis-bucketing: no facet named
konfiappears anywhere, while the migratedorg.edufeed.ekw.konfi:*facets parse normally. Both classes are present in the committed fixture.Also in here
amb-nostrwas the only module with nophpunit.xmland notests/— this adds both, copyingshared/'s setup.composer updateadds dev dependencies only; no runtime package changed. The one runtime-looking line incomposer.lockis the local path repository recording the current branch name, which it does on every branch.What to look at
ExtTagParser::parseKey()— particularly that an illegalsubreturns null rather than falling back to a scalar reading.ekw:shape (LEGACY_NAMESPACES) is right.AMB.mdsays consumers MAY during transition and the JS reader does; I followed the reference rather than diverging, but it is a judgement call and easy to remove.parseKey()is public on purpose — facet discovery and filter building work from tag keys alone.Nothing is wired into the plugin yet, so this changes no rendered output.
First of the prerequisites for the server-rendered archive and facet routes. Pure PHP, no WordPress, no network — needed identically whether we serve pages straight off the relay or off a local index, so it is decision-independent. Implements the normative left-anchored grammar from nips/AMB.md: ext-key = "ext" ":" ns ":" facet [ ":" sub ] sub = "id" / "type" / "name" / "prefLabel" ":" lang `<ns>` and `<facet>` contain no colon, so keys are parsed with fixed arity and a key outside the grammar is dropped rather than guessed at. Rejection is per key: a legacy `ext:ekw:konfi:themen:id` on an event vanishes while its conforming siblings on the same event still parse. That mirrors the relay-side reader (nostrlib parseExtKey) and is asserted on real corpus data, not just on hand-written tags. Behaviour is deliberately identical to the reference JS reader in edufeed-app (`src/lib/helpers/educational/parseExtensionTags.js`), mixed facets included — concepts and scalars accumulate independently under one (ns, facet) pair. The two readers drifting apart is what the NIP's parsing rule exists to prevent. Verified as parity, not just as passing tests: both parsers were run over the same 100-event live sample from amb-relay (kind 30142, `#l=ekw`, 32 events carrying ext tags, 82 facet instances across two namespaces) and their canonicalised output is byte-identical. That sample also confirms the drop is a drop and not a mis-bucketing — no facet named `konfi` appears anywhere, while the migrated `org.edufeed.ekw.konfi:*` facets parse normally. `parseKey()` is public because facet discovery and filter building work from tag keys alone, without reading values. amb-nostr was the only module with no phpunit.xml and no tests/; this adds both, copying shared/'s setup. `composer update` adds dev dependencies only — no runtime package changed. The one runtime-looking line in composer.lock is the local path repository recording the current branch name, which it does on every branch. amb-nostr 21/21, 54 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 applied in
c8b430f. No behaviour change toExtTagParser.The parity guard — and the fact that it was vacuous when I first wrote it
Built the
@group paritytest as suggested: it runs the real reference reader from phpunit, skipping (not failing) whennodeorEDUFEED_APP_PATHis absent.Then I mutated the parser to check the guard actually bites, and it didn't. Deleting the whole
:namebranch and breaking themixedkind derivation both left the two dumps byte-identical, because the live fixture exercises neither — it contains no:namesub and produces nomixedfacet. Exactly the point in your review: parity on live data is weaker than it sounds. My 100-event#l=ekwslice was denser than 8482 random events and still missed two whole code paths.Fixed by adding a 27-case grammar corpus (
tests/fixtures/parity-grammar.jsonl) covering every branch, including ones with no live instance. Re-verified by mutation::namehandling removedmixedkind derivation brokenLEGACY_NAMESPACESemptiedtypesub rejected inparseKeyThe last one is a genuine equivalence, not a hole: a
:typevalue is ignored and a type-only facet is dropped either way, so no observable output differs. Nothing in the suite catches it, and nothing should.Both readers agree on all 27 grammar cases — including mixed facets,
:name, empty-:id, surplus segments and the legacy unprefixed shape. That is a much stronger claim than the original one.Your two pinning items
testIntegerLikeNamesBecomeIntKeysand documented in the class docblock as a known divergence, with your reasoning (unfixable without abandoning name-keyed arrays; zero live instances; bites a downstreamis_string()/===on a facet name, not string interpolation).LEGACY_NAMESPACES— kept, with a comment recording that it is unexercised by live data (0 unprefixedekw:keys in 8482 events) and kept only to avoid diverging unilaterally from the reference reader.Also pinned the shared empty-
:id-then-prefLabelbehaviour you flagged, as reference behaviour rather than a port artefact.On the konfi data, before it reaches @laoc_buzz
Your measurement reproduces exactly — I get 10 legacy-only, 1 migrated, 0 both on the
#l=ekwslice. But "the migration script has effectively not been run" isn't quite the shape of it, and the distinction changes who can fix it:The one migrated resource is authored by
776c7bfe…— the migration key. The 10 stuck ones are authored by1c5ff3ca…(2) and64278b3f…(8). A republish under the migration key lands at a different address (different pubkey), so it creates a parallel resource instead of replacing the legacy one — and I confirmed the migrated event'sdtag collides with none of the 10.So re-running the script cannot fix those 10. It needs the original signing keys, which is a custody question, not a scheduling one.