Skip to content

[routing] Namespace-aware tokenizer split for dotted tool IDs (with measured impact) #213

Description

@dgenio

Problem statement

src/contextweaver/_utils.py:tokenize (the single source of truth per docs/agent-context/invariants.md) is regex-driven on word boundaries. A query like "search invoices by customer" against tool ID billing.invoices.search matches via tags and description, but not via the canonical name tokenbilling.invoices.search lexes as one token. The gold set IDs are uniformly dotted (admin.audit.export, crm.deals.search, analytics.dashboards.list, …).

Splitting at dots/underscores/hyphens within tokens — while keeping the original token — is a small, deterministic change that likely lifts R@5 substantially on this corpus. The lift should be measurable on multiple backends.

Proposed solution

In tokenize(), after normalisation, additionally split each token at ., _, -, / boundaries and emit both the original token and its sub-tokens. Stay deterministic, stay stdlib. Add tests asserting tokenize("crm.deals.search") contains {"crm.deals.search", "crm", "deals", "search"}. Measure impact via the per-backend matrix on the current v0.3.0 baseline.

Round 2 Q2=C strict-regression-direction-only-lift posture: regression bounds are strict (no cell may drop more than 1pp); lift target is direction-only (≥1 cell must improve, magnitude not pre-specified).

Alternatives considered

  • Light stemming (Porter, snowball). Rejected — adds nontrivial complexity for a corpus where the high-leverage split is mechanical (delimiter-based), not morphological.
  • Tokenizer protocol abstraction. Rejected — the single-source-of-truth invariant exists for a reason; pluggability is a future concern. Keep the change minimal.
  • Change STOPWORDS instead. Rejected — the issue isn't stopword filtering; the issue is that compound IDs never get split.

Affected modules

src/contextweaver/_utils.py (the tokenize function only), tests/test_utils.py

Estimated effort

S (1–2 days)

Baseline

Current R@5 on 50-query gold (TF-IDF): 0.74 / 0.61 / 0.31 at sizes 50 / 83 / 1000 (benchmarks/README.md:60-65).

After the per-backend matrix issue lands, baseline expands to 9 cells (3 backends × 3 sizes).

Success metric

  • Direction-only lift target: R@5 must improve on at least one matrix cell (any backend, any size).
  • Strict regression bound: no matrix cell may drop more than 1pp on R@5.
  • Strict latency bound: no matrix cell may exceed baseline_p99 × 1.30 (Round 2 Q5=C).

Evaluation plan

Run make benchmark-matrix before and after; record both latest.jsons. The scorecard delta (from the regression-PR-comment issue) is the primary evidence. Paste the before/after table into the PR description (Round 2 Q3=B Reproducibility subsection).

Scope

src/contextweaver/_utils.py (tokenize only) + tests/test_utils.py + acceptance evidence in PR description.

Non-goals

  • No stemming.
  • No language detection.
  • No tokenizer protocol abstraction.
  • No router changes.
  • No new public API.
  • No change to STOPWORDS.

Design constraints

  • Deterministic. Same input → same set across N=100 calls.
  • Sub-tokens emitted in addition to (not in place of) the original token.
  • STOPWORDS still applied to all tokens (original + sub-tokens).
  • Single source of truth invariant (docs/agent-context/invariants.md) preserved.
  • No new dependencies.

Depends on

  • Per-backend × per-size benchmark matrix (needed to measure impact across backends).
  • Soft regression PR comment (makes the PR review trivial — paste the comment into the PR body).

Suggested order

After the matrix + regression-comment issues land. Cannot ship without the matrix to prove no per-backend regression.

Acceptance Criteria (STRICT)

  • tokenize("crm.deals.search") returns a set containing {"crm", "deals", "search"} (plus the dotted form unless it falls in STOPWORDS — it won't)
  • Determinism test: tokenize(x) == tokenize(x) across N=100 calls
  • No matrix cell shows R@5 regression > -1pp on the current gold set (strict)
  • At least one matrix cell shows measurable R@5 lift (direction-only)
  • No latency regression > +30% on any p99 cell (strict — ⚠️-marker check)
  • make ci passes; all 871+ existing tests still green
  • PR body includes a before/after table copied from the regression PR comment (Round 2 Q3=B reproducibility)

Validation

# Before merging, on the PR branch:
make benchmark-matrix
make scorecard
git diff benchmarks/scorecard.md  # paste into PR body

The regression PR comment will fire automatically on push.

Files likely touched

src/contextweaver/_utils.py, tests/test_utils.py

Risk / rollback

Low — local change to one function. Revert is a single git revert; existing baselines preserved.

Security/privacy notes

None. Tokenizer is pure text transform with no I/O.

Agent-ready notes

  • Starting points: src/contextweaver/_utils.py:tokenize — single function to modify. tests/test_utils.py for test patterns.
  • Constraints: do NOT introduce a TokenizerProtocol here (out of scope). Do NOT change STOPWORDS. Determinism is a hard invariant — must be preserved.
  • Review focus for Copilot code review:
    • Determinism on edge cases (empty string, single dot, trailing punctuation, unicode).
    • Sub-tokens go through STOPWORDS filtering (don't accidentally leak filtered words back in).
    • Performance: the additional splitting should not measurably affect p99 latency (verify via scorecard).
    • The split must not change behavior for non-dotted tokens (existing 871 tests cover this).

Cross-references

  • Composes with the per-backend × per-size matrix and the regression PR comment — both are prerequisites for measuring impact safely.
  • First product feature in the cycle that's net-new (not previously tracked as a GitHub issue).

Size

S

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/contextContext engine: manager, pipeline, firewallarea/routingRouting engine: catalog, graph, router, cardscomplexity/simpleStraightforward change, minimal riskenhancementNew feature or requestpriority/highHigh priority — closes a critical gap

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions