qa/source_roles: extract _classify_source_role + weights to own module

Phase 1 step 1 of #53 (collapse legacy 2000-line query() into the
unified run_query orchestrator). Pure code motion — no behavior
change, no signature change. Lifts:

  - SOURCE_ROLE_BUDGET_WEIGHTS / SOURCE_ROLE_RANK_WEIGHTS
  - _NOISY/_SEQUEL/_SECONDARY title-marker tuples
  - _classify_source_role function

from query.py:781-854,1579-1591 to a new arborist/qa/source_roles.py
module. query.py re-exports under the old names so existing imports
(and the 45-test test_query.py suite) keep working.

Goal: run_query needs the source-role classifier to do role-weighted
context-budget splits (Plan §5 Phase 1 deliverable 1). Until this
file exists, run_query has no import path to it that doesn't pull in
the full 4280-line query.py module. Lazy-imports `_title_query_tokens`
and `_stem_token_for_match` from query.py for now — those move to
_text_norm.py in a later Phase 1 step.

Validation: 45 tests in test_query.py pass; 257 tests in the broader
query/corpus/sidecar/wallet/bucket/claim_lattice gate pass.
This commit is contained in:
russell@unturf.com 2026-05-31 11:40:43 -04:00
parent 2b8c12303b
commit 9ba6317382
No known key found for this signature in database
2 changed files with 124 additions and 92 deletions

View file

