Skip to content

feat(store): tag controlled-vocabulary alias map + canonicalization backfill (#653) - #680

Open
norrietaylor wants to merge 1 commit into
mainfrom
feat/653-tag-controlled-vocab
Open

feat(store): tag controlled-vocabulary alias map + canonicalization backfill (#653)#680
norrietaylor wants to merge 1 commit into
mainfrom
feat/653-tag-controlled-vocab

Conversation

@norrietaylor

@norrietaylor norrietaylor commented Jun 25, 2026

Copy link
Copy Markdown
Owner

What

Phase 1 of finishing epic #653 — the controlled-vocabulary / alias map (ontology #3). De-fragments tags (e.g. domain/sandbox vs domain/build/sandboxing) so co-occurrence edges and entity promotion key off a single canonical form. Inert on upgrade (empty default alias map).

Changes

  • feeds/tags.py — pure canonicalize_tag / canonicalize_tags: alias substitution → optional namespace normalization → order-preserving dedupe. Wraps, does not modify, normalize_tag so existing entity-node keys stay stable. Whole-tag match only (no substring matching); idempotent given a flattened map.
  • config.pyTagsConfig.aliases (alias -> canonical). The parser validates tag grammar on keys/values, flattens chains (a→b→c ⇒ a→c), and rejects cycles at load time.
  • storecanonicalize_existing_tags() backfill: rewrites stored tags through the alias map, idempotently (only changed rows written), with no re-embedding (tags don't feed embeddings) and updated_at untouched (preserves recency signals). Exposed as distillery_relations action="canonicalize_tags".
  • promote_entities() is now alias-aware — resolves aliases before the separator-collapse, so an aliased variant (entity/cloudflare-sandboxes) promotes to the same node as its canonical form (entity/cloudflare).
  • distillery.yaml.example — documented aliases sample.

Operational order (for Phase 4)

Run canonicalize_tags before promote_entities so entity nodes key off canonical tags. (No production data is touched by this PR — the rollout is gated to Phase 4.)

Deferred to a follow-up PR (tracked)

  • Write-path choke-point (canonicalize new writes via the store) and merge-site canonicalization (poller/classify/gh-sync).
  • known_namespaces + namespace_enforcement (off/warn/strict) gating of unknown namespaces.

Neither is required for the rollout — the backfill cleans existing data, and the feed poller already applies normalize_tag when enforce_namespaces is on. Splitting keeps this PR focused (matches the design's recommended PR-1/PR-2 split).

Testing

  • Pure canonicalization (alias collapse, alias-before-normalize precedence, idempotency, substring safety, dedupe).
  • Config parser (chain flatten, cycle → ValueError, invalid key/value).
  • Idempotent backfill + within-entry dedupe; alias-aware promotion; backfill-then-promote lands on the canonical node; MCP action happy/no-op/error paths.
  • Full suite: 3229 passed, 79 skipped; mypy --strict src/ clean (73 files); ruff check src/ tests/ clean.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added optional tag aliasing support so related tags can be treated as a single canonical tag.
    • Added a new tag-canonicalization action to update existing records in bulk.
    • Entity promotion now uses canonical tags more consistently, reducing duplicate entity variants.
  • Bug Fixes

    • Improved tag handling to flatten alias chains, reject invalid alias loops, and deduplicate repeated tags while preserving order.
    • Existing data can now be backfilled to align stored tags with the canonical vocabulary.

…ackfill (#653)

Implements the core of epic #653 ontology #3 — a controlled-vocabulary alias
map that de-fragments tags so co-occurrence edges and entity promotion key off a
single canonical form.

- feeds/tags.py: pure canonicalize_tag / canonicalize_tags (alias substitution
  then optional namespace normalization, order-preserving dedupe). Wraps — does
  not modify — the existing normalize_tag so entity-node keys stay stable.
- config.py: TagsConfig.aliases (alias -> canonical map). Parser validates tag
  grammar, flattens chains (a->b->c => a->c), and rejects cycles at load time.
- store: canonicalize_existing_tags() backfill — rewrites stored tags through
  the alias map, idempotently (only changed rows written), no re-embedding,
  updated_at untouched so recency signals are preserved. Exposed as
  distillery_relations action="canonicalize_tags".
- promote_entities() is now alias-aware (resolves aliases before the
  separator-collapse), so aliased variants promote to one node.
- distillery.yaml.example: documented aliases sample.

Operational order (Phase 4): run canonicalize_tags before promote_entities so
entity nodes key off canonical tags.

Deferred to a follow-up: write-path choke-point (canonicalize NEW writes) and
known_namespaces / namespace_enforcement gating — not required for the backfill.

Tests: pure canonicalization, config flatten/cycle/validation, idempotent
backfill + within-entry dedupe, alias-aware promotion, MCP action. Full suite
3229 passed; mypy --strict and ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Tag aliases can now be declared in config, canonicalized through new tag helpers, applied during entity promotion and tag backfill, and exposed through a new canonicalize_tags relations action. Tests cover parsing, canonicalization, promotion, backfill, and tool responses.

Changes

Tag alias canonicalization

Layer / File(s) Summary
Alias config parsing
distillery.yaml.example, src/distillery/config.py, tests/test_config.py
tags.aliases is documented, parsed, flattened, and stored in TagsConfig, with config tests covering parsing and error cases.
Tag canonicalization helpers
src/distillery/feeds/tags.py, tests/test_tag_canonicalization.py
canonicalize_tag() and canonicalize_tags() are added for alias-aware tag canonicalization and order-preserving deduplication, with unit tests for alias and namespace cases.
Alias-aware promotion
src/distillery/store/protocol.py, src/distillery/store/duckdb.py, src/distillery/mcp/tools/relations.py, tests/test_entity_promotion.py
The store promotion protocol, DuckDB promotion plan, MCP promote_entities wiring, and tests now accept alias maps and route aliased entity tags to canonical nodes.
Existing-tag backfill
src/distillery/store/protocol.py, src/distillery/store/duckdb.py, tests/test_entity_promotion.py
canonicalize_existing_tags is added to the store protocol and DuckDB implementation; tests cover tag rewrite, deduplication, and idempotent reruns.
Relations canonicalize_tags action
src/distillery/mcp/server.py, src/distillery/mcp/tools/relations.py, tests/test_mcp_tools/test_relations_canonicalize_tags.py
The relations tool docs and action validation add canonicalize_tags; the handler calls the store backfill method and tests cover success, no-op, and INTERNAL failure.

Sequence Diagram(s)

sequenceDiagram
  participant distillery_relations
  participant DuckDBStore
  participant distillery.feeds.tags
  distillery_relations->>DuckDBStore: canonicalize_existing_tags(aliases, reserved_prefixes, normalize_namespaces)
  DuckDBStore->>distillery.feeds.tags: canonicalize_tags(active tags, aliases, reserved_prefixes, normalize_namespaces)
  distillery.feeds.tags-->>DuckDBStore: canonical tag lists
  DuckDBStore-->>distillery_relations: entries_scanned, entries_rewritten, tags_collapsed
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

I hopped through aliases, soft and neat,
turned many tags to one true treat.
Backfill booped by moonlit light,
and promote hopped to the right bite.
🐇 Hooray for canonical carrot-sweet!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding a controlled-vocabulary tag alias map and canonicalization backfill.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/distillery/config.py`:
- Around line 910-932: The alias flattening in _flatten_alias_map only resolves
exact keys, so it can miss second hops that appear after namespace normalization
in canonicalize_tag. Update the config loading/canonicalization flow so aliases
are flattened or validated against normalized names once reserved_prefixes are
known, or make canonicalize_tag iterate to a fixed point with cycle protection.
Ensure the fix covers the alias parsing path around _flatten_alias_map and the
config handling that builds aliases before reserved_prefixes.

In `@src/distillery/mcp/server.py`:
- Around line 1376-1378: The action list already includes canonicalize_tags, but
the RETURNS documentation in the MCP server handler still omits its success
payload. Update the response contract in server.py near the main action/returns
docs to add canonicalize_tags alongside the existing promote_entities entry, and
document the entries_scanned, entries_rewritten, and tags_collapsed fields so
clients can rely on the full success shape.

In `@src/distillery/store/duckdb.py`:
- Around line 4708-4713: The canonical tag lookup in the entity promotion flow
is missing pre-existing aliased entity nodes, which can lead to duplicate
canonical nodes. Update the promotion logic around canonicalize_tag so existing
entity-node metadata.source_tag values are normalized with the same
alias/namespace rules before the exact source_tag = canonical lookup, and reuse
or migrate any matching node before creating a new one. Use the entity-node
creation/promotion path and the canonicalize_tag callsite as the main anchors
when adjusting the lookup and insertion behavior.
- Around line 4907-4920: The bulk tag rewrite in the entries update path commits
successfully but does not verify that the rewritten tags can be read back, so
add a bounded post-commit read-back in the same flow (or extend the existing
integrity check) to materialize the touched entries.tags values for the updated
row set before calling the checkpoint helper. Use the update loop that builds
updates and the surrounding transaction block to locate the fix, and ensure any
read-back failure raises before the operation reports success.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 67a2af73-7317-4edd-b3ec-325caa102a61

📥 Commits

Reviewing files that changed from the base of the PR and between bb66fe4 and 8f37bc5.

📒 Files selected for processing (11)
  • distillery.yaml.example
  • src/distillery/config.py
  • src/distillery/feeds/tags.py
  • src/distillery/mcp/server.py
  • src/distillery/mcp/tools/relations.py
  • src/distillery/store/duckdb.py
  • src/distillery/store/protocol.py
  • tests/test_config.py
  • tests/test_entity_promotion.py
  • tests/test_mcp_tools/test_relations_canonicalize_tags.py
  • tests/test_tag_canonicalization.py

Comment thread src/distillery/config.py
Comment on lines +910 to +932
def _flatten_alias_map(aliases: dict[str, str]) -> dict[str, str]:
"""Resolve alias chains to a single hop and reject cycles.

``{"a": "b", "b": "c"}`` flattens to ``{"a": "c", "b": "c"}`` so the runtime
lookup is O(1) and idempotent. A key mapping to itself is a permitted no-op.
A true cycle (``a -> b -> a``) raises ``ValueError``.
"""
flat: dict[str, str] = {}
for key in aliases:
seen = [key]
cur = key
while cur in aliases:
nxt = aliases[cur]
if nxt == cur: # self-map: terminal, no-op
break
if nxt in seen:
raise ValueError(
"tags.aliases contains a cycle: " + " -> ".join([*seen, nxt])
)
seen.append(nxt)
cur = nxt
flat[key] = cur
return flat

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Flattening misses alias hops that only appear after namespace normalization.

Line 921 only follows exact alias keys, and Line 988 parses aliases before reserved_prefixes are available. That leaves configs like project/sbx -> entity/cloudflare/workers plus entity/cloudflare-workers -> entity/cloudflare partially flattened: a single canonicalize_tag(..., normalize_namespaces=True) pass stops at entity/cloudflare-workers, while a second pass reaches entity/cloudflare. Because promotion/backfill call canonicalization once, this can persist or group on the intermediate tag instead of the true canonical one.

Please either flatten/reject alias graphs with normalization-induced second hops at load time, or make the runtime canonicalization reach a fixed point with cycle protection.

Also applies to: 988-1009

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/distillery/config.py` around lines 910 - 932, The alias flattening in
_flatten_alias_map only resolves exact keys, so it can miss second hops that
appear after namespace normalization in canonicalize_tag. Update the config
loading/canonicalization flow so aliases are flattened or validated against
normalized names once reserved_prefixes are known, or make canonicalize_tag
iterate to a fixed point with cycle protection. Ensure the fix covers the alias
parsing path around _flatten_alias_map and the config handling that builds
aliases before reserved_prefixes.

Comment on lines +1376 to +1378
- action (str, required): Operation. Valid: [add, get, remove, traverse, metrics,
reconcile, list_candidates, resolve_candidate, suggest_links, promote_entities,
canonicalize_tags].

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Document the canonicalize_tags success payload too.

The action is listed as valid, but the RETURNS section still stops at promote_entities, so clients do not see the entries_scanned / entries_rewritten / tags_collapsed response contract.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/distillery/mcp/server.py` around lines 1376 - 1378, The action list
already includes canonicalize_tags, but the RETURNS documentation in the MCP
server handler still omits its success payload. Update the response contract in
server.py near the main action/returns docs to add canonicalize_tags alongside
the existing promote_entities entry, and document the entries_scanned,
entries_rewritten, and tags_collapsed fields so clients can rely on the full
success shape.

Comment on lines +4708 to +4713
canonical = canonicalize_tag(
tag,
aliases=aliases,
reserved_prefixes=reserved_prefixes,
normalize_namespaces=True,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Reuse pre-existing aliased entity nodes before creating canonical ones.

Now that member tags are canonicalized here, an entity node created before aliases with metadata.source_tag="entity/cloudflare-sandboxes" is missed by the later exact source_tag = canonical lookup, so promotion can create a duplicate canonical entity node. Canonicalize existing entity-node source_tag values with the same alias/namespace rules and reuse or migrate that node before insertion.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/distillery/store/duckdb.py` around lines 4708 - 4713, The canonical tag
lookup in the entity promotion flow is missing pre-existing aliased entity
nodes, which can lead to duplicate canonical nodes. Update the promotion logic
around canonicalize_tag so existing entity-node metadata.source_tag values are
normalized with the same alias/namespace rules before the exact source_tag =
canonical lookup, and reuse or migrate any matching node before creating a new
one. Use the entity-node creation/promotion path and the canonicalize_tag
callsite as the main anchors when adjusting the lookup and insertion behavior.

Comment on lines +4907 to +4920
if updates:
try:
conn.execute("BEGIN TRANSACTION")
for new_tags, entry_id in updates:
conn.execute(
"UPDATE entries SET tags = ? WHERE id = ?",
[new_tags, entry_id],
)
conn.execute("COMMIT")
except Exception:
with contextlib.suppress(Exception):
conn.execute("ROLLBACK")
raise
self._checkpoint_after_write(conn)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Add read-back verification after the bulk tag rewrite.

This backfill rewrites entries.tags across many rows, but after commit it only checkpoints. Add a bounded read-back that materializes the touched tags column (or extend the existing integrity verifier to include it) so tag-page corruption is caught before reporting success.

Suggested direction
-            self._checkpoint_after_write(conn)
+            touched_ids = [entry_id for _, entry_id in updates]
+            self._checkpoint_after_write(conn)
+            conn.execute(
+                "SELECT id, tags FROM entries WHERE id = ANY(?)",
+                [touched_ids],
+            ).fetchall()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if updates:
try:
conn.execute("BEGIN TRANSACTION")
for new_tags, entry_id in updates:
conn.execute(
"UPDATE entries SET tags = ? WHERE id = ?",
[new_tags, entry_id],
)
conn.execute("COMMIT")
except Exception:
with contextlib.suppress(Exception):
conn.execute("ROLLBACK")
raise
self._checkpoint_after_write(conn)
if updates:
try:
conn.execute("BEGIN TRANSACTION")
for new_tags, entry_id in updates:
conn.execute(
"UPDATE entries SET tags = ? WHERE id = ?",
[new_tags, entry_id],
)
conn.execute("COMMIT")
except Exception:
with contextlib.suppress(Exception):
conn.execute("ROLLBACK")
raise
touched_ids = [entry_id for _, entry_id in updates]
self._checkpoint_after_write(conn)
conn.execute(
"SELECT id, tags FROM entries WHERE id = ANY(?)",
[touched_ids],
).fetchall()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/distillery/store/duckdb.py` around lines 4907 - 4920, The bulk tag
rewrite in the entries update path commits successfully but does not verify that
the rewritten tags can be read back, so add a bounded post-commit read-back in
the same flow (or extend the existing integrity check) to materialize the
touched entries.tags values for the updated row set before calling the
checkpoint helper. Use the update loop that builds updates and the surrounding
transaction block to locate the fix, and ensure any read-back failure raises
before the operation reports success.

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