@ -772,86 +772,17 @@ class _Hit:
source_role: str = "unclassified"
# Heuristic role classification + per-role budget multiplier. Lets the
# primary answer page (e.g. `Jurassic Park (film)` for a JP film query)
# claim a wider context slice than peripheral pages (`Jurassic Park (film
# score)`, `Jurassic Park video games`). The running `char_budget` check
# still bounds total context to `max_context_chars`; weights just shift
# how the budget gets divided.
SOURCE_ROLE_BUDGET_WEIGHTS = {
"primary_answer_source": 2.0,
"secondary_context_source": 1.0,
"noisy_background_source": 0.5,
"sequel_background_source": 0.5,
"background_source": 1.0,
"unclassified": 1.0,
# Self-promoted providence records (STRICT live, past kindergarten
# window). Same budget weight as background — Wikipedia stays the
# canonical primary; self-reference is supplementary anchoring.
# Trust model: STRICT-as-fact unless the verifier falsifies it.
"self_reference_source": 1.0,
}
# Title patterns that demote a source's role. Lower-cased substring match.
# 2026-04-30: extended to catch tie-in spinoff titles. The JP-dinosaurs
# query lazy-anchored on "Jurassic Park: Operation Genesis" (a video game)
# whose enumerative dinosaur tables pattern-matched the question shape
# more cleanly than the actual film article's prose. Adding the explicit
# game subtitle plus generic markers ("the game", "video games") so
# similar tie-ins classify as noisy and drop out of the evidence map.
_NOISY_TITLE_MARKERS = (
"score", "music", "soundtrack", "video game", "video games",
"merchandise", "discography", "operation genesis", "the game",
# Source-role classifier + per-role weighting now live in
# arborist.qa.source_roles. Re-export under the old names so callers
# (and tests) that import from here keep working until the rest of
# Phase 1 lands.
from arborist.qa.source_roles import (
SOURCE_ROLE_BUDGET_WEIGHTS,
classify_source_role as _classify_source_role,
_NOISY_TITLE_MARKERS,
_SEQUEL_TITLE_MARKERS,
_SECONDARY_TITLE_MARKERS,
)
_SEQUEL_TITLE_MARKERS = (
" iii", " ii)", " ii ", " iv", " v ", " v)", "lost world", "sequel",
" 2)", " 3)", " 4)",
)
_SECONDARY_TITLE_MARKERS = (
"list of", "characters", "franchise", "history of", "people",
"timeline of",
)
def _classify_source_role(
title: str | None,
qtokens_stem: set[str],
*,
document_uri: str | None = None,
) -> str:
"""Tag a source by its likely role for an N-token query.
URI-scheme classification fires first: documents whose URI starts
with ``arborist://providence/`` are self-promoted providence
records (per ``arborist/sources/providence.py``) and classify as
``self_reference_source`` regardless of title shape that role
captures the trust model "STRICT-as-fact unless verifier
falsifies."
Order matters for the title-based fallback: noisy/sequel/secondary
markers fire first because they catch peripheral pages whose
titles otherwise overlap query tokens fully (e.g. `Jurassic Park
(film score)` shares 3 stems with `{dinosaur, jurassic, park,
film}` but is not the primary answer source for a dinosaurs
question). Primary requires the strongest title coverage (N-1
of N stems present).
"""
if document_uri and document_uri.startswith("arborist://providence/"):
return "self_reference_source"
if not title:
return "unclassified"
t = title.lower()
if any(k in t for k in _NOISY_TITLE_MARKERS):
return "noisy_background_source"
if any(k in t for k in _SEQUEL_TITLE_MARKERS):
return "sequel_background_source"
if any(k in t for k in _SECONDARY_TITLE_MARKERS):
return "secondary_context_source"
title_tokens = _title_query_tokens(t.replace("_", " "))
title_stems = {_stem_token_for_match(tok) for tok in title_tokens}
if qtokens_stem and len(title_stems & qtokens_stem) >= max(1, len(qtokens_stem) - 1):
return "primary_answer_source"
return "background_source"
def _search_titles(conn, qtokens: list[str], limit: int) -> list[tuple]:
@ -1576,19 +1507,7 @@ def _rerank(
# list-pages and franchise/sequel siblings even when the list-page wins
# on body-density. Tuned against the JP-dinosaurs and "where is florida"
# defects.
SOURCE_ROLE_RANK_WEIGHTS = {
"primary_answer_source": 2.0,
"secondary_context_source": 0.7,
"background_source": 0.9,
"noisy_background_source": 0.3,
"sequel_background_source": 0.3,
"unclassified": 1.0,
# Self-reference: STRICT live providence records past kindergarten
# window. Treated like background — Wikipedia stays canonical
# primary; self-reference is supplementary anchoring trusted as
# fact unless the verifier falsifies the underlying record.
"self_reference_source": 0.9,
}
from arborist.qa.source_roles import SOURCE_ROLE_RANK_WEIGHTS # noqa: E402
def _rerank_by_source_role(hits: list[_Hit], question: str) -> list[_Hit]:

113
arborist/qa/source_roles.py Normal file
View file

@ -0,0 +1,113 @@
"""Source-role classifier + per-role weighting (Phase 1 of legacy
``query.py`` collapse, ticket #53).
Pure-function module: takes a document title + question-stem set and
returns one of seven role tokens used by the retrieval orchestrator
to (a) bias rank scores and (b) split the per-source context-char
budget. Same logic the legacy ``_classify_source_role`` shipped this
file is the un-import-cycled home so ``run_query`` and ``query``
share one implementation.
Imports ``_title_query_tokens`` + ``_stem_token_for_match`` from
``arborist.qa.query`` for now; those helpers will move to
``arborist.qa._text_norm`` in a later Phase 1 step.
"""
from __future__ import annotations
# Per-role multiplier when allocating the per-source slice of the
# total context budget. Primary answer source claims ~2× the slice;
# noisy/sequel pages claim ~0.5×. Running ``char_budget`` deduction
# in the orchestrator still bounds total context — weights just shift
# how the budget gets divided.
SOURCE_ROLE_BUDGET_WEIGHTS = {
"primary_answer_source": 2.0,
"secondary_context_source": 1.0,
"noisy_background_source": 0.5,
"sequel_background_source": 0.5,
"background_source": 1.0,
"unclassified": 1.0,
# STRICT-live providence records past the kindergarten window.
# Wikipedia stays canonical primary; self-reference is supplementary
# anchoring trusted as fact unless the verifier falsifies it.
"self_reference_source": 1.0,
}
# Per-role score multiplier applied during the rerank step.
# Primary gets +2.0× promotion; noisy/sequel get demoted to 0.3×.
# Mismatch with BUDGET_WEIGHTS is intentional — rank order and budget
# share are different policy levers.
SOURCE_ROLE_RANK_WEIGHTS = {
"primary_answer_source": 2.0,
"secondary_context_source": 0.7,
"background_source": 0.9,
"noisy_background_source": 0.3,
"sequel_background_source": 0.3,
"unclassified": 1.0,
"self_reference_source": 0.9,
}
# Title patterns that demote a source's role. Lower-cased substring.
# 2026-04-30: extended to catch tie-in spinoff titles (the JP-dinosaurs
# query lazy-anchored on "Jurassic Park: Operation Genesis" — a video
# game whose enumerative dinosaur tables pattern-matched the question
# shape more cleanly than the actual film article's prose).
_NOISY_TITLE_MARKERS = (
"score", "music", "soundtrack", "video game", "video games",
"merchandise", "discography", "operation genesis", "the game",
)
_SEQUEL_TITLE_MARKERS = (
" iii", " ii)", " ii ", " iv", " v ", " v)", "lost world", "sequel",
" 2)", " 3)", " 4)",
)
_SECONDARY_TITLE_MARKERS = (
"list of", "characters", "franchise", "history of", "people",
"timeline of",
)
def classify_source_role(
title: str | None,
qtokens_stem: set[str],
*,
document_uri: str | None = None,
) -> str:
"""Tag a source by its likely role for an N-token query.
URI-scheme classification fires first: documents whose URI starts
with ``arborist://providence/`` are self-promoted providence
records (per ``arborist/sources/providence.py``) and classify as
``self_reference_source`` regardless of title shape that role
captures the trust model "STRICT-as-fact unless verifier
falsifies."
Order matters for the title-based fallback: noisy/sequel/secondary
markers fire first because they catch peripheral pages whose
titles otherwise overlap query tokens fully (e.g. ``Jurassic Park
(film score)`` shares 3 stems with ``{dinosaur, jurassic, park,
film}`` but is not the primary answer source for a dinosaurs
question). Primary requires the strongest title coverage (N-1
of N stems present).
"""
if document_uri and document_uri.startswith("arborist://providence/"):
return "self_reference_source"
if not title:
return "unclassified"
t = title.lower()
if any(k in t for k in _NOISY_TITLE_MARKERS):
return "noisy_background_source"
if any(k in t for k in _SEQUEL_TITLE_MARKERS):
return "sequel_background_source"
if any(k in t for k in _SECONDARY_TITLE_MARKERS):
return "secondary_context_source"
# Lazy import — `_title_query_tokens` + `_stem_token_for_match`
# live in query.py today; they move to _text_norm.py in a later
# Phase 1 step. Import-cycle-safe by deferring to call time.
from arborist.qa.query import _title_query_tokens, _stem_token_for_match
title_tokens = _title_query_tokens(t.replace("_", " "))
title_stems = {_stem_token_for_match(tok) for tok in title_tokens}
if qtokens_stem and len(title_stems & qtokens_stem) >= max(
1, len(qtokens_stem) - 1
):
return "primary_answer_source"
return "background_source"